diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index c00e752..54e4c21 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -23,3 +23,17 @@ {"id":"int-826011e5ee8ee8d205e7e838693c8fa1","kind":"field_change","created_at":"2026-07-17T14:52:45.121813Z","actor":"Val Alexander","issue_id":"threads-986.17.9","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Phase 4 FREEZE executed 2026-07-17 (Val approval recorded on this bead 2026-07-15; gate:val-phase4-freeze satisfied). Epic AC verified: (1) all four surfaces live+functional behind fixtures adapter (daemon flip filed as threads-v3g) — /weaves rail, thread pane, strand inspector, /proposals approval, merged in coven-cave PR #3223; (2) metaphor-referent binding preserved — Nova sign-off .17.8 closed 2026-07-17 with all three coherence checks PASS, no findings; source-pin suites hold referents in CI; (3) Charm pass complete (.17.7); (4) proposal flow E2E over real staged fixtures (.17.6); contract spec landed on main via PR #1 (a55d99af1). Children .17.1-.17.8 + .17.10 all closed with evidence. goals.md updated."}} {"id":"int-a0bacc8f5edaee7a580c1a218a43157a","kind":"field_change","created_at":"2026-07-17T14:52:46.121787Z","actor":"Val Alexander","issue_id":"threads-986.17","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"EPIC SUMMARY — Phase 4 Cave UX complete. Built: PHASE-4-CAVE-SURFACES.md contract (PR #1, a55d99af1); read plumbing with dual adapters (fixtures-first default, daemon behind same interface); weave rail with tension rollup (snapped>frayed>unknown>stale>holds); thread pane Holds/Frayed/Snapped with strands + channel bindings; strand inspector with tri-state diff + R7 lineage; proposal approval flow (staged DegradeToProposal, approve/reject daemon-forwarders, fail-closed everywhere) — all merged via coven-cave PR #3223 (645a1894 head, 18 checks green, test:app 740/740). Gates: Charm voice pass .17.7; Nova coherence sign-off .17.8 (PASS, no findings); Val UX-accept .17.10; Val freeze approval .17.9. Rendering rules R1-R11 enforced fail-closed; PatternDescriptor never rendered as enforcement; Gate-4 unknown states render Blocked. Follow-up: threads-v3g (daemon endpoints + adapter flip). Screenshots 01-07 at ~/.openclaw/workspace/sage/goals/phase4-artifacts/."}} {"id":"int-8181a70aeede0f1b0c58cd5fa4521a68","kind":"field_change","created_at":"2026-07-17T14:53:21.464406Z","actor":"Val Alexander","issue_id":"threads-986","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"PROGRAM EPIC COMPLETE — all four AC met. (1) Phases 0-4 delivered: v0.2 design freeze (.4); Phase 1 crate freeze (.18, coven-threads-core through v0.1.3); Phase 2 daemon integration freeze (.20, coven PR #382 merged f745117 — gate operational on main, + WARD-C6 compaction hook PR #393); Phase 3 portability freeze (.21, + jq4 surfaces map/.af exporter PR #2); Phase 4 Cave UX freeze (.17, coven-cave PR #3223). (2) RFC-0001 §5 conformance mirror 14/14 (.11). (3) Daemon imports validator: tier-0 edits route through validate_fail_closed, pinned v0.1.3. (4) Portability ships with C7 survives-serialization enforced (c7_roundtrip suite; C1-C7 jointly canonical in grimoire §9 per .12). 30 child beads closed with evidence. Remaining non-child follow-up: threads-v3g (daemon endpoints + adapter flip)."}} +{"id":"int-66ac26cbefee070c08321748420bad15","kind":"field_change","created_at":"2026-07-17T19:21:41.010774Z","actor":"Val Alexander","issue_id":"threads-eql","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"MERGED coven PR #409 (a9724b09), all checks green. ward_manifest baselines advance on every committed protected write (store_baseline upsert + advance_surface_baseline post-apply); refusals keep pending files and append proposal-scoped audit rows. Independently live-verified on the PR branch with a hermetic daemon over the real socket: baseline hash advanced after approve; the previously-broken second cycle (drift->stage->restore-to-advanced-baseline->approve) applied 200; refused approve kept file + audited and retry stayed viable. Clarified: tier-0 POST /edits is HeldForCoherence by design — approve is the sole tier-0 commit point."}} +{"id":"int-8c4cb8b33a94f385798ab5be6d435d8d","kind":"field_change","created_at":"2026-07-17T19:21:42.881808Z","actor":"Val Alexander","issue_id":"threads-v3g","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"COMPLETE, both sides merged. Daemon: coven PR #408 (85566ba) GET /api/v1/threads/weaves + POST /api/v1/threads/proposals/{id}/{approve|reject} per spec §3/§3.7, + P1 follow-up PR #409 (a9724b09, threads-eql) making multi-cycle approves work. Cave: coven-cave PR #3362 (3db8547c8) — daemon adapter is the DEFAULT (spec §1 fixtures-first condition lapsed; COVEN_THREADS_ADAPTER=fixtures opts out), daemon semantic refusals surface verbatim (proposal-refused/not-found/proposal-corrupt, 409/404) instead of transport-shaped 503. Live-verified end to end through the real socket with a hermetic daemon: reads normalize, staging, drift refusal, approve apply, corrupt guard, unknown-id fail-closed. Validation: coven gates green; cave test:app 758/758 + typecheck on merged head. Stowaway Desktop commit rescued to rescue/val-ui-tweaks-2026-07-17 (needs its own PR)."}} +{"id":"int-be6d3f843b0a440d8eb1d1c81d475d32","kind":"field_change","created_at":"2026-07-18T07:02:18.244791Z","actor":"Val Alexander","issue_id":"threads-vmf","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"MERGED coven PR #418 (b98504cd, all checks green incl. windows after cfg fix 2001da04). Root cause: handler Err propagated out of threads_weaves_response on the first WardConfig::load failure and daemon::handle_http_stream ?-returned before writing any HTTP bytes (empty reply, curl exit 52). Fix: per-familiar isolation (log to stderr + daemon-recovery.log, skip, continue — never abort fleet read) and handle_http_stream converts api handler errors to JSON 500 envelopes for every endpoint. Independently live-verified on the PR branch: fleet of healthy sage + Val's REAL v0.1-dialect nova ward.toml returns 200 with sage listed. Tests: threads_weaves_skips_malformed_ward_without_aborting_fleet_read, handle_http_stream_converts_api_handler_error_to_json_500. Surfacing of malformed-config familiars in the UI deferred to threads-v60 migration lane."}} +{"id":"int-b1b336460d93e610124263159faeada3","kind":"field_change","created_at":"2026-07-18T07:02:19.14866Z","actor":"Val Alexander","issue_id":"threads-wro","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"MERGED coven-cave PR #3407 (d1edf3e6, 19/19 checks). preferences-store + theme-store suites now set a hermetic temp COVEN_HOME so cave-home reconciliation stops walking the real ~/.coven legacy symlinks against temp canonical overrides. Verified locally on a migrated machine: both suites + backdrop/migration siblings green; remaining test:api failure is the separately-tracked pre-existing tts flake (cave-k7i3 pattern)."}} +{"id":"int-b0352cdd9a72fadc88de97b11bb2f4b9","kind":"field_change","created_at":"2026-07-18T08:11:35.780927Z","actor":"Val Alexander","issue_id":"threads-v60","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"COMPLETE. Command: coven ward migrate merged in coven PR #419 (74b09cd1, all checks green incl. windows after TOML-escape fixture fix 023dc5bb); shipping in v0.1.5 (signed tag pushed, release pipeline running). Fleet migration APPLIED on Val's machine 2026-07-18 with Val's decisions (SSH signing-key fingerprint via runtime arg; protected->tier 0 + protected_surface; editable->tier 2 Logged; backup-only legacy disposition): all nine familiars migrated with byte-exact ward.toml.v01.bak backups; thoth additionally needed a familiars.toml workspace= line (added, backup kept). Live verification post-kickstart: GET /api/v1/threads/weaves HTTP 200 with nine weaves, 4-8 threads each, all tensions Holds, all coherence Coherent — the authority-boundary protection layer is now live over real familiar memory. v0.1 approval_tiers/harness_blocks/invariants semantics intentionally not carried (no Phase-2 equivalent; preserved in backups)."}} +{"id":"int-7c8ce4baece71955acd514e5c34b1e12","kind":"field_change","created_at":"2026-07-18T08:24:30.113404Z","actor":"Val Alexander","issue_id":"threads-2q5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"MERGED coven PR #393 2026-07-15 (daemon main; verified via git log 04e0ce7 ancestor of main). WARD-C6 CompactionLedger events append in store::vacuum_store_path with pre/post thread commitments; tests vacuum_appends_compaction_ledger_for_warded_surface, vacuum_without_warded_surfaces_appends_no_compaction_ledger, compaction_ledger_keeps_ward_audit_append_only. Full evidence in bead comments. Closing stale in_progress claim from coordinator session."}} +{"id":"int-45fee6c5015cd238bdf2b4f49197d1c5","kind":"field_change","created_at":"2026-07-18T08:24:30.930406Z","actor":"Val Alexander","issue_id":"threads-jq4","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"MERGED coven-threads PR #2 2026-07-15 (5b4a51a on main), tag v0.1.3 cut at that commit and coven pin bumped (30474c3). Surfaces content map + export_af lossy one-way Letta exporter, no .af import path, C7 conformance in tests/c7_roundtrip.rs. Full evidence in bead comments. Closing stale in_progress claim from coordinator session."}} +{"id":"int-96e600594080674425edaa10b7a4934c","kind":"field_change","created_at":"2026-07-18T12:03:18.346804Z","actor":"Val Alexander","issue_id":"threads-k9s","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} +{"id":"int-3992491181076736d0b8c9d8072dd308","kind":"field_change","created_at":"2026-07-18T12:03:18.909381Z","actor":"Val Alexander","issue_id":"threads-3xd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} +{"id":"int-019bebf2d54257621bcb1e7d3bf06f74","kind":"field_change","created_at":"2026-07-18T12:03:19.445615Z","actor":"Val Alexander","issue_id":"threads-uqx","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} +{"id":"int-1805bcc20e8bca84ac7425b1877298a9","kind":"field_change","created_at":"2026-07-18T14:42:35.772492Z","actor":"Val Alexander","issue_id":"threads-k9s","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"COMPLETE, both halves merged. Spec: §2.7 DegradedFamiliarView + §4.R12 + route-1 WeaveListView shape (coven-threads main 6476a67 + 6829e5d). Daemon: coven#422 merged e04a7af8 (reopened after accidental close; branch restored from verified head e5dd8fc5) — degraded entries with const reason + sanitized single-line errors, recovery-log breadcrumbs kept, no-ward familiars skipped. Cave: coven-cave#3415 merged 037bca078 — WeaveListView {weaves, degraded}, R12 blocked rail rows with exact copy 'ward unreadable — protection not verifiable', traceForDegradedFamiliar drawer, degraded fixture, forward-compat unknown reasons; typecheck + test:app 774/774. Fail-visible gap from threads-vmf closed end to end."}} +{"id":"int-c49d97f567aa70cc2b8d289e925f4114","kind":"field_change","created_at":"2026-07-18T14:45:05.028849Z","actor":"Val Alexander","issue_id":"threads-0ip","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"MERGED coven PR #426 (7ad55434), all checks green. Root causes fixed, not papered over: (1) windows pty_runner codex_json_batch_shim flake — side-effect files (args.txt/stdin.txt) were read before reliably visible on windows; now condition-based read_side_effect_file polling with deadline; (2) ubuntu smoke session-kill timeout — record_session_exit's natural-exit path could overwrite a concurrent 'killed' status; now update_session_status_if_current CAS (UPDATE ... WHERE status='running') with unit test conditionally_updates_session_status_only_from_expected_current_status; daemon.rs exit recorder uses it. Coordinator-reviewed the product diff. No assertions weakened, no quarantines needed."}} +{"id":"int-eefa76ec3968a5d984f3da51e113add6","kind":"field_change","created_at":"2026-07-18T21:24:21.080221Z","actor":"Val Alexander","issue_id":"threads-3ym","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-17b3b70454cfdb7bbdaca849bc30ad2f","kind":"field_change","created_at":"2026-07-18T21:25:07.575924Z","actor":"Val Alexander","issue_id":"threads-3ym","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"PR #7 opened: https://github.com/OpenCoven/coven-threads/pull/7. 75+17+14 tests green, clippy clean. Awaiting merge + v0.1.4 tag."}} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1c07867 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +All notable changes to `coven-threads-core` are documented here. + +## [0.2.0] — unreleased + +### Added + +- `AuditEventType::ApplyAudit` variant + `"apply_audit"` tag in `WARD_AUDIT_SCHEMA_SQL`'s CHECK constraint (coven-threads#5). + - Enables the coven daemon to persist Gate-4 Tier-2 applied-write records into `ward.audit` (downstream: coven#414). +- `WardAuditRecord::detail` field — nullable JSON column for event-type-specific payload. + - For `ApplyAudit` events: `{"prev_sha256":"","bytes_written":N}`. +- `WardAuditRecord::for_apply(...)` constructor — builds the audit row for a Gate-4 applied write. +- `WardAuditRecord::apply_prev_sha256_hex()` / `apply_bytes_written()` — decode helpers for `ApplyAudit` detail. +- `APPLY_AUDIT_DETAIL_KEY_PREV` / `APPLY_AUDIT_DETAIL_KEY_BYTES` — stable JSON key constants. +- Phase 5 approval paths, veto windows, semantic surface regions, full-diff replay commitments, and validated daemon-to-client wire envelopes. +- Deterministic compilation of the retired Ward identity declarations for name, person, pronouns, purpose, and Coven membership. +- RFC-0001 provenance events with persistence-level required-detail constraints. +- `ward_audit_migration_sql` — transaction SQL builder that preserves existing detail payloads while also supporting pre-detail legacy tables. Schema ownership is tracked in `ward_schema_meta`, not SQLite's database-global `PRAGMA user_version`. +- Exhaustiveness test `schema_names_all_event_tags` extended to cover `ApplyAudit`. +- New tests: `for_apply_produces_correct_shape`, `for_apply_roundtrips_json`, `migration_sql_contains_apply_audit_and_detail_column`. + +### Design notes + +- **Column approach (Option A):** `diff_hash` carries `next_sha256`; `prev_sha256` + `bytes_written` ride in the new `detail` TEXT column as JSON. This avoids a second table rebuild by not adding typed hash columns while keeping the data query-accessible via SQLite JSON functions. +- **Migration approach (Option B-lite):** exports a migration builder and schema-action classifier so the daemon can perform the correct guarded rebuild for the observed source schema; component metadata avoids colliding with unrelated tables in `coven.sqlite3`. + +## [0.1.3] — prior release + +Initial audit module with seven `AuditEventType` variants (`proposal_submitted`, `proposal_approved`, `proposal_rejected`, `proposal_vetoed`, `ward_updated`, `validation_verdict`, `compaction_ledger`). diff --git a/Cargo.lock b/Cargo.lock index a5a514b..6090a21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,7 +67,7 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "coven-threads-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "blake3", "serde", diff --git a/Cargo.toml b/Cargo.toml index e1c41ea..3696a88 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/coven-threads-core"] [workspace.package] -version = "0.1.0" +version = "0.2.0" edition = "2021" rust-version = "1.75" license = "MIT" @@ -17,7 +17,7 @@ thiserror = "2" uuid = { version = "1", features = ["v4", "serde"] } blake3 = "1" sha2 = "0.10" -time = { version = "0.3", features = ["serde", "macros"] } +time = { version = "0.3", features = ["serde", "serde-well-known", "macros"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/coven-threads-core/examples/generate_phase5_retired_ward_corpus.rs b/crates/coven-threads-core/examples/generate_phase5_retired_ward_corpus.rs new file mode 100644 index 0000000..910bc94 --- /dev/null +++ b/crates/coven-threads-core/examples/generate_phase5_retired_ward_corpus.rs @@ -0,0 +1,8 @@ +//! Emit the public synthetic retired-Ward Phase 5 corpus as canonical JSON. + +#[path = "../tests/support/phase5_retired_ward_corpus.rs"] +mod corpus; + +fn main() { + println!("{}", corpus::canonical_corpus_json()); +} diff --git a/crates/coven-threads-core/src/approval.rs b/crates/coven-threads-core/src/approval.rs new file mode 100644 index 0000000..6a8ac54 --- /dev/null +++ b/crates/coven-threads-core/src/approval.rs @@ -0,0 +1,993 @@ +//! Phase-5 approval semantics — core types (`threads-uqx.3`). +//! +//! This module defines the authority-ceremony layer sitting *above* the load +//! axis ([`Channel`]) and *below* the daemon's apply logic. The key +//! decomposition (`specs/PHASE-5-APPROVAL-SEMANTICS.md` §3.1, decision 1): +//! +//! - **[`Channel`]** — *why* a thread is stressed (deliberate / forced / +//! serialization / mutation). Phase-0 axis, not changed. +//! - **[`ApprovalPath`]** — *which promotion ceremony* is required before the +//! daemon may apply a proposal. Phase-5 addition. These are orthogonal; never +//! gate on `Channel` to infer `ApprovalPath`. +//! +//! ## Veto windows are delayed-apply only (decision 2) +//! +//! A [`VetoWindow`] means: the daemon stages the proposal, makes it *visibly +//! pending* for at least `min_visible`, then applies only if the window closes +//! without a veto. Provisional apply (apply-then-rollback) is explicitly +//! forbidden until Val/Nova accept rollback semantics. +//! +//! ## Proposal classification (decision 3 / 5 bead scope) +//! +//! [`ProposalClassification`] is the record the daemon produces at intake. It +//! carries: +//! - the channel the mutation arrived on; +//! - which surface regions were affected (region evidence, `threads-uqx.5`); +//! - the floor path tier (tier 0 = protected, tier 1 = reviewed, …); +//! - the required approval path — highest ceremony wins; +//! - an **`evidence_replay_hash`** (WARD-C7 generalised): the delayed-apply +//! scheduler must prove at deadline that the evidence that gated the +//! window-open decision can still be replayed to the same result. +//! +//! ## Audit event shape (decisions 2 + 8) +//! +//! The audit trail for a proposal lifecycle is: `proposal_submitted` → +//! `proposal_window_opened` (when delayed-apply applies) → close event. The +//! close event carries an explicit [`WindowCloseReason`] field — the window is a +//! first-class audit interval, not a gap between two rows. +//! +//! ## Label mapping is a daemon wire contract (decision 7) +//! +//! [`ApprovalPath::display_label`] is the canonical display string the daemon +//! emits over the wire. Cave has zero policy freedom over these labels: it +//! renders exactly what the daemon sends. The daemon MUST reject at load if a +//! variant has no corresponding display label, or if a received label string +//! cannot be mapped back to a variant (see [`ApprovalPath::from_display_label`]). +//! +//! [`Channel`]: crate::channel::Channel + +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; + +use crate::channel::Channel; +use crate::ids::{FamiliarId, ProposalId, SurfaceId}; + +// --------------------------------------------------------------------------- +// ApprovalPath +// --------------------------------------------------------------------------- + +/// The promotion ceremony required before a proposal may be applied. +/// +/// ## Ceremony ordering (highest wins) +/// +/// When a proposal touches surfaces with different approval requirements, the +/// daemon uses the highest ceremony for the proposal as a unit (all-or-nothing, +/// matching the existing Ward behaviour). The ordering is: +/// +/// `AutoRegression` < `FamiliarCoherence` < `HumanApproval` < +/// `HumanApprovalWithRationale` +/// +/// ## Display labels (daemon wire contract — decision 7) +/// +/// The daemon emits `{variant, label, veto_deadline}` over its wire protocol. +/// Clients (Cave and others) MUST render the label as received; they have zero +/// policy freedom over the string. The daemon MUST reject at load time if a +/// variant has no label ([`ApprovalPath::display_label`] is exhaustive) or if +/// a received label string is not recognised ([`ApprovalPath::from_display_label`] +/// returns `None` → load-time reject). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ApprovalPath { + /// Deterministic regression gates pass; human principal may veto within the + /// window. If the window closes without a veto, the proposal is applied + /// automatically. RFC-0001 §5.3 auto tier. + AutoRegression { + /// Optional veto window. `None` means: apply as soon as regression gates + /// clear, with no veto period (emergency / unattended automation path). + veto: Option, + }, + + /// Familiar-coherence gate runs; a veto window follows before apply. + /// Maps to RFC-0001 §5.3 familiar-review tier. + FamiliarCoherence { + /// The veto window (always required for this path; familiar review is + /// meaningless without a chance for the familiar to surface a concern). + veto: VetoWindow, + }, + + /// A human principal must explicitly approve before apply. No veto window; + /// the write is blocked until approval arrives. + HumanApproval, + + /// A human principal must approve *and* record a rationale. The rationale + /// is stored in the audit row. Highest-ceremony path. + HumanApprovalWithRationale, +} + +impl ApprovalPath { + /// Stable display label the daemon emits over the wire (decision 7). + /// + /// These strings are the canonical external representation. Do not change + /// them without a migration plan; Cave and other clients will be + /// hard-referencing the strings. + pub fn display_label(&self) -> &'static str { + match self { + ApprovalPath::AutoRegression { .. } => "auto", + ApprovalPath::FamiliarCoherence { .. } => "familiar_review", + ApprovalPath::HumanApproval => "human_review", + ApprovalPath::HumanApprovalWithRationale => "human_required", + } + } + + /// Parse a display label back to a variant **shape** (without inner fields, + /// which must come from the full wire payload). + /// + /// The daemon MUST call this at load time and reject if the result is + /// `None` — no unknown labels may pass through. + pub fn from_display_label(label: &str) -> Option { + match label { + "auto" => Some(ApprovalPathKind::AutoRegression), + "familiar_review" => Some(ApprovalPathKind::FamiliarCoherence), + "human_review" => Some(ApprovalPathKind::HumanApproval), + "human_required" => Some(ApprovalPathKind::HumanApprovalWithRationale), + _ => None, + } + } + + /// Ordinal for "highest ceremony wins" comparison. + fn ceremony_ordinal(&self) -> u8 { + match self { + ApprovalPath::AutoRegression { .. } => 0, + ApprovalPath::FamiliarCoherence { .. } => 1, + ApprovalPath::HumanApproval => 2, + ApprovalPath::HumanApprovalWithRationale => 3, + } + } + + /// Returns the higher-ceremony path of `self` and `other`. + pub fn highest(self, other: ApprovalPath) -> ApprovalPath { + match (self, other) { + ( + ApprovalPath::AutoRegression { veto: left }, + ApprovalPath::AutoRegression { veto: right }, + ) => ApprovalPath::AutoRegression { + veto: Self::strongest_optional_window(left, right), + }, + ( + ApprovalPath::FamiliarCoherence { veto: left }, + ApprovalPath::FamiliarCoherence { veto: right }, + ) => ApprovalPath::FamiliarCoherence { + veto: left.strongest(right), + }, + (left, right) => { + if right.ceremony_ordinal() > left.ceremony_ordinal() { + right + } else { + left + } + } + } + } + + /// Whether this path includes a veto window. + pub fn has_veto_window(&self) -> bool { + match self { + ApprovalPath::AutoRegression { veto } => veto.is_some(), + ApprovalPath::FamiliarCoherence { .. } => true, + ApprovalPath::HumanApproval | ApprovalPath::HumanApprovalWithRationale => false, + } + } + + fn strongest_optional_window( + left: Option, + right: Option, + ) -> Option { + match (left, right) { + (Some(left), Some(right)) => Some(left.strongest(right)), + (Some(window), None) | (None, Some(window)) => Some(window), + (None, None) => None, + } + } + + /// Extract the veto window, if present. + pub fn veto_window(&self) -> Option<&VetoWindow> { + match self { + ApprovalPath::AutoRegression { veto } => veto.as_ref(), + ApprovalPath::FamiliarCoherence { veto } => Some(veto), + ApprovalPath::HumanApproval | ApprovalPath::HumanApprovalWithRationale => None, + } + } +} + +/// Variant kind without inner fields — used for label-round-trip validation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalPathKind { + /// Deterministic regression path with an optional veto window. + AutoRegression, + /// Familiar-coherence review followed by a veto window. + FamiliarCoherence, + /// Explicit human approval. + HumanApproval, + /// Explicit human approval with a recorded rationale. + HumanApprovalWithRationale, +} + +impl ApprovalPathKind { + /// The display label for this kind (decision 7 — must round-trip with + /// `ApprovalPath::from_display_label`). + pub fn display_label(&self) -> &'static str { + match self { + ApprovalPathKind::AutoRegression => "auto", + ApprovalPathKind::FamiliarCoherence => "familiar_review", + ApprovalPathKind::HumanApproval => "human_review", + ApprovalPathKind::HumanApprovalWithRationale => "human_required", + } + } +} + +// --------------------------------------------------------------------------- +// VetoWindow +// --------------------------------------------------------------------------- + +/// A delayed-apply veto window (decision 2). +/// +/// ## Semantics +/// +/// The daemon stages the proposal and makes it *visibly pending* for at least +/// [`min_visible`]. After `deadline` passes without a veto, the daemon +/// **revalidates** (using `evidence_replay_hash` on [`ProposalClassification`]) +/// and applies only if revalidation succeeds. +/// +/// Provisional apply (write-then-rollback) is **not** modelled here and must +/// not be inferred. Every apply happens *after* the deadline, never before. +/// +/// ## `min_visible` enforcement +/// +/// The daemon MUST NOT close a veto window before `staged_at + min_visible` has +/// elapsed, even if the deadline has technically passed (e.g. if the window was +/// created with a very short duration). This prevents race conditions where a +/// proposal is technically pending but never actually reachable by a human. +/// +/// [`min_visible`]: VetoWindow::min_visible +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "VetoWindowWire")] +pub struct VetoWindow { + /// How long the window stays open from the moment the proposal is staged. + duration: Duration, + /// Minimum time the proposal must be *visibly pending* before the window + /// may close. Prevents proposals that are technically pending but + /// unreachable in practice (same shape as the two-compaction contract's + /// minimum-visibility requirement). + /// + /// MUST be ≤ `duration`. + min_visible: Duration, +} + +impl VetoWindow { + /// Construct a veto window, panicking if `min_visible > duration`. + pub fn new(duration: Duration, min_visible: Duration) -> Self { + Self::try_new(duration, min_visible).expect("invalid VetoWindow") + } + + /// Construct a validated veto window. + pub fn try_new(duration: Duration, min_visible: Duration) -> Result { + if min_visible > duration { + return Err(format!( + "VetoWindow: min_visible ({min_visible:?}) must be ≤ duration ({duration:?})" + )); + } + time::Duration::try_from(duration) + .map_err(|_| format!("VetoWindow duration {duration:?} is not representable"))?; + time::Duration::try_from(min_visible) + .map_err(|_| format!("VetoWindow min_visible {min_visible:?} is not representable"))?; + Ok(Self { + duration, + min_visible, + }) + } + + /// Total time the veto window stays open. + pub fn duration(&self) -> Duration { + self.duration + } + + /// Minimum time the proposal must remain visibly pending. + pub fn min_visible(&self) -> Duration { + self.min_visible + } + + fn strongest(self, other: Self) -> Self { + Self::new( + self.duration.max(other.duration), + self.min_visible.max(other.min_visible), + ) + } + + /// Calculate the absolute deadline given a staged-at timestamp. + pub fn deadline(&self, staged_at: OffsetDateTime) -> Result { + let duration = time::Duration::try_from(self.duration) + .map_err(|_| "VetoWindow duration is not representable".to_string())?; + staged_at.checked_add(duration).ok_or_else(|| { + "VetoWindow deadline is outside the representable timestamp range".into() + }) + } + + /// The earliest time at which the window may close (staged_at + min_visible). + pub fn earliest_close(&self, staged_at: OffsetDateTime) -> Result { + let min_visible = time::Duration::try_from(self.min_visible) + .map_err(|_| "VetoWindow min_visible is not representable".to_string())?; + staged_at.checked_add(min_visible).ok_or_else(|| { + "VetoWindow earliest close is outside the representable timestamp range".into() + }) + } + + /// Whether the window may now be closed given the current time and when it + /// was staged. + /// + /// Returns `Ok(true)` iff `now >= earliest_close(staged_at)`. The scheduler + /// should additionally check that `now >= deadline(staged_at)` before + /// auto-applying; this method only answers the min-visible gate. + pub fn is_min_visible_elapsed( + &self, + staged_at: OffsetDateTime, + now: OffsetDateTime, + ) -> Result { + Ok(now >= self.earliest_close(staged_at)?) + } +} + +#[derive(Deserialize)] +struct VetoWindowWire { + duration: Duration, + min_visible: Duration, +} + +impl TryFrom for VetoWindow { + type Error = String; + + fn try_from(value: VetoWindowWire) -> Result { + Self::try_new(value.duration, value.min_visible) + } +} + +// --------------------------------------------------------------------------- +// ProposalClassification +// --------------------------------------------------------------------------- + +/// The daemon's classification record produced at proposal intake. +/// +/// Every field is set when the proposal is first received; none may be mutated +/// afterwards (the record is append-only, mirroring `ward.audit`). At +/// delayed-apply deadline, the daemon replays gate evidence against +/// `evidence_replay_hash` — if the result differs, the proposal is rejected +/// (WARD-C7 generalised: evidence must survive the time gap). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProposalClassification { + /// The proposal this classification belongs to. + pub proposal_id: ProposalId, + /// The familiar whose weave is being mutated. + pub familiar_id: FamiliarId, + /// The channel the mutation arrived on (load axis — not the approval path). + pub channel: Channel, + /// Materialized surfaces affected by the proposal. + pub affected_surfaces: Vec, + /// Surface regions the diff touches (populated by region-extractor + /// predicates, `threads-uqx.5`). May be empty while uqx.5 is unshipped. + pub affected_regions: Vec, + /// Floor path tier for all touched surfaces (lowest tier = most protected). + /// Tier 0 = protected; tier 1 = reviewed; tier 2 = logged; tier 3 = free. + pub path_tier_floor: u8, + /// The approval ceremony required (highest ceremony of all touched + /// surfaces + regions). + pub approval_path: ApprovalPath, + /// Blake3 hash over the gate-evidence bundle that was evaluated when this + /// classification was created. The delayed-apply scheduler MUST re-derive + /// this hash at deadline and reject if it differs (WARD-C7 generalised). + /// + /// Encoding: raw 32-byte Blake3 digest. + pub evidence_replay_hash: [u8; 32], + /// When the classification was produced. + pub classified_at: OffsetDateTime, +} + +// --------------------------------------------------------------------------- +// SurfaceRegionId +// --------------------------------------------------------------------------- + +/// Opaque identifier for a typed semantic surface region (`threads-uqx.5`). +/// +/// The full `SurfaceRegionPredicate` machinery lives in `uqx.5`; this module +/// only needs the identifier so `ProposalClassification` can reference regions +/// without a circular dependency. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SurfaceRegionId(pub String); + +impl SurfaceRegionId { + /// Construct from any string-like value. + pub fn new>(s: S) -> Self { + Self(s.into()) + } + + /// Borrow the inner string. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for SurfaceRegionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +// --------------------------------------------------------------------------- +// WindowCloseReason +// --------------------------------------------------------------------------- + +/// The reason a veto window closed (decision 2 + 8 — explicit reason field on +/// every close event; the window is a first-class audit interval). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WindowCloseReason { + /// The window expired without a veto and revalidation succeeded; proposal + /// was applied. + Applied, + /// A principal vetoed the proposal before the deadline. + Vetoed, + /// Deadline replay produced different evidence; proposal was rejected. + EvidenceDiverged, + /// Deadline replay could not produce authoritative evidence; proposal was + /// rejected fail-closed. + RevalidationFailed, + /// A superseding proposal was submitted for the same surface before this + /// window closed; this proposal is no longer pending. + Superseded, +} + +impl WindowCloseReason { + /// Stable tag for the `decision` column in `ward.audit`. + pub fn tag(&self) -> &'static str { + match self { + WindowCloseReason::Applied => "applied", + WindowCloseReason::Vetoed => "vetoed", + WindowCloseReason::EvidenceDiverged => "evidence_diverged", + WindowCloseReason::RevalidationFailed => "revalidation_failed", + WindowCloseReason::Superseded => "superseded", + } + } +} + +// --------------------------------------------------------------------------- +// ProposalWindowAuditRecord +// --------------------------------------------------------------------------- + +/// Audit record for the `proposal_window_opened` event (decision 8). +/// +/// This is a *supplementary* shape for the detail payload in a +/// `WardAuditRecord` with `event_type = AuditEventType::ProposalWindowOpened`. +/// The top-level fields (`proposal_id`, `familiar_id`, `ward_hash`, …) come +/// from the `WardAuditRecord`; this struct is serialised into the `detail` +/// column. +/// +/// Note: `AuditEventType::ProposalWindowOpened` will be added to +/// `AuditEventType` in issue #5 / the Cody implementation lane, which owns +/// the schema migration. This struct documents the shape so that migration +/// can be written correctly. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProposalWindowAuditDetail { + /// The approval path that opened this window. + pub approval_path_label: String, + /// Absolute deadline (RFC 3339). + #[serde(with = "time::serde::rfc3339")] + pub deadline: OffsetDateTime, + /// Earliest moment the window may close (RFC 3339). + #[serde(with = "time::serde::rfc3339")] + pub earliest_close: OffsetDateTime, + /// Hex-encoded `evidence_replay_hash` from `ProposalClassification`. + pub evidence_replay_hash_hex: String, + /// Affected surface region ids, for audit legibility. + pub affected_regions: Vec, +} + +/// Audit record for the window-close event (the paired close to +/// `proposal_window_opened`). +/// +/// Serialised into the `detail` column of the close-event `WardAuditRecord`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProposalWindowCloseAuditDetail { + /// Why the window closed. + pub reason: WindowCloseReason, + /// Whether the evidence replay hash matched at deadline. This is `None` for + /// `Vetoed` and `Superseded`, where replay is not attempted. + pub replay_hash_matched: Option, + /// Rationale text (required for `HumanApprovalWithRationale` path; `None` + /// for other paths unless the approver voluntarily adds one). + pub rationale: Option, +} + +/// Audit detail for every successful proposal approval. +/// +/// Human-required approvals carry their mandatory rationale here. Delayed +/// approval paths additionally carry the window-close evidence. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProposalApprovalAuditDetail { + /// Canonical [`ApprovalPath::display_label`] value. + pub approval_path_label: String, + /// Required and non-empty for `human_required`; optional otherwise. + pub rationale: Option, + /// Delayed-apply close evidence, when this approval closed a veto window. + pub window_close: Option, +} + +// --------------------------------------------------------------------------- +// Wire envelope (daemon → client) +// --------------------------------------------------------------------------- + +/// The wire envelope the daemon sends to clients for a pending proposal +/// (decision 7 — label, variant, optional deadline). +/// +/// Cave and other clients receive this and render it as-is. They MUST NOT +/// infer policy from the variant or label. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ApprovalPathWireEnvelope { + /// Stable enum variant name (snake_case, for machine use). + pub variant: ApprovalPathKind, + /// Human-readable display label (daemon wire contract — never remapped + /// by clients). + pub label: String, + /// Absolute veto deadline, if this path has a veto window. + #[serde(with = "time::serde::rfc3339::option")] + pub veto_deadline: Option, + /// The surfaces affected by this proposal (for display). + pub affected_surfaces: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct UncheckedApprovalPathWireEnvelope { + variant: ApprovalPathKind, + label: String, + #[serde(with = "time::serde::rfc3339::option")] + veto_deadline: Option, + affected_surfaces: Vec, +} + +impl<'de> Deserialize<'de> for ApprovalPathWireEnvelope { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let unchecked = UncheckedApprovalPathWireEnvelope::deserialize(deserializer)?; + let envelope = Self { + variant: unchecked.variant, + label: unchecked.label, + veto_deadline: unchecked.veto_deadline, + affected_surfaces: unchecked.affected_surfaces, + }; + envelope + .validate_label_round_trip() + .map_err(serde::de::Error::custom)?; + Ok(envelope) + } +} + +impl ApprovalPathWireEnvelope { + /// Construct from a classification and optional staged-at timestamp. + pub fn from_classification( + classification: &ProposalClassification, + staged_at: Option, + ) -> Result { + let variant = match &classification.approval_path { + ApprovalPath::AutoRegression { .. } => ApprovalPathKind::AutoRegression, + ApprovalPath::FamiliarCoherence { .. } => ApprovalPathKind::FamiliarCoherence, + ApprovalPath::HumanApproval => ApprovalPathKind::HumanApproval, + ApprovalPath::HumanApprovalWithRationale => { + ApprovalPathKind::HumanApprovalWithRationale + } + }; + let label = classification.approval_path.display_label().to_string(); + let veto_deadline = match classification.approval_path.veto_window() { + Some(window) => { + let staged_at = staged_at.ok_or_else(|| { + "veto-bearing approval path requires a staged_at timestamp".to_string() + })?; + Some(window.deadline(staged_at)?) + } + None => None, + }; + let envelope = Self { + variant, + label, + veto_deadline, + affected_surfaces: classification.affected_surfaces.clone(), + }; + envelope.validate_label_round_trip()?; + Ok(envelope) + } + + /// Validate that the label round-trips back to the variant (daemon load-time + /// check — decision 7). Returns `Err` with the offending label if not. + pub fn validate_label_round_trip(&self) -> Result<(), String> { + let parsed = ApprovalPath::from_display_label(&self.label).ok_or_else(|| { + format!( + "unknown display label {:?}: daemon must reject at load time", + self.label + ) + })?; + if parsed != self.variant { + return Err(format!( + "label {:?} maps to {:?} but envelope carries {:?}", + self.label, parsed, self.variant + )); + } + match self.variant { + ApprovalPathKind::FamiliarCoherence if self.veto_deadline.is_none() => { + return Err("familiar_review requires a veto deadline".into()) + } + ApprovalPathKind::HumanApproval | ApprovalPathKind::HumanApprovalWithRationale + if self.veto_deadline.is_some() => + { + return Err("human approval paths must not carry a veto deadline".into()) + } + _ => {} + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use time::OffsetDateTime; + + // ---- ApprovalPath display labels ---- + + #[test] + fn all_variants_have_display_labels() { + let paths = [ + ApprovalPath::AutoRegression { veto: None }, + ApprovalPath::FamiliarCoherence { + veto: VetoWindow::new(Duration::from_secs(3600), Duration::from_secs(300)), + }, + ApprovalPath::HumanApproval, + ApprovalPath::HumanApprovalWithRationale, + ]; + let expected = ["auto", "familiar_review", "human_review", "human_required"]; + for (path, label) in paths.iter().zip(expected.iter()) { + assert_eq!(path.display_label(), *label, "label mismatch for {path:?}"); + } + } + + #[test] + fn display_labels_round_trip_to_kind() { + // decision 7: every label maps to a known kind; unknown labels fail. + for label in ["auto", "familiar_review", "human_review", "human_required"] { + let kind = ApprovalPath::from_display_label(label); + assert!( + kind.is_some(), + "label {label:?} must round-trip to a known kind" + ); + // kind → label must also agree. + assert_eq!(kind.unwrap().display_label(), label); + } + // Unknown label → None (daemon rejects at load). + assert_eq!(ApprovalPath::from_display_label("tier_0"), None); + assert_eq!(ApprovalPath::from_display_label(""), None); + assert_eq!(ApprovalPath::from_display_label("AUTO"), None); // case-sensitive + } + + #[test] + fn approval_path_serializes_with_kind_tag() { + // serde tag = "kind" so the wire payload carries the discriminant. + let path = ApprovalPath::HumanApproval; + let json = serde_json::to_string(&path).unwrap(); + assert!(json.contains("\"kind\":\"human_approval\""), "{json}"); + let back: ApprovalPath = serde_json::from_str(&json).unwrap(); + assert_eq!(path, back); + } + + // ---- ApprovalPath::highest ---- + + #[test] + fn highest_ceremony_wins() { + let auto = ApprovalPath::AutoRegression { veto: None }; + let human = ApprovalPath::HumanApproval; + let rationale = ApprovalPath::HumanApprovalWithRationale; + + assert_eq!(auto.clone().highest(human.clone()), human.clone()); + assert_eq!(human.clone().highest(auto.clone()), human.clone()); + assert_eq!(human.clone().highest(rationale.clone()), rationale.clone()); + assert_eq!(rationale.clone().highest(auto.clone()), rationale.clone()); + // Symmetric. + assert_eq!(auto.clone().highest(rationale.clone()), rationale.clone()); + } + + #[test] + fn equal_auto_paths_preserve_the_strongest_veto_window() { + let without_veto = ApprovalPath::AutoRegression { veto: None }; + let with_veto = ApprovalPath::AutoRegression { + veto: Some(VetoWindow::new( + Duration::from_secs(3600), + Duration::from_secs(900), + )), + }; + + assert_eq!( + without_veto.highest(with_veto.clone()), + with_veto, + "aggregation must not drop a required veto window" + ); + } + + #[test] + fn equal_familiar_paths_merge_to_the_strongest_window() { + let shorter = ApprovalPath::FamiliarCoherence { + veto: VetoWindow::new(Duration::from_secs(1800), Duration::from_secs(300)), + }; + let stronger = ApprovalPath::FamiliarCoherence { + veto: VetoWindow::new(Duration::from_secs(7200), Duration::from_secs(900)), + }; + + assert_eq!(shorter.highest(stronger.clone()), stronger); + } + + // ---- VetoWindow ---- + + #[test] + fn veto_window_deadline_is_staged_at_plus_duration() { + let w = VetoWindow::new(Duration::from_secs(3600), Duration::from_secs(300)); + let staged = OffsetDateTime::UNIX_EPOCH; + let dl = w.deadline(staged).unwrap(); + assert_eq!( + dl, + staged + time::Duration::hours(1), + "deadline should be staged_at + 1h" + ); + } + + #[test] + fn veto_window_earliest_close_is_staged_at_plus_min_visible() { + let w = VetoWindow::new(Duration::from_secs(3600), Duration::from_secs(600)); + let staged = OffsetDateTime::UNIX_EPOCH; + let ec = w.earliest_close(staged).unwrap(); + assert_eq!(ec, staged + time::Duration::minutes(10)); + } + + #[test] + fn veto_window_min_visible_gate() { + let w = VetoWindow::new(Duration::from_secs(3600), Duration::from_secs(600)); + let staged = OffsetDateTime::UNIX_EPOCH; + // Before min_visible elapses → not eligible to close. + let too_early = staged + time::Duration::seconds(599); + assert!(!w.is_min_visible_elapsed(staged, too_early).unwrap()); + // After min_visible → eligible. + let after = staged + time::Duration::seconds(600); + assert!(w.is_min_visible_elapsed(staged, after).unwrap()); + } + + #[test] + fn veto_window_timestamp_overflow_fails_closed() { + let window = VetoWindow::new(Duration::from_secs(1), Duration::from_secs(1)); + let max = time::macros::datetime!(9999-12-31 23:59:59 UTC); + assert!(window.deadline(max).is_err()); + assert!(window.earliest_close(max).is_err()); + assert!(window.is_min_visible_elapsed(max, max).is_err()); + } + + #[test] + #[should_panic(expected = "min_visible")] + fn veto_window_rejects_min_visible_greater_than_duration() { + // min_visible > duration is a construction error. + let _ = VetoWindow::new(Duration::from_secs(300), Duration::from_secs(600)); + } + + #[test] + fn veto_window_deserialization_rejects_invalid_visibility() { + let json = r#"{ + "duration":{"secs":300,"nanos":0}, + "min_visible":{"secs":600,"nanos":0} + }"#; + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn veto_window_deserialization_rejects_unrepresentable_duration() { + let json = r#"{ + "duration":{"secs":18446744073709551615,"nanos":999999999}, + "min_visible":{"secs":0,"nanos":0} + }"#; + assert!(serde_json::from_str::(json).is_err()); + } + + // ---- WindowCloseReason ---- + + #[test] + fn window_close_reason_tags_are_stable() { + let pairs = [ + (WindowCloseReason::Applied, "applied"), + (WindowCloseReason::Vetoed, "vetoed"), + (WindowCloseReason::EvidenceDiverged, "evidence_diverged"), + (WindowCloseReason::RevalidationFailed, "revalidation_failed"), + (WindowCloseReason::Superseded, "superseded"), + ]; + for (reason, expected_tag) in pairs { + assert_eq!(reason.tag(), expected_tag); + // serde round-trip. + let json = serde_json::to_string(&reason).unwrap(); + let back: WindowCloseReason = serde_json::from_str(&json).unwrap(); + assert_eq!(reason, back); + } + } + + // ---- SurfaceRegionId ---- + + #[test] + fn surface_region_id_roundtrips() { + let id = SurfaceRegionId::new("execution_prompt"); + assert_eq!(id.as_str(), "execution_prompt"); + let json = serde_json::to_string(&id).unwrap(); + let back: SurfaceRegionId = serde_json::from_str(&json).unwrap(); + assert_eq!(id, back); + } + + // ---- ProposalClassification ---- + + #[test] + fn proposal_classification_roundtrips_json() { + use crate::ids::{FamiliarId, ProposalId}; + + let c = ProposalClassification { + proposal_id: ProposalId::new(), + familiar_id: FamiliarId::new(), + channel: Channel::Mutation, + affected_surfaces: vec![SurfaceId::new("SOUL.md")], + affected_regions: vec![SurfaceRegionId::new("output_formats")], + path_tier_floor: 1, + approval_path: ApprovalPath::FamiliarCoherence { + veto: VetoWindow::new(Duration::from_secs(7200), Duration::from_secs(900)), + }, + evidence_replay_hash: [0xde; 32], + classified_at: OffsetDateTime::UNIX_EPOCH, + }; + let json = serde_json::to_string_pretty(&c).unwrap(); + let back: ProposalClassification = serde_json::from_str(&json).unwrap(); + assert_eq!(c, back); + } + + // ---- ApprovalPathWireEnvelope ---- + + #[test] + fn wire_envelope_label_round_trip_validates() { + use crate::ids::{FamiliarId, ProposalId}; + + let classification = ProposalClassification { + proposal_id: ProposalId::new(), + familiar_id: FamiliarId::new(), + channel: Channel::Mutation, + affected_surfaces: vec![SurfaceId::new("SOUL.md")], + affected_regions: vec![], + path_tier_floor: 0, + approval_path: ApprovalPath::HumanApprovalWithRationale, + evidence_replay_hash: [0xaa; 32], + classified_at: OffsetDateTime::UNIX_EPOCH, + }; + let env = ApprovalPathWireEnvelope::from_classification( + &classification, + Some(OffsetDateTime::UNIX_EPOCH), + ) + .unwrap(); + assert_eq!(env.label, "human_required"); + assert_eq!(env.variant, ApprovalPathKind::HumanApprovalWithRationale); + assert_eq!(env.affected_surfaces, vec![SurfaceId::new("SOUL.md")]); + assert!(env.validate_label_round_trip().is_ok()); + } + + #[test] + fn wire_envelope_refuses_invalid_veto_staging_time() { + use crate::ids::{FamiliarId, ProposalId}; + + let classification = ProposalClassification { + proposal_id: ProposalId::new(), + familiar_id: FamiliarId::new(), + channel: Channel::Mutation, + affected_surfaces: vec![SurfaceId::new("SOUL.md")], + affected_regions: vec![], + path_tier_floor: 1, + approval_path: ApprovalPath::FamiliarCoherence { + veto: VetoWindow::new(Duration::from_secs(3600), Duration::from_secs(300)), + }, + evidence_replay_hash: [0xaa; 32], + classified_at: OffsetDateTime::UNIX_EPOCH, + }; + + assert!(ApprovalPathWireEnvelope::from_classification(&classification, None).is_err()); + let max = time::macros::datetime!(9999-12-31 23:59:59 UTC); + assert!(ApprovalPathWireEnvelope::from_classification(&classification, Some(max)).is_err()); + } + + #[test] + fn wire_envelope_rejects_unknown_label() { + let mut env = ApprovalPathWireEnvelope { + variant: ApprovalPathKind::AutoRegression, + label: "auto".into(), + veto_deadline: None, + affected_surfaces: vec![], + }; + // Corrupt the label. + env.label = "tier_0".into(); + assert!(env.validate_label_round_trip().is_err()); + } + + #[test] + fn wire_envelope_rejects_mismatched_label_and_variant() { + let env = ApprovalPathWireEnvelope { + variant: ApprovalPathKind::FamiliarCoherence, + label: "auto".into(), // mismatch: auto != familiar_review + veto_deadline: None, + affected_surfaces: vec![], + }; + assert!(env.validate_label_round_trip().is_err()); + } + + #[test] + fn wire_envelope_deserialization_rejects_invalid_contract() { + let json = r#"{ + "variant":"familiar_coherence", + "label":"auto", + "veto_deadline":null, + "affected_surfaces":[] + }"#; + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn window_timestamps_use_rfc3339_on_the_wire() { + let deadline = time::macros::datetime!(2026-07-21 12:00:00 UTC); + let envelope = ApprovalPathWireEnvelope { + variant: ApprovalPathKind::FamiliarCoherence, + label: "familiar_review".into(), + veto_deadline: Some(deadline), + affected_surfaces: vec![], + }; + let envelope_json = serde_json::to_value(&envelope).unwrap(); + assert!(envelope_json["veto_deadline"].is_string()); + let decoded: ApprovalPathWireEnvelope = serde_json::from_value(envelope_json).unwrap(); + assert_eq!(decoded, envelope); + + let detail = ProposalWindowAuditDetail { + approval_path_label: "familiar_review".into(), + deadline, + earliest_close: deadline - time::Duration::minutes(5), + evidence_replay_hash_hex: "ab".repeat(32), + affected_regions: vec![], + }; + let detail_json = serde_json::to_value(&detail).unwrap(); + assert!(detail_json["deadline"].is_string()); + assert!(detail_json["earliest_close"].is_string()); + let decoded: ProposalWindowAuditDetail = serde_json::from_value(detail_json).unwrap(); + assert_eq!(decoded, detail); + } + + #[test] + fn auto_path_veto_window_present_and_absent() { + let with_veto = ApprovalPath::AutoRegression { + veto: Some(VetoWindow::new( + Duration::from_secs(1800), + Duration::from_secs(60), + )), + }; + let without_veto = ApprovalPath::AutoRegression { veto: None }; + assert!(with_veto.has_veto_window()); + assert!(!without_veto.has_veto_window()); + assert!(with_veto.veto_window().is_some()); + assert!(without_veto.veto_window().is_none()); + } +} diff --git a/crates/coven-threads-core/src/audit.rs b/crates/coven-threads-core/src/audit.rs index fab7fc0..c573ddc 100644 --- a/crates/coven-threads-core/src/audit.rs +++ b/crates/coven-threads-core/src/audit.rs @@ -14,14 +14,140 @@ //! //! WARD-C6 (compaction ledger appends to `ward.audit`) rides the same table: //! `AuditEventType::CompactionLedger`. +//! +//! **Gate-4 applied writes** are also auditable here via +//! `AuditEventType::ApplyAudit` (see §3.4, coven-threads#5). +//! +//! ## Schema versioning and migration +//! +//! `WARD_AUDIT_SCHEMA_SQL` uses `CREATE TABLE IF NOT EXISTS`, so new empty +//! stores automatically receive the current DDL. Legacy stores must be rebuilt +//! with [`ward_audit_migration_sql`], choosing whether the source schema has a +//! `detail` column from schema reality. The migration rebuilds `ward_audit` in +//! a single transaction (create, copy, drop, rename) because SQLite cannot +//! `ALTER` a CHECK constraint. +//! +//! ## Where content hashes ride for applied writes +//! +//! `WardAuditRecord::diff_hash` carries `next_sha256` (the post-write content +//! hash, matching the RFC-0001 §5.6 `diff_hash` semantic for verdict rows). +//! The complementary `prev_sha256` and `bytes_written` ride in `detail` as a +//! compact JSON object `{"prev_sha256":"","bytes_written":N}`. This +//! choice: +//! - avoids new columns that would require another SQLite table rebuild; +//! - keeps `diff_hash` as the canonical single-hash field (consistent with +//! verdict rows where it holds the proposal diff hash); +//! - makes `prev_sha256` query-accessible via SQLite JSON functions when +//! needed (`json_extract(detail, '$.prev_sha256')`). use serde::{Deserialize, Serialize}; use time::OffsetDateTime; +use crate::approval::{ + ApprovalPath, ApprovalPathKind, ProposalApprovalAuditDetail, ProposalWindowAuditDetail, + ProposalWindowCloseAuditDetail, WindowCloseReason, +}; use crate::channel::Channel; use crate::ids::{FamiliarId, ProposalId, SurfaceId, ThreadId, WriterId}; use crate::validate::{MutationRequest, RejectReason, Verdict}; +/// Stable JSON key for the `detail` field of an `apply_audit` row; +pub const APPLY_AUDIT_DETAIL_KEY_PREV: &str = "prev_sha256"; +/// Stable JSON key for the bytes-written count in an `apply_audit` detail. +pub const APPLY_AUDIT_DETAIL_KEY_BYTES: &str = "bytes_written"; +/// Current `ward_audit` component schema version. +pub const WARD_AUDIT_SCHEMA_VERSION: i64 = 20; +/// SQL used after a fresh/current schema is verified without a rebuild. +pub const WARD_AUDIT_STAMP_V020_SQL: &str = " +CREATE TABLE IF NOT EXISTS ward_schema_meta ( + component TEXT PRIMARY KEY NOT NULL, + version INTEGER NOT NULL +); +INSERT INTO ward_schema_meta (component, version) +VALUES ('ward_audit', 20) +ON CONFLICT(component) DO UPDATE SET version = excluded.version; +"; + +/// Safe startup action for the daemon-owned `ward_audit` schema. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WardAuditSchemaAction { + /// No table exists: create the current schema, then stamp it. + InitializeFresh, + /// A legacy table without `detail` exists. + MigrateLegacyWithoutDetail, + /// A legacy table with `detail` exists; preserve every detail payload. + MigrateLegacyWithDetail, + /// The table is current but predates component-version tracking. + StampCurrent, + /// Component metadata is newer than this library; do not downgrade. + UnsupportedNewerVersion, + /// Schema and version are already current. + None, +} + +/// Decide the safe audit-schema startup action from schema reality and version. +/// +/// `schema_sql` must contain the SQL for the table and its Ward-owned triggers. +/// Schema reality is authoritative. Component metadata alone must never cause +/// a rebuild or suppress one because a buggy controller could stamp a legacy +/// schema. +pub fn ward_audit_schema_action( + schema_sql: Option<&str>, + component_version: Option, +) -> WardAuditSchemaAction { + if component_version.is_some_and(|version| version > WARD_AUDIT_SCHEMA_VERSION) { + return WardAuditSchemaAction::UnsupportedNewerVersion; + } + let Some(schema_sql) = schema_sql else { + return WardAuditSchemaAction::InitializeFresh; + }; + let required_fragments = [ + "detail", + "proposal_window_opened", + "memory_entry_admitted", + "principal_authorized_write", + "apply_audit", + "json_array_length(detail, '$.entry_hash') = 32", + "ward_audit_require_authorization_insert", + "ward_audit_require_proposal_approval_detail_insert", + "ward_audit_require_window_close_detail_insert", + "ward_audit_require_single_terminal_insert", + "julianday(json_extract(detail, '$.deadline')) IS NOT NULL", + "NOT GLOB '*[^0-9A-Fa-f]*'", + ]; + if required_fragments + .iter() + .any(|fragment| !schema_sql.contains(fragment)) + { + return if schema_sql.contains("detail") { + WardAuditSchemaAction::MigrateLegacyWithDetail + } else { + WardAuditSchemaAction::MigrateLegacyWithoutDetail + }; + } + if component_version.unwrap_or_default() < WARD_AUDIT_SCHEMA_VERSION { + WardAuditSchemaAction::StampCurrent + } else { + WardAuditSchemaAction::None + } +} + +/// RFC-0001 §5.6 detail for a standard memory admission. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MemoryEntryAdmissionAuditDetail { + /// Hash of the admitted memory entry. + pub entry_hash: Vec, + /// Prior committed Ward state or principal-authorized write event. + pub source_attestation: String, +} + +/// RFC-0001 §5.6 detail for a principal-authorized memory write. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PrincipalAuthorizedWriteAuditDetail { + /// Principal authorization evidence for the write. + pub principal_authorization: String, +} + /// Event types recorded in `ward.audit`. /// /// The first five are RFC-0001 §5.6's named set, verbatim. The last two are the @@ -32,6 +158,8 @@ use crate::validate::{MutationRequest, RejectReason, Verdict}; pub enum AuditEventType { /// A proposal was submitted for review (RFC-0001 §5.6). ProposalSubmitted, + /// A delayed-apply veto window became visible and auditable. + ProposalWindowOpened, /// A proposal was approved (RFC-0001 §5.6). ProposalApproved, /// A proposal was rejected (RFC-0001 §5.6). @@ -40,10 +168,26 @@ pub enum AuditEventType { ProposalVetoed, /// The Ward itself was updated (RFC-0001 §5.6). WardUpdated, + /// A continuity-bearing memory entry was admitted with provenance. + MemoryEntryAdmitted, + /// A principal authorized a memory write outside proposal admission. + PrincipalAuthorizedWrite, /// A gate verdict was issued by `validate` (§5 — every verdict is audited). ValidationVerdict, /// WARD-C6 compaction ledger entry (§3.3, inherited by reference). CompactionLedger, + /// A Gate-4 Tier-2 write was applied and its content snapshot recorded + /// (coven-threads#5, coven#414). + /// + /// Field mapping for this event type: + /// - `diff_hash` — `next_sha256` (post-write content hash of the surface). + /// - `detail` — JSON `{"prev_sha256":"","bytes_written":N}`; + /// `prev_sha256` is the pre-write content hash, `bytes_written` is the + /// number of bytes written to the surface. + /// - `tier` — typically `"tier_2"` (logged-write tier). + /// - `decision` — `"applied"` (the write completed successfully). + /// - `files_touched` — the resolved surface ids that were written. + ApplyAudit, } impl AuditEventType { @@ -51,18 +195,26 @@ impl AuditEventType { pub fn tag(&self) -> &'static str { match self { AuditEventType::ProposalSubmitted => "proposal_submitted", + AuditEventType::ProposalWindowOpened => "proposal_window_opened", AuditEventType::ProposalApproved => "proposal_approved", AuditEventType::ProposalRejected => "proposal_rejected", AuditEventType::ProposalVetoed => "proposal_vetoed", AuditEventType::WardUpdated => "ward_updated", + AuditEventType::MemoryEntryAdmitted => "memory_entry_admitted", + AuditEventType::PrincipalAuthorizedWrite => "principal_authorized_write", AuditEventType::ValidationVerdict => "validation_verdict", AuditEventType::CompactionLedger => "compaction_ledger", + AuditEventType::ApplyAudit => "apply_audit", } } } /// One row of `ward.audit` (RFC-0001 §5.6 field set). /// +/// For `ApplyAudit` events, the extra per-apply fields (`prev_sha256`, +/// `bytes_written`) are encoded in the `detail` field as a JSON object (see +/// module-level docs and [`APPLY_AUDIT_DETAIL_KEY_PREV`]). +/// /// `ward_hash` is RFC-0001 §5.6's audit-log field; at this layer it is the /// `weave_hash` of the weave the verdict was issued against — the commitment /// binding "which authority state decided this." @@ -86,7 +238,22 @@ pub struct WardAuditRecord { /// Who approved/decided, when a principal or tier actor is involved. pub approver: Option, /// Hash of the proposed diff, when the event carries one. + /// + /// For `ApplyAudit` events this is the `next_sha256` — the post-write + /// content hash of the surface. pub diff_hash: Option>, + /// Opaque JSON detail payload, event-type–specific. + /// + /// **Invariant for `ApplyAudit` rows:** `detail` MUST be a JSON object + /// containing exactly: + /// - `"prev_sha256"` — hex string (64 lower-case ASCII chars), the + /// pre-write SHA-256 of the surface content, or `null` when unknown. + /// - `"bytes_written"` — u64, number of bytes written to the surface. + /// + /// Other event types MAY leave `detail` as `None`, or use event-type- + /// specific shapes documented at their construction site. Callers MUST + /// NOT assume `detail` is non-null for non-`ApplyAudit` rows. + pub detail: Option, /// The surfaces this event touches. pub files_touched: Vec, /// The channel the triggering request arrived on, if any (§2.4). @@ -137,6 +304,7 @@ impl WardAuditRecord { decision, approver: None, diff_hash: None, + detail: None, files_touched: vec![request.surface.clone()], channel: Some(request.channel), thread_id, @@ -144,6 +312,307 @@ impl WardAuditRecord { decided_at, } } + + /// Build the audit row for a Gate-4 Tier-2 applied write (coven-threads#5). + /// + /// `next_hash` is the post-write content hash (SHA-256) of the surface; + /// it rides in `diff_hash` matching the single-hash pattern used for + /// verdict rows. + /// + /// `prev_hash` (pre-write content hash) and `bytes_written` are encoded in + /// `detail` as `{"prev_sha256":"","bytes_written":N}` because adding + /// typed columns would require an immediate SQLite table rebuild in all + /// consumers. + /// + /// `weave_hash` is the Ward/weave version the Gate-4 decision was made + /// against (RFC-0001 §5.6 `ward_hash`). + #[allow(clippy::too_many_arguments)] + pub fn for_apply( + familiar_id: FamiliarId, + weave_hash: &[u8], + surface: SurfaceId, + tier: &str, + prev_hash: Option<&[u8]>, + next_hash: Option<&[u8]>, + bytes_written: u64, + channel: Option, + submitted_at: OffsetDateTime, + decided_at: OffsetDateTime, + ) -> Self { + let prev_hex = prev_hash.map(bytes_to_hex); + let detail = serde_json::json!({ + APPLY_AUDIT_DETAIL_KEY_PREV: prev_hex, + APPLY_AUDIT_DETAIL_KEY_BYTES: bytes_written, + }) + .to_string(); + Self { + event_type: AuditEventType::ApplyAudit, + proposal_id: None, + familiar_id, + ward_version: None, + ward_hash: weave_hash.to_vec(), + tier: Some(tier.to_string()), + decision: "applied".to_string(), + approver: None, + diff_hash: next_hash.map(|h| h.to_vec()), + detail: Some(detail), + files_touched: vec![surface], + channel, + thread_id: None, + submitted_at, + decided_at, + } + } + + /// Build an RFC-0001 §5.6 standard memory-admission audit row. + pub fn for_memory_entry_admitted( + familiar_id: FamiliarId, + weave_hash: &[u8], + entry_hash: &[u8], + source_attestation: impl Into, + decided_at: OffsetDateTime, + ) -> Self { + let detail = MemoryEntryAdmissionAuditDetail { + entry_hash: entry_hash.to_vec(), + source_attestation: source_attestation.into(), + }; + Self { + event_type: AuditEventType::MemoryEntryAdmitted, + proposal_id: None, + familiar_id, + ward_version: None, + ward_hash: weave_hash.to_vec(), + tier: None, + decision: "admitted".into(), + approver: None, + diff_hash: None, + detail: Some(serde_json::to_string(&detail).expect("serializing typed audit detail")), + files_touched: Vec::new(), + channel: None, + thread_id: None, + submitted_at: decided_at, + decided_at, + } + } + + /// Build an RFC-0001 §5.6 principal-authorized memory-write audit row. + pub fn for_principal_authorized_write( + familiar_id: FamiliarId, + weave_hash: &[u8], + approver: WriterId, + principal_authorization: impl Into, + files_touched: Vec, + decided_at: OffsetDateTime, + ) -> Self { + let detail = PrincipalAuthorizedWriteAuditDetail { + principal_authorization: principal_authorization.into(), + }; + Self { + event_type: AuditEventType::PrincipalAuthorizedWrite, + proposal_id: None, + familiar_id, + ward_version: None, + ward_hash: weave_hash.to_vec(), + tier: None, + decision: "authorized".into(), + approver: Some(approver), + diff_hash: None, + detail: Some(serde_json::to_string(&detail).expect("serializing typed audit detail")), + files_touched, + channel: Some(Channel::Mutation), + thread_id: None, + submitted_at: decided_at, + decided_at, + } + } + + /// Validate event-specific detail requirements before persistence. + pub fn validate_event_detail(&self) -> Result<(), String> { + if matches!( + self.event_type, + AuditEventType::ProposalApproved + | AuditEventType::ProposalRejected + | AuditEventType::ProposalVetoed + ) && self.proposal_id.is_none() + { + return Err("proposal terminal events require proposal_id".into()); + } + match self.event_type { + AuditEventType::ProposalWindowOpened => { + let detail: ProposalWindowAuditDetail = serde_json::from_str( + self.detail + .as_deref() + .ok_or("proposal_window_opened requires detail")?, + ) + .map_err(|error| format!("invalid proposal window detail: {error}"))?; + if self.proposal_id.is_none() + || detail.approval_path_label.trim().is_empty() + || detail.evidence_replay_hash_hex.len() != 64 + || !detail + .evidence_replay_hash_hex + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + || detail.earliest_close > detail.deadline + { + return Err( + "proposal_window_opened requires a valid path, replay hash, and interval" + .into(), + ); + } + } + AuditEventType::ProposalApproved => { + let detail: ProposalApprovalAuditDetail = serde_json::from_str( + self.detail + .as_deref() + .ok_or("proposal_approved requires approval detail")?, + ) + .map_err(|error| format!("invalid proposal approval detail: {error}"))?; + validate_proposal_approval_detail(self.approver.as_ref(), &detail)?; + } + AuditEventType::ProposalRejected | AuditEventType::ProposalVetoed + if self.detail.is_some() => + { + let detail: ProposalWindowCloseAuditDetail = + serde_json::from_str(self.detail.as_deref().expect("detail checked above")) + .map_err(|error| { + format!("invalid proposal window close detail: {error}") + })?; + validate_window_close_detail(&self.event_type, &detail)?; + } + AuditEventType::MemoryEntryAdmitted => { + let detail: MemoryEntryAdmissionAuditDetail = serde_json::from_str( + self.detail + .as_deref() + .ok_or("memory_entry_admitted requires detail")?, + ) + .map_err(|error| format!("invalid memory admission detail: {error}"))?; + if detail.entry_hash.len() != 32 || detail.source_attestation.trim().is_empty() { + return Err( + "memory_entry_admitted requires entry_hash and source_attestation".into(), + ); + } + } + AuditEventType::WardUpdated | AuditEventType::PrincipalAuthorizedWrite => { + let detail: PrincipalAuthorizedWriteAuditDetail = serde_json::from_str( + self.detail + .as_deref() + .ok_or("authorized Ward writes require detail")?, + ) + .map_err(|error| format!("invalid principal authorization detail: {error}"))?; + if detail.principal_authorization.trim().is_empty() { + return Err("authorized Ward writes require principal_authorization".into()); + } + } + _ => {} + } + Ok(()) + } + + /// Decode `prev_sha256` from the `detail` JSON for `ApplyAudit` rows. + /// + /// Returns `None` for non-`ApplyAudit` events or if the field is missing. + pub fn apply_prev_sha256_hex(&self) -> Option { + let detail = self.detail.as_deref()?; + let v: serde_json::Value = serde_json::from_str(detail).ok()?; + v.get(APPLY_AUDIT_DETAIL_KEY_PREV) + .and_then(|x| x.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + } + + /// Decode `bytes_written` from the `detail` JSON for `ApplyAudit` rows. + /// + /// Returns `None` for non-`ApplyAudit` events or if the field is missing. + pub fn apply_bytes_written(&self) -> Option { + let detail = self.detail.as_deref()?; + let v: serde_json::Value = serde_json::from_str(detail).ok()?; + v.get(APPLY_AUDIT_DETAIL_KEY_BYTES).and_then(|x| x.as_u64()) + } +} + +fn validate_window_close_detail( + event_type: &AuditEventType, + detail: &ProposalWindowCloseAuditDetail, +) -> Result<(), String> { + let event_matches_reason = match event_type { + AuditEventType::ProposalApproved => detail.reason == WindowCloseReason::Applied, + AuditEventType::ProposalVetoed => detail.reason == WindowCloseReason::Vetoed, + AuditEventType::ProposalRejected => matches!( + detail.reason, + WindowCloseReason::EvidenceDiverged + | WindowCloseReason::RevalidationFailed + | WindowCloseReason::Superseded + ), + _ => false, + }; + if !event_matches_reason { + return Err("proposal terminal event does not match its window close reason".into()); + } + + let replay_result_matches = match detail.reason { + WindowCloseReason::Applied => detail.replay_hash_matched == Some(true), + WindowCloseReason::EvidenceDiverged | WindowCloseReason::RevalidationFailed => { + detail.replay_hash_matched == Some(false) + } + WindowCloseReason::Vetoed | WindowCloseReason::Superseded => { + detail.replay_hash_matched.is_none() + } + }; + if !replay_result_matches { + return Err("window close replay result does not match the close reason".into()); + } + Ok(()) +} + +fn validate_proposal_approval_detail( + approver: Option<&WriterId>, + detail: &ProposalApprovalAuditDetail, +) -> Result<(), String> { + let has_approver = approver + .map(WriterId::as_str) + .is_some_and(|value| !value.trim().is_empty()); + let path = ApprovalPath::from_display_label(&detail.approval_path_label) + .ok_or("proposal approval carries an unknown approval path")?; + match path { + ApprovalPathKind::HumanApprovalWithRationale => { + if !has_approver + || detail + .rationale + .as_deref() + .map_or(true, |rationale| rationale.trim().is_empty()) + { + return Err("human_required approval requires an approver and rationale".into()); + } + if detail.window_close.is_some() { + return Err("human approval paths must not carry window close evidence".into()); + } + } + ApprovalPathKind::HumanApproval => { + if !has_approver { + return Err("human_review approval requires an approver".into()); + } + if detail.window_close.is_some() { + return Err("human approval paths must not carry window close evidence".into()); + } + } + ApprovalPathKind::FamiliarCoherence => { + let close = detail + .window_close + .as_ref() + .ok_or("familiar_review approval requires window close evidence")?; + validate_window_close_detail(&AuditEventType::ProposalApproved, close)?; + } + ApprovalPathKind::AutoRegression => { + if let Some(close) = &detail.window_close { + validate_window_close_detail(&AuditEventType::ProposalApproved, close)?; + } + } + } + Ok(()) +} + +fn bytes_to_hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() } fn reject_tag(reason: &RejectReason) -> &'static str { @@ -163,13 +632,277 @@ fn reject_tag(reason: &RejectReason) -> &'static str { /// Idempotent (`IF NOT EXISTS` throughout) so the daemon can apply it at /// startup. Append-only is enforced *in the store*: UPDATE and DELETE abort via /// triggers (RFC-0001 §5.6: entries MUST NOT be deleted or modified). +/// Template for rebuilding a legacy `ward_audit` table to schema v20. +/// +/// SQLite cannot `ALTER` a CHECK constraint on an existing table. This SQL +/// performs a safe swap in one transaction: +/// 1. Creates `ward_audit_new` with the updated CHECK (plus `detail` column). +/// 2. Copies all existing rows (NULLing `detail` for old rows). +/// 3. Drops the old table. +/// 4. Renames the new table into place. +/// 5. Re-creates indexes and append-only triggers. +/// +/// Callers must inspect `sqlite_master` and use [`ward_audit_schema_action`]. +/// [`ward_audit_migration_sql`] substitutes the correct source expression so +/// stores with an existing `detail` column preserve every payload while older +/// stores use SQL `NULL`. +/// +/// The `ward_audit_new` table name is not guarded with `IF NOT EXISTS`, so a +/// double-run fails on the `CREATE` step and cannot silently lose data. +const WARD_AUDIT_MIGRATION_TEMPLATE_SQL: &str = r#" +BEGIN; + +CREATE TABLE ward_audit_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL CHECK (event_type IN ( + 'proposal_submitted','proposal_window_opened', + 'proposal_approved','proposal_rejected', + 'proposal_vetoed','ward_updated','memory_entry_admitted', + 'principal_authorized_write','validation_verdict', + 'compaction_ledger','apply_audit')), + proposal_id TEXT, + familiar_id TEXT NOT NULL, + ward_version TEXT, + ward_hash BLOB NOT NULL, + tier TEXT, + decision TEXT NOT NULL, + approver TEXT, + diff_hash BLOB, + detail TEXT, + files_touched TEXT NOT NULL, + channel TEXT, + thread_id TEXT, + submitted_at TEXT NOT NULL, + decided_at TEXT NOT NULL, + recorded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + CHECK ( + event_type != 'proposal_window_opened' OR ( + proposal_id IS NOT NULL + AND detail IS NOT NULL AND json_valid(detail) + AND json_type(detail, '$.approval_path_label') IS 'text' + AND length(trim(json_extract(detail, '$.approval_path_label'))) > 0 + AND json_type(detail, '$.deadline') IS 'text' + AND julianday(json_extract(detail, '$.deadline')) IS NOT NULL + AND json_type(detail, '$.earliest_close') IS 'text' + AND julianday(json_extract(detail, '$.earliest_close')) IS NOT NULL + AND julianday(json_extract(detail, '$.earliest_close')) + <= julianday(json_extract(detail, '$.deadline')) + AND json_type(detail, '$.evidence_replay_hash_hex') IS 'text' + AND length(json_extract(detail, '$.evidence_replay_hash_hex')) = 64 + AND json_extract(detail, '$.evidence_replay_hash_hex') + NOT GLOB '*[^0-9A-Fa-f]*' + AND json_type(detail, '$.affected_regions') IS 'array' + ) + ), + CHECK ( + event_type != 'memory_entry_admitted' OR ( + detail IS NOT NULL AND json_valid(detail) + AND json_type(detail, '$.entry_hash') IS 'array' + AND json_array_length(detail, '$.entry_hash') = 32 + AND json_type(detail, '$.source_attestation') IS 'text' + AND length(trim(json_extract(detail, '$.source_attestation'))) > 0 + ) + ) +); + +INSERT INTO ward_audit_new ( + id, event_type, proposal_id, familiar_id, ward_version, ward_hash, + tier, decision, approver, diff_hash, detail, files_touched, channel, + thread_id, submitted_at, decided_at, recorded_at +) +SELECT + id, event_type, proposal_id, familiar_id, ward_version, ward_hash, + tier, decision, approver, diff_hash, __SOURCE_DETAIL__, files_touched, channel, + thread_id, submitted_at, decided_at, recorded_at +FROM ward_audit; + +DROP TABLE ward_audit; +ALTER TABLE ward_audit_new RENAME TO ward_audit; + +CREATE INDEX IF NOT EXISTS ward_audit_familiar_idx ON ward_audit (familiar_id, recorded_at); +CREATE INDEX IF NOT EXISTS ward_audit_event_idx ON ward_audit (event_type, recorded_at); +CREATE TRIGGER IF NOT EXISTS ward_audit_append_only_update +BEFORE UPDATE ON ward_audit +BEGIN + SELECT RAISE(ABORT, 'ward_audit is append-only (RFC-0001 §5.6)'); +END; + +CREATE TRIGGER IF NOT EXISTS ward_audit_append_only_delete +BEFORE DELETE ON ward_audit +BEGIN + SELECT RAISE(ABORT, 'ward_audit is append-only (RFC-0001 §5.6)'); +END; + +CREATE TRIGGER IF NOT EXISTS ward_audit_require_single_terminal_insert +BEFORE INSERT ON ward_audit +WHEN NEW.event_type IN ('proposal_approved','proposal_rejected','proposal_vetoed') + AND ( + NEW.proposal_id IS NULL + OR EXISTS ( + SELECT 1 FROM ward_audit + WHERE proposal_id = NEW.proposal_id + AND event_type IN ( + 'proposal_approved','proposal_rejected','proposal_vetoed' + ) + ) + ) +BEGIN + SELECT RAISE(ABORT, 'proposal requires exactly one terminal event'); +END; + +CREATE TRIGGER IF NOT EXISTS ward_audit_require_authorization_insert +BEFORE INSERT ON ward_audit +WHEN NEW.event_type IN ('ward_updated', 'principal_authorized_write') + AND ( + NEW.detail IS NULL OR NOT json_valid(NEW.detail) + OR json_type(NEW.detail, '$.principal_authorization') IS NOT 'text' + OR COALESCE(length(trim(json_extract(NEW.detail, '$.principal_authorization'))), 0) = 0 + ) +BEGIN + SELECT RAISE(ABORT, 'authorized Ward writes require principal_authorization'); +END; + +CREATE TRIGGER IF NOT EXISTS ward_audit_require_proposal_approval_detail_insert +BEFORE INSERT ON ward_audit +WHEN NEW.event_type = 'proposal_approved' + AND ( + NEW.detail IS NULL OR NOT json_valid(NEW.detail) + OR json_type(NEW.detail, '$.approval_path_label') IS NOT 'text' + OR json_extract(NEW.detail, '$.approval_path_label') + NOT IN ('auto','familiar_review','human_review','human_required') + OR COALESCE(json_type(NEW.detail, '$.rationale'), 'missing') + NOT IN ('null','text') + OR COALESCE(json_type(NEW.detail, '$.window_close'), 'missing') + NOT IN ('null','object') + OR ( + json_extract(NEW.detail, '$.approval_path_label') + IN ('human_review','human_required') + AND ( + NEW.approver IS NULL + OR length(trim(NEW.approver)) = 0 + ) + ) + OR ( + json_extract(NEW.detail, '$.approval_path_label') = 'human_required' + AND ( + json_type(NEW.detail, '$.rationale') IS NOT 'text' + OR length(trim(json_extract(NEW.detail, '$.rationale'))) = 0 + ) + ) + OR ( + json_extract(NEW.detail, '$.approval_path_label') + IN ('human_review','human_required') + AND json_type(NEW.detail, '$.window_close') IS NOT 'null' + ) + OR ( + json_extract(NEW.detail, '$.approval_path_label') = 'familiar_review' + AND json_type(NEW.detail, '$.window_close') IS NOT 'object' + ) + OR ( + EXISTS ( + SELECT 1 FROM ward_audit + WHERE proposal_id = NEW.proposal_id + AND event_type = 'proposal_window_opened' + ) + AND json_type(NEW.detail, '$.window_close') IS NOT 'object' + ) + OR ( + json_type(NEW.detail, '$.window_close') IS 'object' + AND ( + json_extract(NEW.detail, '$.window_close.reason') != 'applied' + OR json_type(NEW.detail, '$.window_close.replay_hash_matched') + IS NOT 'true' + OR COALESCE( + json_type(NEW.detail, '$.window_close.rationale'), + 'missing' + ) NOT IN ('null','text') + ) + ) + ) +BEGIN + SELECT RAISE(ABORT, 'proposal approval requires valid path-specific detail'); +END; + +CREATE TRIGGER IF NOT EXISTS ward_audit_require_window_close_detail_insert +BEFORE INSERT ON ward_audit +WHEN NEW.event_type IN ('proposal_rejected', 'proposal_vetoed') + AND EXISTS ( + SELECT 1 FROM ward_audit + WHERE proposal_id = NEW.proposal_id + AND event_type = 'proposal_window_opened' + ) + AND ( + NEW.detail IS NULL OR NOT json_valid(NEW.detail) + OR json_type(NEW.detail, '$.reason') IS NOT 'text' + OR json_extract(NEW.detail, '$.reason') NOT IN ( + 'applied','vetoed','evidence_diverged','revalidation_failed','superseded' + ) + OR COALESCE(json_type(NEW.detail, '$.replay_hash_matched'), 'missing') + NOT IN ('null','true','false') + OR COALESCE(json_type(NEW.detail, '$.rationale'), 'missing') + NOT IN ('null','text') + OR ( + NEW.event_type = 'proposal_vetoed' + AND json_extract(NEW.detail, '$.reason') != 'vetoed' + ) + OR ( + NEW.event_type = 'proposal_rejected' + AND json_extract(NEW.detail, '$.reason') + NOT IN ('evidence_diverged','revalidation_failed','superseded') + ) + OR ( + json_extract(NEW.detail, '$.reason') = 'applied' + AND json_type(NEW.detail, '$.replay_hash_matched') IS NOT 'true' + ) + OR ( + json_extract(NEW.detail, '$.reason') + IN ('evidence_diverged','revalidation_failed') + AND json_type(NEW.detail, '$.replay_hash_matched') IS NOT 'false' + ) + OR ( + json_extract(NEW.detail, '$.reason') IN ('vetoed','superseded') + AND json_type(NEW.detail, '$.replay_hash_matched') IS NOT 'null' + ) + ) +BEGIN + SELECT RAISE(ABORT, 'window terminal events require a valid close reason'); +END; + +CREATE TABLE IF NOT EXISTS ward_schema_meta ( + component TEXT PRIMARY KEY NOT NULL, + version INTEGER NOT NULL +); +INSERT INTO ward_schema_meta (component, version) +VALUES ('ward_audit', 20) +ON CONFLICT(component) DO UPDATE SET version = excluded.version; + +COMMIT; +"#; + +/// Build the transactional legacy migration selected from schema reality. +/// +/// Set `source_has_detail` only when the source table actually contains that +/// column. The resulting SQL either copies `detail` byte-for-byte or supplies +/// `NULL` for pre-detail schemas. +pub fn ward_audit_migration_sql(source_has_detail: bool) -> String { + WARD_AUDIT_MIGRATION_TEMPLATE_SQL.replace( + "__SOURCE_DETAIL__", + if source_has_detail { "detail" } else { "NULL" }, + ) +} + +/// DDL for the `ward.audit` table inside `coven.sqlite3` (§3.4). +/// +/// See module docs for migration notes. pub const WARD_AUDIT_SCHEMA_SQL: &str = r#" CREATE TABLE IF NOT EXISTS ward_audit ( id INTEGER PRIMARY KEY AUTOINCREMENT, event_type TEXT NOT NULL CHECK (event_type IN ( - 'proposal_submitted','proposal_approved','proposal_rejected', - 'proposal_vetoed','ward_updated','validation_verdict', - 'compaction_ledger')), + 'proposal_submitted','proposal_window_opened', + 'proposal_approved','proposal_rejected', + 'proposal_vetoed','ward_updated','memory_entry_admitted', + 'principal_authorized_write','validation_verdict', + 'compaction_ledger','apply_audit')), proposal_id TEXT, familiar_id TEXT NOT NULL, ward_version TEXT, @@ -178,17 +911,45 @@ CREATE TABLE IF NOT EXISTS ward_audit ( decision TEXT NOT NULL, approver TEXT, diff_hash BLOB, + detail TEXT, -- event-type-specific JSON; see module docs files_touched TEXT NOT NULL, -- JSON array of surface ids channel TEXT, thread_id TEXT, submitted_at TEXT NOT NULL, -- RFC 3339 decided_at TEXT NOT NULL, -- RFC 3339 - recorded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + recorded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + CHECK ( + event_type != 'proposal_window_opened' OR ( + proposal_id IS NOT NULL + AND detail IS NOT NULL AND json_valid(detail) + AND json_type(detail, '$.approval_path_label') IS 'text' + AND length(trim(json_extract(detail, '$.approval_path_label'))) > 0 + AND json_type(detail, '$.deadline') IS 'text' + AND julianday(json_extract(detail, '$.deadline')) IS NOT NULL + AND json_type(detail, '$.earliest_close') IS 'text' + AND julianday(json_extract(detail, '$.earliest_close')) IS NOT NULL + AND julianday(json_extract(detail, '$.earliest_close')) + <= julianday(json_extract(detail, '$.deadline')) + AND json_type(detail, '$.evidence_replay_hash_hex') IS 'text' + AND length(json_extract(detail, '$.evidence_replay_hash_hex')) = 64 + AND json_extract(detail, '$.evidence_replay_hash_hex') + NOT GLOB '*[^0-9A-Fa-f]*' + AND json_type(detail, '$.affected_regions') IS 'array' + ) + ), + CHECK ( + event_type != 'memory_entry_admitted' OR ( + detail IS NOT NULL AND json_valid(detail) + AND json_type(detail, '$.entry_hash') IS 'array' + AND json_array_length(detail, '$.entry_hash') = 32 + AND json_type(detail, '$.source_attestation') IS 'text' + AND length(trim(json_extract(detail, '$.source_attestation'))) > 0 + ) + ) ); CREATE INDEX IF NOT EXISTS ward_audit_familiar_idx ON ward_audit (familiar_id, recorded_at); CREATE INDEX IF NOT EXISTS ward_audit_event_idx ON ward_audit (event_type, recorded_at); - CREATE TRIGGER IF NOT EXISTS ward_audit_append_only_update BEFORE UPDATE ON ward_audit BEGIN @@ -200,6 +961,141 @@ BEFORE DELETE ON ward_audit BEGIN SELECT RAISE(ABORT, 'ward_audit is append-only (RFC-0001 §5.6)'); END; + +CREATE TRIGGER IF NOT EXISTS ward_audit_require_single_terminal_insert +BEFORE INSERT ON ward_audit +WHEN NEW.event_type IN ('proposal_approved','proposal_rejected','proposal_vetoed') + AND ( + NEW.proposal_id IS NULL + OR EXISTS ( + SELECT 1 FROM ward_audit + WHERE proposal_id = NEW.proposal_id + AND event_type IN ( + 'proposal_approved','proposal_rejected','proposal_vetoed' + ) + ) + ) +BEGIN + SELECT RAISE(ABORT, 'proposal requires exactly one terminal event'); +END; + +CREATE TRIGGER IF NOT EXISTS ward_audit_require_authorization_insert +BEFORE INSERT ON ward_audit +WHEN NEW.event_type IN ('ward_updated', 'principal_authorized_write') + AND ( + NEW.detail IS NULL OR NOT json_valid(NEW.detail) + OR json_type(NEW.detail, '$.principal_authorization') IS NOT 'text' + OR COALESCE(length(trim(json_extract(NEW.detail, '$.principal_authorization'))), 0) = 0 + ) +BEGIN + SELECT RAISE(ABORT, 'authorized Ward writes require principal_authorization'); +END; + +CREATE TRIGGER IF NOT EXISTS ward_audit_require_proposal_approval_detail_insert +BEFORE INSERT ON ward_audit +WHEN NEW.event_type = 'proposal_approved' + AND ( + NEW.detail IS NULL OR NOT json_valid(NEW.detail) + OR json_type(NEW.detail, '$.approval_path_label') IS NOT 'text' + OR json_extract(NEW.detail, '$.approval_path_label') + NOT IN ('auto','familiar_review','human_review','human_required') + OR COALESCE(json_type(NEW.detail, '$.rationale'), 'missing') + NOT IN ('null','text') + OR COALESCE(json_type(NEW.detail, '$.window_close'), 'missing') + NOT IN ('null','object') + OR ( + json_extract(NEW.detail, '$.approval_path_label') + IN ('human_review','human_required') + AND ( + NEW.approver IS NULL + OR length(trim(NEW.approver)) = 0 + ) + ) + OR ( + json_extract(NEW.detail, '$.approval_path_label') = 'human_required' + AND ( + json_type(NEW.detail, '$.rationale') IS NOT 'text' + OR length(trim(json_extract(NEW.detail, '$.rationale'))) = 0 + ) + ) + OR ( + json_extract(NEW.detail, '$.approval_path_label') + IN ('human_review','human_required') + AND json_type(NEW.detail, '$.window_close') IS NOT 'null' + ) + OR ( + json_extract(NEW.detail, '$.approval_path_label') = 'familiar_review' + AND json_type(NEW.detail, '$.window_close') IS NOT 'object' + ) + OR ( + EXISTS ( + SELECT 1 FROM ward_audit + WHERE proposal_id = NEW.proposal_id + AND event_type = 'proposal_window_opened' + ) + AND json_type(NEW.detail, '$.window_close') IS NOT 'object' + ) + OR ( + json_type(NEW.detail, '$.window_close') IS 'object' + AND ( + json_extract(NEW.detail, '$.window_close.reason') != 'applied' + OR json_type(NEW.detail, '$.window_close.replay_hash_matched') + IS NOT 'true' + OR COALESCE( + json_type(NEW.detail, '$.window_close.rationale'), + 'missing' + ) NOT IN ('null','text') + ) + ) + ) +BEGIN + SELECT RAISE(ABORT, 'proposal approval requires valid path-specific detail'); +END; + +CREATE TRIGGER IF NOT EXISTS ward_audit_require_window_close_detail_insert +BEFORE INSERT ON ward_audit +WHEN NEW.event_type IN ('proposal_rejected', 'proposal_vetoed') + AND EXISTS ( + SELECT 1 FROM ward_audit + WHERE proposal_id = NEW.proposal_id + AND event_type = 'proposal_window_opened' + ) + AND ( + NEW.detail IS NULL OR NOT json_valid(NEW.detail) + OR json_type(NEW.detail, '$.reason') IS NOT 'text' + OR json_extract(NEW.detail, '$.reason') NOT IN ( + 'applied','vetoed','evidence_diverged','revalidation_failed','superseded' + ) + OR COALESCE(json_type(NEW.detail, '$.replay_hash_matched'), 'missing') + NOT IN ('null','true','false') + OR COALESCE(json_type(NEW.detail, '$.rationale'), 'missing') + NOT IN ('null','text') + OR ( + NEW.event_type = 'proposal_vetoed' + AND json_extract(NEW.detail, '$.reason') != 'vetoed' + ) + OR ( + NEW.event_type = 'proposal_rejected' + AND json_extract(NEW.detail, '$.reason') + NOT IN ('evidence_diverged','revalidation_failed','superseded') + ) + OR ( + json_extract(NEW.detail, '$.reason') = 'applied' + AND json_type(NEW.detail, '$.replay_hash_matched') IS NOT 'true' + ) + OR ( + json_extract(NEW.detail, '$.reason') + IN ('evidence_diverged','revalidation_failed') + AND json_type(NEW.detail, '$.replay_hash_matched') IS NOT 'false' + ) + OR ( + json_extract(NEW.detail, '$.reason') IN ('vetoed','superseded') + AND json_type(NEW.detail, '$.replay_hash_matched') IS NOT 'null' + ) + ) +BEGIN + SELECT RAISE(ABORT, 'window terminal events require a valid close reason'); +END; "#; #[cfg(test)] @@ -212,6 +1108,7 @@ mod tests { surface: SurfaceId::new("SOUL.md"), writer: WriterId::new("principal:val"), channel: Channel::Mutation, + identity_context: None, } } @@ -260,12 +1157,16 @@ mod tests { // The CHECK constraint and the enum must not drift. for et in [ AuditEventType::ProposalSubmitted, + AuditEventType::ProposalWindowOpened, AuditEventType::ProposalApproved, AuditEventType::ProposalRejected, AuditEventType::ProposalVetoed, AuditEventType::WardUpdated, + AuditEventType::MemoryEntryAdmitted, + AuditEventType::PrincipalAuthorizedWrite, AuditEventType::ValidationVerdict, AuditEventType::CompactionLedger, + AuditEventType::ApplyAudit, ] { assert!( WARD_AUDIT_SCHEMA_SQL.contains(&format!("'{}'", et.tag())), @@ -283,5 +1184,370 @@ mod tests { assert!(WARD_AUDIT_SCHEMA_SQL.contains("ward_audit_append_only_update")); assert!(WARD_AUDIT_SCHEMA_SQL.contains("ward_audit_append_only_delete")); assert!(WARD_AUDIT_SCHEMA_SQL.contains("append-only (RFC-0001 §5.6)")); + assert!(WARD_AUDIT_SCHEMA_SQL.contains("ward_audit_require_single_terminal_insert")); + } + + #[test] + fn for_apply_produces_correct_shape() { + let now = OffsetDateTime::now_utc(); + let familiar_id = FamiliarId::new(); + let surface = SurfaceId::new("SOUL.md"); + let prev = [0xaa_u8; 32]; + let next = [0xbb_u8; 32]; + + let record = WardAuditRecord::for_apply( + familiar_id, + &[0xcc; 32], + surface.clone(), + "tier_2", + Some(&prev), + Some(&next), + 42, + Some(Channel::Mutation), + now, + now, + ); + assert_eq!(record.event_type, AuditEventType::ApplyAudit); + assert_eq!(record.decision, "applied"); + assert_eq!(record.tier.as_deref(), Some("tier_2")); + assert_eq!(record.diff_hash, Some(next.to_vec())); + assert_eq!(record.files_touched, vec![surface]); + + // detail decoding helpers + let prev_hex = record.apply_prev_sha256_hex().unwrap(); + assert_eq!(prev_hex.len(), 64); + assert!(prev_hex.chars().all(|c| c.is_ascii_hexdigit())); + assert_eq!(record.apply_bytes_written(), Some(42)); + } + + #[test] + fn for_apply_roundtrips_json() { + let now = OffsetDateTime::now_utc(); + let record = WardAuditRecord::for_apply( + FamiliarId::new(), + &[1; 32], + SurfaceId::new("SOUL.md"), + "tier_2", + None, + None, + 0, + None, + now, + now, + ); + let json = serde_json::to_string(&record).unwrap(); + let back: WardAuditRecord = serde_json::from_str(&json).unwrap(); + assert_eq!(record, back); + let detail: serde_json::Value = + serde_json::from_str(record.detail.as_deref().unwrap()).unwrap(); + assert!(detail[APPLY_AUDIT_DETAIL_KEY_PREV].is_null()); + assert_eq!(record.apply_prev_sha256_hex(), None); + } + + #[test] + fn migration_sql_contains_apply_audit_and_detail_column() { + let migration = ward_audit_migration_sql(true); + assert!( + migration.contains("'apply_audit'"), + "migration CHECK must contain apply_audit tag" + ); + assert!( + migration.contains("detail"), + "migration must add detail column" + ); + assert!( + migration.contains("BEGIN;"), + "migration must be a transaction" + ); + assert!( + migration.contains("COMMIT;"), + "migration must be a transaction" + ); + assert!( + !migration.contains("CREATE TABLE IF NOT EXISTS ward_audit_new"), + "migration must fail fast if a stale ward_audit_new table exists" + ); + assert_eq!(WARD_AUDIT_SCHEMA_VERSION, 20); + assert!(!migration.contains("PRAGMA user_version")); + assert!(!WARD_AUDIT_STAMP_V020_SQL.contains("PRAGMA user_version")); + assert!(WARD_AUDIT_STAMP_V020_SQL.contains("ward_schema_meta")); + } + + #[test] + fn migration_preserves_detail_when_the_source_has_it() { + let preserving = ward_audit_migration_sql(true); + assert!(preserving.contains("diff_hash, detail, files_touched")); + assert!(!preserving.contains("__SOURCE_DETAIL__")); + + let pre_detail = ward_audit_migration_sql(false); + assert!(pre_detail.contains("diff_hash, NULL, files_touched")); + assert!(!pre_detail.contains("__SOURCE_DETAIL__")); + } + + #[test] + fn provenance_detail_shapes_match_rfc_fields() { + let admitted = MemoryEntryAdmissionAuditDetail { + entry_hash: vec![0xab; 32], + source_attestation: "ward:abc123".into(), + }; + let admitted_json = serde_json::to_value(admitted).unwrap(); + assert!(admitted_json.get("entry_hash").is_some()); + assert!(admitted_json.get("source_attestation").is_some()); + + let write = PrincipalAuthorizedWriteAuditDetail { + principal_authorization: "principal:val-ed25519".into(), + }; + let write_json = serde_json::to_value(write).unwrap(); + assert!(write_json.get("principal_authorization").is_some()); + } + + #[test] + fn provenance_events_reject_missing_required_detail() { + let now = OffsetDateTime::UNIX_EPOCH; + let record = WardAuditRecord { + event_type: AuditEventType::MemoryEntryAdmitted, + proposal_id: None, + familiar_id: FamiliarId::new(), + ward_version: None, + ward_hash: vec![0xaa; 32], + tier: None, + decision: "admitted".into(), + approver: None, + diff_hash: None, + detail: None, + files_touched: vec![], + channel: None, + thread_id: None, + submitted_at: now, + decided_at: now, + }; + + assert!(record.validate_event_detail().is_err()); + + let mut ward_update = record; + ward_update.event_type = AuditEventType::WardUpdated; + assert!(ward_update.validate_event_detail().is_err()); + } + + #[test] + fn proposal_window_open_rejects_missing_required_detail() { + let now = OffsetDateTime::UNIX_EPOCH; + let record = WardAuditRecord { + event_type: AuditEventType::ProposalWindowOpened, + proposal_id: Some(ProposalId::new()), + familiar_id: FamiliarId::new(), + ward_version: None, + ward_hash: vec![0xaa; 32], + tier: None, + decision: "pending".into(), + approver: None, + diff_hash: None, + detail: None, + files_touched: vec![], + channel: Some(Channel::Mutation), + thread_id: None, + submitted_at: now, + decided_at: now, + }; + + assert!(record.validate_event_detail().is_err()); + + let mut malformed = record; + malformed.detail = Some( + serde_json::to_string(&ProposalWindowAuditDetail { + approval_path_label: "familiar_review".into(), + deadline: now, + earliest_close: now + time::Duration::seconds(1), + evidence_replay_hash_hex: "g".repeat(64), + affected_regions: vec![], + }) + .unwrap(), + ); + assert!(malformed.validate_event_detail().is_err()); + } + + #[test] + fn proposal_window_close_detail_matches_terminal_event() { + let now = OffsetDateTime::UNIX_EPOCH; + let mut record = WardAuditRecord { + event_type: AuditEventType::ProposalApproved, + proposal_id: Some(ProposalId::new()), + familiar_id: FamiliarId::new(), + ward_version: None, + ward_hash: vec![0xaa; 32], + tier: None, + decision: "applied".into(), + approver: None, + diff_hash: None, + detail: Some( + serde_json::to_string(&ProposalApprovalAuditDetail { + approval_path_label: "familiar_review".into(), + rationale: None, + window_close: Some(ProposalWindowCloseAuditDetail { + reason: WindowCloseReason::Applied, + replay_hash_matched: Some(true), + rationale: None, + }), + }) + .unwrap(), + ), + files_touched: vec![], + channel: Some(Channel::Mutation), + thread_id: None, + submitted_at: now, + decided_at: now, + }; + assert!(record.validate_event_detail().is_ok()); + + record.detail = Some( + serde_json::to_string(&ProposalApprovalAuditDetail { + approval_path_label: "familiar_review".into(), + rationale: None, + window_close: Some(ProposalWindowCloseAuditDetail { + reason: WindowCloseReason::Vetoed, + replay_hash_matched: None, + rationale: None, + }), + }) + .unwrap(), + ); + assert!(record.validate_event_detail().is_err()); + } + + #[test] + fn human_required_approval_requires_nonempty_rationale() { + let now = OffsetDateTime::UNIX_EPOCH; + let mut record = WardAuditRecord { + event_type: AuditEventType::ProposalApproved, + proposal_id: Some(ProposalId::new()), + familiar_id: FamiliarId::new(), + ward_version: None, + ward_hash: vec![0xaa; 32], + tier: None, + decision: "applied".into(), + approver: Some(WriterId::new("principal:val")), + diff_hash: None, + detail: Some( + serde_json::to_string(&ProposalApprovalAuditDetail { + approval_path_label: "human_required".into(), + rationale: None, + window_close: None, + }) + .unwrap(), + ), + files_touched: vec![], + channel: Some(Channel::Mutation), + thread_id: None, + submitted_at: now, + decided_at: now, + }; + assert!(record.validate_event_detail().is_err()); + + record.detail = Some( + serde_json::to_string(&ProposalApprovalAuditDetail { + approval_path_label: "human_required".into(), + rationale: Some("principal reviewed identity impact".into()), + window_close: None, + }) + .unwrap(), + ); + assert!(record.validate_event_detail().is_ok()); + + record.approver = Some(WriterId::new(" ")); + assert!(record.validate_event_detail().is_err()); + + record.approver = None; + assert!(record.validate_event_detail().is_err()); + + record.approver = Some(WriterId::new("principal:val")); + record.proposal_id = None; + assert!(record.validate_event_detail().is_err()); + } + + #[test] + fn provenance_constructors_produce_valid_required_detail() { + let now = OffsetDateTime::UNIX_EPOCH; + let admitted = WardAuditRecord::for_memory_entry_admitted( + FamiliarId::new(), + &[0xaa; 32], + &[0xbb; 32], + "ward:event-1", + now, + ); + assert!(admitted.validate_event_detail().is_ok()); + + let write = WardAuditRecord::for_principal_authorized_write( + FamiliarId::new(), + &[0xaa; 32], + WriterId::new("principal:val"), + "signature:abc", + vec![SurfaceId::new("MEMORY.md")], + now, + ); + assert!(write.validate_event_detail().is_ok()); + } + + #[test] + fn schema_action_initializes_missing_table() { + assert_eq!( + ward_audit_schema_action(None, None), + WardAuditSchemaAction::InitializeFresh + ); + } + + #[test] + fn schema_action_migrates_legacy_table_even_with_forged_version() { + let legacy = "CREATE TABLE ward_audit (event_type TEXT CHECK \ + (event_type IN ('proposal_submitted','ward_updated')))"; + assert_eq!( + ward_audit_schema_action(Some(legacy), Some(WARD_AUDIT_SCHEMA_VERSION)), + WardAuditSchemaAction::MigrateLegacyWithoutDetail + ); + } + + #[test] + fn schema_action_migrates_table_missing_persistence_constraints() { + let unconstrained = "CREATE TABLE ward_audit ( + detail TEXT, + event_type TEXT CHECK (event_type IN ( + 'proposal_window_opened','memory_entry_admitted', + 'principal_authorized_write','apply_audit' + )) + )"; + assert_eq!( + ward_audit_schema_action(Some(unconstrained), Some(WARD_AUDIT_SCHEMA_VERSION)), + WardAuditSchemaAction::MigrateLegacyWithDetail + ); + } + + #[test] + fn schema_action_stamps_current_unversioned_table_without_rebuild() { + assert_eq!( + ward_audit_schema_action(Some(WARD_AUDIT_SCHEMA_SQL), None), + WardAuditSchemaAction::StampCurrent + ); + } + + #[test] + fn schema_action_leaves_current_versioned_table_unchanged() { + assert_eq!( + ward_audit_schema_action(Some(WARD_AUDIT_SCHEMA_SQL), Some(WARD_AUDIT_SCHEMA_VERSION)), + WardAuditSchemaAction::None + ); + } + + #[test] + fn schema_action_refuses_to_downgrade_a_newer_component() { + assert_eq!( + ward_audit_schema_action( + Some(WARD_AUDIT_SCHEMA_SQL), + Some(WARD_AUDIT_SCHEMA_VERSION + 1) + ), + WardAuditSchemaAction::UnsupportedNewerVersion + ); + assert_eq!( + ward_audit_schema_action(None, Some(WARD_AUDIT_SCHEMA_VERSION + 1)), + WardAuditSchemaAction::UnsupportedNewerVersion + ); } } diff --git a/crates/coven-threads-core/src/identity_invariants.rs b/crates/coven-threads-core/src/identity_invariants.rs new file mode 100644 index 0000000..db98224 --- /dev/null +++ b/crates/coven-threads-core/src/identity_invariants.rs @@ -0,0 +1,1453 @@ +//! Identity invariant compilation and predicate implementations (`threads-uqx.4`). +//! +//! [`IdentityInvariantSet`] compiles the retired Ward declaration strings into +//! typed, deterministic checks over candidate-harness identity facts. The +//! lower-level surface commitment predicates remain available as supporting +//! evidence, but they are not substitutes for semantic identity evaluation. +//! +//! ## Design principle (spec §2.3, §3.2, decision 4) +//! +//! Identity invariants compile into [`PatternPredicate`] implementations. +//! The predicate is **authoritative**; its [`PatternDescriptor`] is **derived +//! and never enforced on**. This is the same source-authoritative discipline +//! that Phase 0 established for the thread/strand/weave spine — applied here +//! to semantic identity checks. +//! +//! ## Referent mapping (§3.2) +//! +//! | Coven type | Role in identity invariants | +//! |---|---| +//! | [`Thread`] | Authority relationship: protected identity surface → writer | +//! | [`Strand`] | Evidence: hashes, manifest entries, audit anchors | +//! | Weave | Identity predicates are part of the weave pattern, not side-table rows | +//! | [`Channel`] | Invariants must hold under `Mutation` and `Serialization` at minimum | +//! +//! ## Advisory probes (decision 4 / RFC-0001 §5.4–5.5) +//! +//! Model-judgment signals (confidence scores, semantic drift flags, pattern +//! matches) are **Gate-3 evidence only**. They are supplementary; they are +//! never the sole authority for any gated path. The structural/deterministic +//! predicate result is authoritative; probe output feeds `advisory_probes` on +//! the proposal surface, never `probes`. +//! +//! See [`AdvisoryProbe`] and [`AdvisoryProbeResult`] for the type shapes. +//! +//! ## Fail-closed ambiguity rule (decision 4) +//! +//! When deterministic extraction fails or is ambiguous — missing strand, +//! hash mismatch, unparseable manifest entry — the predicate MUST fail closed +//! (return `WeaveCoherence::Broken` or `WeaveCoherence::Degraded`). Silent +//! fallback to probe judgment is forbidden for gates; probes are supplementary +//! evidence, not fallback authorities. + +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; + +use crate::channel::Channel; +use crate::ids::SurfaceId; +use crate::pattern::{PatternDescriptor, PatternPredicate, StrandRequirement, WeaveCoherence}; +use crate::strand::{Strand, StrandKind}; +use crate::thread::Thread; + +// --------------------------------------------------------------------------- +// Retired declaration compiler and candidate identity facts +// --------------------------------------------------------------------------- + +/// Identity fact named by the retired Ward v0.1 invariant declarations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IdentityFact { + /// `familiar.name`. + Name, + /// `familiar.person`. + Person, + /// `familiar.pronouns`. + Pronouns, + /// `familiar.purpose`. + Purpose, + /// `familiar.coven`. + Coven, +} + +impl IdentityFact { + fn from_declaration_name(value: &str) -> Option { + match value { + "familiar.name" => Some(Self::Name), + "familiar.person" => Some(Self::Person), + "familiar.pronouns" => Some(Self::Pronouns), + "familiar.purpose" => Some(Self::Purpose), + "familiar.coven" => Some(Self::Coven), + _ => None, + } + } +} + +/// Comparison operator supported by retired Ward v0.1 declarations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IdentityInvariantOperator { + /// Candidate value must equal the declared value byte-for-byte. + Equals, + /// Candidate value must contain the declared value byte-for-byte. + Includes, +} + +/// One compiled identity invariant. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IdentityInvariantDeclaration { + /// Identity fact being constrained. + pub fact: IdentityFact, + /// Deterministic comparison operation. + pub operator: IdentityInvariantOperator, + /// Principal-declared expected value. + pub expected: String, +} + +/// One identity fact deterministically extracted from the complete candidate +/// familiar harness. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CandidateIdentityFact { + /// Extracted fact. + pub fact: IdentityFact, + /// Extracted candidate value. + pub value: String, +} + +/// Deterministic identity facts for a complete candidate familiar harness. +/// +/// The daemon must derive these facts from the materialized candidate, not +/// infer them from the path that changed. Missing or ambiguous extraction must +/// be represented by omitting the fact; evaluation then fails closed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CandidateIdentityFacts { + /// Commitment tying this extraction to the complete materialized candidate. + pub candidate_commitment: [u8; 32], + values: Vec, +} + +/// Gate-4 identity evidence bound to the daemon's complete candidate harness. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CandidateIdentityContext { + /// Daemon-computed commitment over the complete materialized harness. + pub candidate_commitment: [u8; 32], + /// Deterministically extracted facts carrying their own source commitment. + pub facts: CandidateIdentityFacts, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CandidateIdentityFactsWire { + candidate_commitment: [u8; 32], + values: Vec, +} + +impl CandidateIdentityFacts { + /// Construct a validated candidate fact set. + pub fn try_new( + candidate_commitment: [u8; 32], + mut values: Vec, + ) -> Result { + values.sort_by_key(|entry| entry.fact); + let mut seen = BTreeSet::new(); + for entry in &values { + if entry.value.trim().is_empty() { + return Err(format!("{:?} identity fact must not be empty", entry.fact)); + } + if !seen.insert(entry.fact) { + return Err(format!("duplicate {:?} identity fact", entry.fact)); + } + } + Ok(Self { + candidate_commitment, + values, + }) + } + + /// Return the extracted value for a fact. + pub fn get(&self, fact: IdentityFact) -> Option<&str> { + self.values + .iter() + .find(|entry| entry.fact == fact) + .map(|entry| entry.value.as_str()) + } + + /// Borrow the canonical fact list. + pub fn values(&self) -> &[CandidateIdentityFact] { + &self.values + } +} + +impl<'de> Deserialize<'de> for CandidateIdentityFacts { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let wire = CandidateIdentityFactsWire::deserialize(deserializer)?; + Self::try_new(wire.candidate_commitment, wire.values).map_err(serde::de::Error::custom) + } +} + +/// Validated set of compiled identity invariants. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct IdentityInvariantSet { + declarations: Vec, +} + +impl IdentityInvariantSet { + /// Compile retired Ward declaration strings such as + /// `familiar.name == 'Example'` and `familiar.purpose includes 'review'`. + /// + /// `familiar.name` and `familiar.person` are mandatory. Unknown fields, + /// unknown operators, duplicate declarations, and empty values fail closed. + pub fn compile(declarations: I) -> Result> + where + I: IntoIterator, + S: AsRef, + { + let mut compiled = Vec::new(); + let mut errors = Vec::new(); + for (index, declaration) in declarations.into_iter().enumerate() { + match parse_identity_declaration(declaration.as_ref()) { + Ok(declaration) => compiled.push(declaration), + Err(error) => errors.push(format!("invariant[{index}]: {error}")), + } + } + if !errors.is_empty() { + return Err(errors); + } + Self::try_new(compiled).map_err(|error| vec![error]) + } + + /// Construct from already-parsed declarations and validate the complete set. + pub fn try_new(mut declarations: Vec) -> Result { + declarations.sort_by_key(|declaration| declaration.fact); + let mut seen = BTreeSet::new(); + for declaration in &declarations { + if declaration.expected.is_empty() { + return Err(format!( + "{:?} identity invariant expected value must not be empty", + declaration.fact + )); + } + if !seen.insert(declaration.fact) { + return Err(format!( + "duplicate {:?} identity invariant declaration", + declaration.fact + )); + } + } + for mandatory in [IdentityFact::Name, IdentityFact::Person] { + if !seen.contains(&mandatory) { + return Err(format!( + "missing mandatory {:?} identity invariant declaration", + mandatory + )); + } + } + Ok(Self { declarations }) + } + + /// Evaluate candidate-harness facts without consulting the changed path. + /// + /// Callers must run this for every candidate capable of changing familiar + /// behaviour. A missing extraction, including an omitted optional declared + /// fact, is a deterministic failure rather than an advisory result. + pub fn evaluate( + &self, + expected_candidate_commitment: [u8; 32], + candidate: Option<&CandidateIdentityFacts>, + ) -> WeaveCoherence { + let Some(candidate) = candidate else { + return WeaveCoherence::Broken { + reason: "candidate identity facts unavailable — fail closed".into(), + }; + }; + if candidate.candidate_commitment != expected_candidate_commitment { + return WeaveCoherence::Broken { + reason: "candidate identity facts do not match the materialized harness".into(), + }; + } + + let mut failures = Vec::new(); + for declaration in &self.declarations { + let Some(actual) = candidate.get(declaration.fact) else { + failures.push(format!( + "{:?} identity fact unavailable or ambiguous", + declaration.fact + )); + continue; + }; + let holds = match declaration.operator { + IdentityInvariantOperator::Equals => actual == declaration.expected, + IdentityInvariantOperator::Includes => actual.contains(&declaration.expected), + }; + if !holds { + failures.push(format!( + "{:?} identity invariant did not hold", + declaration.fact + )); + } + } + + if failures.is_empty() { + WeaveCoherence::Coherent + } else { + WeaveCoherence::Broken { + reason: failures.join("; "), + } + } + } + + /// Borrow the canonical declaration list. + pub fn declarations(&self) -> &[IdentityInvariantDeclaration] { + &self.declarations + } +} + +impl<'de> Deserialize<'de> for IdentityInvariantSet { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let declarations = Vec::::deserialize(deserializer)?; + Self::try_new(declarations).map_err(serde::de::Error::custom) + } +} + +/// Authoritative pattern wrapper that joins structural thread coherence with +/// complete-candidate semantic identity invariants. +pub struct IdentityAwarePattern { + /// Structural authority predicate evaluated first. + pub structural: Box, + /// Semantic identity declarations evaluated for every Gate-4 candidate. + pub invariants: IdentityInvariantSet, +} + +impl std::fmt::Debug for IdentityAwarePattern { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("IdentityAwarePattern") + .field("structural", &self.structural) + .field("invariants", &self.invariants) + .finish() + } +} + +impl PatternPredicate for IdentityAwarePattern { + fn coherent(&self, _threads: &[Thread]) -> WeaveCoherence { + WeaveCoherence::Broken { + reason: "identity-aware pattern requires complete candidate evidence".into(), + } + } + + fn coherent_with_context( + &self, + threads: &[Thread], + identity: Option<&CandidateIdentityContext>, + ) -> WeaveCoherence { + let structural = self.structural.coherent_with_context(threads, identity); + if matches!(structural, WeaveCoherence::Broken { .. }) { + return structural; + } + let identity = self.invariants.evaluate( + identity.map_or([0; 32], |context| context.candidate_commitment), + identity.map(|context| &context.facts), + ); + match identity { + WeaveCoherence::Coherent => structural, + broken => broken, + } + } + + fn describe(&self) -> PatternDescriptor { + let mut descriptor = self.structural.describe(); + descriptor.name = format!("identity-aware({})", descriptor.name); + descriptor + } +} + +fn parse_identity_declaration(value: &str) -> Result { + let value = value.trim(); + let equals = value.find(" == "); + let includes = value.find(" includes "); + let (left, operator, right) = match (equals, includes) { + (Some(equals), Some(includes)) if equals < includes => ( + &value[..equals], + IdentityInvariantOperator::Equals, + &value[equals + 4..], + ), + (Some(_), Some(includes)) | (None, Some(includes)) => ( + &value[..includes], + IdentityInvariantOperator::Includes, + &value[includes + 10..], + ), + (Some(equals), None) => ( + &value[..equals], + IdentityInvariantOperator::Equals, + &value[equals + 4..], + ), + (None, None) => return Err("expected `==` or `includes` operator".into()), + }; + let fact = IdentityFact::from_declaration_name(left.trim()) + .ok_or_else(|| format!("unsupported identity fact {:?}", left.trim()))?; + let right = right.trim(); + if right.is_empty() { + return Err("expected value must not be empty".into()); + } + let expected = if right.starts_with('"') { + serde_json::from_str::(right) + .map_err(|error| format!("invalid quoted expected value: {error}"))? + } else if right.starts_with('\'') { + if right.len() < 2 || !right.ends_with('\'') { + return Err("invalid single-quoted expected value".into()); + } + right[1..right.len() - 1].to_string() + } else { + right.to_string() + }; + if expected.is_empty() { + return Err("expected value must not be empty".into()); + } + Ok(IdentityInvariantDeclaration { + fact, + operator, + expected, + }) +} + +// --------------------------------------------------------------------------- +// FamiliarNameInvariant +// --------------------------------------------------------------------------- + +/// Coarse commitment to an identity surface's complete content. +/// +/// ## How it works +/// +/// At weave construction time, the principal records the `expected_hash` of the +/// identity surface (e.g. `IDENTITY.md`) using +/// [`crate::strand::HashAlgo::Blake3`]. At gate +/// time the daemon extracts the `ContentHash` strand from the thread on that +/// surface and compares byte-for-byte. Mismatch → fail closed. +/// +/// This is not a semantic implementation of `familiar.name`: edits to other +/// execution surfaces can alter identity behaviour while this hash remains +/// unchanged. Use [`IdentityInvariantSet`] over complete candidate-harness facts +/// for RFC-0001 identity enforcement; this predicate is supporting evidence. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FamiliarNameInvariant { + /// The identity surface that carries the name (typically `IDENTITY.md`). + pub surface: SurfaceId, + /// Expected Blake3 content hash (32 bytes) of the surface at weave + /// construction time. The predicate fails closed if no thread for this + /// surface carries a matching hash strand. + pub expected_hash: [u8; 32], + /// Human-readable description of what this invariant is protecting. + pub description: String, +} + +impl PatternPredicate for FamiliarNameInvariant { + fn coherent(&self, threads: &[Thread]) -> WeaveCoherence { + let surface_threads: Vec<&Thread> = threads + .iter() + .filter(|t| t.surface == self.surface) + .collect(); + + if surface_threads.is_empty() { + return WeaveCoherence::Broken { + reason: format!( + "FamiliarNameInvariant: no thread found for identity surface {:?} — \ + fail closed (§2.3: deterministic extraction failure)", + self.surface + ), + }; + } + + if surface_threads.len() != 1 { + return WeaveCoherence::Broken { + reason: format!( + "FamiliarNameInvariant: expected exactly one authority thread for {:?}, \ + found {} — fail closed", + self.surface, + surface_threads.len() + ), + }; + } + let thread = surface_threads[0]; + for channel in [Channel::Mutation, Channel::Serialization] { + if let Err(error) = thread.holds_under(channel) { + return WeaveCoherence::Broken { + reason: format!( + "FamiliarNameInvariant: thread for {:?} does not hold under \ + {channel:?}: {error:?}", + self.surface + ), + }; + } + } + let hashes: Vec<&[u8]> = thread + .strands + .iter() + .filter_map(|strand| match strand { + Strand::ContentHash { value, .. } => Some(value.as_slice()), + _ => None, + }) + .collect(); + if hashes.len() != 1 { + return WeaveCoherence::Broken { + reason: format!( + "FamiliarNameInvariant: expected exactly one ContentHash strand for {:?}, \ + found {} — fail closed", + self.surface, + hashes.len() + ), + }; + } + if hashes[0] != self.expected_hash { + return WeaveCoherence::Broken { + reason: format!( + "FamiliarNameInvariant: content hash mismatch on {:?} — \ + expected {:?}, found {:?}. Identity surface mutation \ + requires human review.", + self.surface, + &self.expected_hash[..4], + &hashes[0][..4.min(hashes[0].len())], + ), + }; + } + WeaveCoherence::Coherent + } + + fn describe(&self) -> PatternDescriptor { + PatternDescriptor { + name: format!("familiar-name-invariant({})", self.surface.as_str()), + protected_surfaces: vec![self.surface.clone()], + channels_required: vec![Channel::Mutation, Channel::Serialization], + strand_requirements: vec![StrandRequirement { + kind: StrandKind::ContentHash, + required_on_channels: vec![Channel::Mutation, Channel::Serialization], + }], + } + } +} + +// --------------------------------------------------------------------------- +// ManifestAnchoredInvariant +// --------------------------------------------------------------------------- + +/// Invariant: a protected surface's entry in the familiar manifest has not +/// changed, proven by the manifest entry hash. +/// +/// This is the manifest-layer complement to [`FamiliarNameInvariant`]: +/// rather than hashing the surface content directly, it anchors to the +/// principal's external manifest record. This is the channel `Forced` requires +/// (WARD-C1..C6) — the invariant must survive compaction without familiar +/// cooperation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManifestAnchoredInvariant { + /// The surface protected by this invariant. + pub surface: SurfaceId, + /// Expected entry hash (32 bytes) as recorded in the manifest at weave + /// construction time. + pub expected_entry_hash: [u8; 32], + /// Human-readable label. + pub description: String, +} + +impl PatternPredicate for ManifestAnchoredInvariant { + fn coherent(&self, threads: &[Thread]) -> WeaveCoherence { + let surface_threads: Vec<&Thread> = threads + .iter() + .filter(|t| t.surface == self.surface) + .collect(); + + if surface_threads.is_empty() { + return WeaveCoherence::Broken { + reason: format!( + "ManifestAnchoredInvariant: no thread for {:?} — fail closed", + self.surface + ), + }; + } + + if surface_threads.len() != 1 { + return WeaveCoherence::Broken { + reason: format!( + "ManifestAnchoredInvariant: expected exactly one authority thread for {:?}, \ + found {} — fail closed", + self.surface, + surface_threads.len() + ), + }; + } + let thread = surface_threads[0]; + for channel in [Channel::Forced, Channel::Serialization] { + if let Err(error) = thread.holds_under(channel) { + return WeaveCoherence::Broken { + reason: format!( + "ManifestAnchoredInvariant: thread for {:?} does not hold under \ + {channel:?}: {error:?}", + self.surface + ), + }; + } + } + let entries: Vec<&[u8]> = thread + .strands + .iter() + .filter_map(|strand| match strand { + Strand::ManifestEntry { entry_hash, .. } => Some(entry_hash.as_slice()), + _ => None, + }) + .collect(); + if entries.len() != 1 { + return WeaveCoherence::Broken { + reason: format!( + "ManifestAnchoredInvariant: expected exactly one ManifestEntry strand for \ + {:?}, found {} — fail closed", + self.surface, + entries.len() + ), + }; + } + if entries[0] != self.expected_entry_hash { + return WeaveCoherence::Broken { + reason: format!( + "ManifestAnchoredInvariant: manifest entry hash mismatch on {:?} — \ + pinned {:?}, found {:?}. Requires principal review.", + self.surface, + &self.expected_entry_hash[..4], + &entries[0][..4.min(entries[0].len())], + ), + }; + } + WeaveCoherence::Coherent + } + + fn describe(&self) -> PatternDescriptor { + PatternDescriptor { + name: format!("manifest-anchored-invariant({})", self.surface.as_str()), + protected_surfaces: vec![self.surface.clone()], + channels_required: vec![Channel::Forced, Channel::Serialization], + strand_requirements: vec![ + StrandRequirement { + kind: StrandKind::ContentHash, + required_on_channels: vec![Channel::Forced], + }, + StrandRequirement { + kind: StrandKind::ManifestEntry, + required_on_channels: vec![Channel::Forced], + }, + ], + } + } +} + +// --------------------------------------------------------------------------- +// CompositeIdentityInvariant +// --------------------------------------------------------------------------- + +/// A composite identity predicate that requires ALL component predicates to +/// be coherent. Used to assemble the full familiar identity invariant from +/// individual hash/manifest anchors. +/// +/// ## Fail-closed semantics +/// +/// If any component predicate returns `Degraded` or `Broken`, the composite +/// returns the worst outcome across all components. There is no partial-pass +/// for identity — the identity invariant holds as a unit or not at all. +pub struct CompositeIdentityInvariant { + /// Label for this composite (used in descriptor and error messages). + pub name: String, + /// Component predicates, evaluated in order. + pub components: Vec>, +} + +impl std::fmt::Debug for CompositeIdentityInvariant { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CompositeIdentityInvariant") + .field("name", &self.name) + .field("component_count", &self.components.len()) + .finish() + } +} + +impl PatternPredicate for CompositeIdentityInvariant { + fn coherent(&self, threads: &[Thread]) -> WeaveCoherence { + let mut degraded_surfaces: Vec = Vec::new(); + let mut broken_reasons: Vec = Vec::new(); + let mut degraded_reasons: Vec = Vec::new(); + let mut saw_degraded = false; + + for component in &self.components { + match component.coherent(threads) { + WeaveCoherence::Coherent => {} + WeaveCoherence::Degraded { + degraded_surfaces: mut s, + reason, + } => { + saw_degraded = true; + degraded_surfaces.append(&mut s); + degraded_reasons.push(reason); + } + WeaveCoherence::Broken { reason } => { + broken_reasons.push(reason); + } + } + } + + if !broken_reasons.is_empty() { + WeaveCoherence::Broken { + reason: broken_reasons.join("; "), + } + } else if saw_degraded { + WeaveCoherence::Degraded { + degraded_surfaces, + reason: degraded_reasons.join("; "), + } + } else { + WeaveCoherence::Coherent + } + } + + fn describe(&self) -> PatternDescriptor { + // Merge component descriptors into one. Surfaces and requirements union. + let mut all_surfaces: Vec = Vec::new(); + let mut all_channels: Vec = Vec::new(); + let mut all_requirements: Vec = Vec::new(); + + for component in &self.components { + let d = component.describe(); + for s in d.protected_surfaces { + if !all_surfaces.contains(&s) { + all_surfaces.push(s); + } + } + for c in d.channels_required { + if !all_channels.contains(&c) { + all_channels.push(c); + } + } + for r in d.strand_requirements { + match all_requirements.iter_mut().find(|x| x.kind == r.kind) { + Some(existing) => { + for ch in r.required_on_channels { + if !existing.required_on_channels.contains(&ch) { + existing.required_on_channels.push(ch); + } + } + } + None => all_requirements.push(r), + } + } + } + + PatternDescriptor { + name: self.name.clone(), + protected_surfaces: all_surfaces, + channels_required: all_channels, + strand_requirements: all_requirements, + } + } +} + +// --------------------------------------------------------------------------- +// Advisory probes (Gate-3 only — decision 4 / RFC-0001 §5.4–5.5) +// --------------------------------------------------------------------------- + +/// A model-judgment advisory probe result (Gate-3 supplementary evidence). +/// +/// Advisory probes are **not authoritative**. They are produced by model-based +/// evaluation (confidence scoring, semantic drift detection, pattern matching) +/// and feed the `advisory_probes` block on the proposal surface. They MUST NOT +/// be placed in the deterministic `probes` block, and they MUST NOT be the +/// sole authority for any gated decision path (decision 4, Q4 answer). +/// +/// ## Structural separation (Q4 decision) +/// +/// Deterministic gate evidence → `probes` block. +/// Model-judgment signals → `advisory_probes` block. +/// +/// This is a schema-level distinction, not a display hint. Rules gating on +/// `probes` must not fire on `advisory_probes`, and vice versa. An empty +/// `advisory_probes` means "no advisory signals ran" — not "no probes at all." +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AdvisoryProbeResult { + /// Probe kind identifier (e.g. "semantic_drift", "confidence_score", + /// "pattern_match"). Not used for gating — for display and audit only. + pub kind: String, + /// Human-readable label for this probe. + pub label: String, + /// Numeric confidence in [0.0, 1.0], if the probe produces one. + pub confidence: Option, + /// Freeform signal text (a summary, a matched pattern, a drift description). + pub signal: Option, + /// Whether this probe flagged a potential issue. + pub flagged: bool, + /// IMPORTANT: advisory probe results are NEVER gated on directly. + /// This field is always `false` in a well-formed probe result and is + /// included here as a documentation anchor. + #[serde(default)] + pub is_authoritative: bool, +} + +impl AdvisoryProbeResult { + /// Construct a new advisory probe result. + /// + /// `is_authoritative` is always forced to `false` — advisory probes are + /// structurally non-authoritative (decision 4). + pub fn new( + kind: impl Into, + label: impl Into, + confidence: Option, + signal: Option, + flagged: bool, + ) -> Self { + Self { + kind: kind.into(), + label: label.into(), + confidence, + signal, + flagged, + is_authoritative: false, + } + } + + /// Validate that this probe result is structurally well-formed: + /// - confidence, if present, is in [0.0, 1.0] + /// - is_authoritative is false (enforced at construction; checked here for + /// wire-received values) + pub fn validate(&self) -> Result<(), String> { + if self.is_authoritative { + return Err("AdvisoryProbeResult.is_authoritative must be false — \ + advisory probes are never authoritative (decision 4)" + .into()); + } + if let Some(c) = self.confidence { + if !(0.0..=1.0).contains(&c) { + return Err(format!( + "AdvisoryProbeResult.confidence must be in [0.0, 1.0], got {c}" + )); + } + } + Ok(()) + } +} + +/// A collection of advisory probe results for a single proposal. +/// +/// This is the `advisory_probes` block shape. It is separate from and must +/// never be mixed with the deterministic `probes` block (Q4 decision). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct AdvisoryProbes { + /// The probe results, in evaluation order. + pub results: Vec, +} + +impl AdvisoryProbes { + /// An empty advisory probes block — means "no advisory signals ran." + pub fn empty() -> Self { + Self::default() + } + + /// Whether any probe flagged an issue. + pub fn any_flagged(&self) -> bool { + self.results.iter().any(|r| r.flagged) + } + + /// Validate all results. + pub fn validate(&self) -> Result<(), Vec> { + let errors: Vec = self + .results + .iter() + .enumerate() + .filter_map(|(i, r)| r.validate().err().map(|e| format!("probe[{i}]: {e}"))) + .collect(); + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::fray::SnapReason; + use crate::ids::{ManifestId, StrandId, ThreadId, WriterId}; + use crate::strand::HashAlgo; + use crate::thread::TensionState; + use time::OffsetDateTime; + + fn hash_thread(surface: &str, hash: [u8; 32]) -> Thread { + Thread { + id: ThreadId::new(), + surface: SurfaceId::new(surface), + writer: WriterId::new("principal:val"), + strands: vec![ + Strand::ContentHash { + id: StrandId::new(), + algorithm: HashAlgo::Blake3, + value: hash.to_vec(), + }, + Strand::ManifestEntry { + id: StrandId::new(), + manifest_id: ManifestId::new(), + entry_hash: hash.to_vec(), + }, + Strand::SerializationMarker { + id: StrandId::new(), + format_version: "0.1.0".into(), + contract_hash: vec![0xcc; 32], + }, + ], + holds_under: vec![Channel::Forced, Channel::Serialization, Channel::Mutation], + created_at: OffsetDateTime::UNIX_EPOCH, + tension: TensionState::Holds, + } + } + + fn full_invariant_set() -> IdentityInvariantSet { + IdentityInvariantSet::compile([ + r#"familiar.name == "Nova""#, + r#"familiar.person == "Val""#, + r#"familiar.pronouns == "she/her""#, + r#"familiar.purpose includes "authority""#, + r#"familiar.coven == "OpenCoven""#, + ]) + .unwrap() + } + + fn full_candidate_facts() -> CandidateIdentityFacts { + CandidateIdentityFacts::try_new( + [0x42; 32], + vec![ + CandidateIdentityFact { + fact: IdentityFact::Name, + value: "Nova".into(), + }, + CandidateIdentityFact { + fact: IdentityFact::Person, + value: "Val".into(), + }, + CandidateIdentityFact { + fact: IdentityFact::Pronouns, + value: "she/her".into(), + }, + CandidateIdentityFact { + fact: IdentityFact::Purpose, + value: "protect the authority boundary".into(), + }, + CandidateIdentityFact { + fact: IdentityFact::Coven, + value: "OpenCoven".into(), + }, + ], + ) + .unwrap() + } + + #[derive(Debug)] + struct EmptyDegradedPredicate; + + impl PatternPredicate for EmptyDegradedPredicate { + fn coherent(&self, _threads: &[Thread]) -> WeaveCoherence { + WeaveCoherence::Degraded { + degraded_surfaces: vec![], + reason: "ambiguous degradation".into(), + } + } + + fn describe(&self) -> PatternDescriptor { + PatternDescriptor { + name: "empty-degraded".into(), + protected_surfaces: vec![], + channels_required: vec![], + strand_requirements: vec![], + } + } + } + + #[test] + fn retired_invariant_declarations_compile_with_full_fidelity() { + let invariants = full_invariant_set(); + assert_eq!(invariants.declarations().len(), 5); + assert_eq!( + invariants.declarations()[0].fact, + IdentityFact::Name, + "declarations are canonicalized" + ); + assert!(matches!( + invariants.evaluate([0x42; 32], Some(&full_candidate_facts())), + WeaveCoherence::Coherent + )); + + let encoded = serde_json::to_string(&invariants).unwrap(); + let decoded: IdentityInvariantSet = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded, invariants); + } + + #[test] + fn normative_single_quoted_declarations_compile_and_evaluate() { + let invariants = IdentityInvariantSet::compile([ + "familiar.name == 'Nova'", + "familiar.person == 'Val'", + "familiar.pronouns == 'she/her'", + "familiar.purpose includes 'authority'", + "familiar.coven == 'OpenCoven'", + ]) + .unwrap(); + assert!(matches!( + invariants.evaluate([0x42; 32], Some(&full_candidate_facts())), + WeaveCoherence::Coherent + )); + } + + #[test] + fn compiler_requires_name_and_person() { + let errors = IdentityInvariantSet::compile([ + r#"familiar.pronouns == "she/her""#, + r#"familiar.purpose includes "authority""#, + ]) + .unwrap_err(); + assert!(errors.iter().any(|error| error.contains("Name"))); + } + + #[test] + fn compiler_uses_the_first_declared_operator_not_rhs_text() { + let invariants = IdentityInvariantSet::compile([ + r#"familiar.name == "Nova includes review""#, + r#"familiar.person == "Val""#, + ]) + .unwrap(); + assert_eq!( + invariants.declarations()[0].expected, + "Nova includes review" + ); + } + + #[test] + fn candidate_fact_drift_fails_even_without_a_path_specific_check() { + let invariants = full_invariant_set(); + let mut facts = full_candidate_facts(); + facts + .values + .iter_mut() + .find(|entry| entry.fact == IdentityFact::Person) + .unwrap() + .value = "someone else".into(); + + assert!(matches!( + invariants.evaluate([0x42; 32], Some(&facts)), + WeaveCoherence::Broken { .. } + )); + } + + #[test] + fn missing_candidate_extraction_fails_closed() { + assert!(matches!( + full_invariant_set().evaluate([0x42; 32], None), + WeaveCoherence::Broken { .. } + )); + } + + #[test] + fn stale_candidate_fact_extraction_fails_closed() { + assert!(matches!( + full_invariant_set().evaluate([0x99; 32], Some(&full_candidate_facts())), + WeaveCoherence::Broken { .. } + )); + } + + #[test] + fn candidate_fact_deserialization_rejects_duplicates() { + let json = format!( + r#"{{"candidate_commitment":[{}],"values":[ + {{"fact":"name","value":"Nova"}}, + {{"fact":"name","value":"Other"}} + ]}}"#, + vec!["0"; 32].join(",") + ); + assert!(serde_json::from_str::(&json).is_err()); + } + + // ---- FamiliarNameInvariant ---- + + #[test] + fn familiar_name_invariant_coherent_when_hash_matches() { + let expected = [0xab; 32]; + let inv = FamiliarNameInvariant { + surface: SurfaceId::new("IDENTITY.md"), + expected_hash: expected, + description: "name must not change".into(), + }; + let threads = vec![hash_thread("IDENTITY.md", expected)]; + assert_eq!(inv.coherent(&threads), WeaveCoherence::Coherent); + } + + #[test] + fn familiar_name_invariant_broken_on_hash_mismatch() { + let expected = [0xab; 32]; + let actual = [0xcd; 32]; + let inv = FamiliarNameInvariant { + surface: SurfaceId::new("IDENTITY.md"), + expected_hash: expected, + description: "name must not change".into(), + }; + let threads = vec![hash_thread("IDENTITY.md", actual)]; + match inv.coherent(&threads) { + WeaveCoherence::Broken { reason } => { + assert!(reason.contains("mismatch"), "{reason}"); + } + other => panic!("expected Broken on hash mismatch, got {other:?}"), + } + } + + #[test] + fn familiar_name_invariant_broken_when_no_thread() { + let inv = FamiliarNameInvariant { + surface: SurfaceId::new("IDENTITY.md"), + expected_hash: [0xab; 32], + description: "name must not change".into(), + }; + match inv.coherent(&[]) { + WeaveCoherence::Broken { reason } => { + assert!(reason.contains("no thread"), "{reason}"); + } + other => panic!("expected Broken with no thread, got {other:?}"), + } + } + + #[test] + fn familiar_name_invariant_broken_when_thread_snapped() { + // decision 4: fail-closed — a snapped thread is not valid evidence. + let expected = [0xab; 32]; + let inv = FamiliarNameInvariant { + surface: SurfaceId::new("IDENTITY.md"), + expected_hash: expected, + description: "name must not change".into(), + }; + let mut thread = hash_thread("IDENTITY.md", expected); + thread.snap( + Channel::Mutation, + SnapReason::Revoked, + OffsetDateTime::now_utc(), + ); + // After snap, holds_under(Mutation) fails → thread is skipped → no + // valid evidence → Broken. + match inv.coherent(&[thread]) { + WeaveCoherence::Broken { .. } => {} + other => panic!("expected Broken with snapped thread, got {other:?}"), + } + } + + #[test] + fn familiar_name_invariant_requires_serialization_survival() { + let expected = [0xab; 32]; + let inv = FamiliarNameInvariant { + surface: SurfaceId::new("IDENTITY.md"), + expected_hash: expected, + description: "name must survive serialization".into(), + }; + let mut thread = hash_thread("IDENTITY.md", expected); + thread + .strands + .retain(|strand| !matches!(strand, Strand::SerializationMarker { .. })); + + assert!(matches!( + inv.coherent(&[thread]), + WeaveCoherence::Broken { .. } + )); + } + + #[test] + fn familiar_name_invariant_rejects_conflicting_threads() { + let expected = [0xab; 32]; + let inv = FamiliarNameInvariant { + surface: SurfaceId::new("IDENTITY.md"), + expected_hash: expected, + description: "name must not conflict".into(), + }; + let mut conflicting = hash_thread("IDENTITY.md", [0xcd; 32]); + conflicting.writer = WriterId::new("principal:other"); + + assert!(matches!( + inv.coherent(&[hash_thread("IDENTITY.md", expected), conflicting]), + WeaveCoherence::Broken { .. } + )); + } + + #[test] + fn familiar_name_invariant_describe_is_derived() { + let inv = FamiliarNameInvariant { + surface: SurfaceId::new("IDENTITY.md"), + expected_hash: [0x00; 32], + description: "test".into(), + }; + let d = inv.describe(); + assert!(d + .protected_surfaces + .contains(&SurfaceId::new("IDENTITY.md"))); + assert!(d.channels_required.contains(&Channel::Mutation)); + assert!(d + .strand_requirements + .iter() + .any(|r| r.kind == StrandKind::ContentHash)); + // Descriptor never enforced on — just round-trip it. + let json = serde_json::to_string(&d).unwrap(); + let back: PatternDescriptor = serde_json::from_str(&json).unwrap(); + assert_eq!(d, back); + } + + // ---- ManifestAnchoredInvariant ---- + + #[test] + fn manifest_anchored_invariant_coherent_when_entry_hash_matches() { + let expected = [0x11; 32]; + let inv = ManifestAnchoredInvariant { + surface: SurfaceId::new("SOUL.md"), + expected_entry_hash: expected, + description: "soul surface must remain anchored".into(), + }; + let threads = vec![hash_thread("SOUL.md", expected)]; + assert_eq!(inv.coherent(&threads), WeaveCoherence::Coherent); + } + + #[test] + fn manifest_anchored_invariant_broken_on_mismatch() { + let inv = ManifestAnchoredInvariant { + surface: SurfaceId::new("SOUL.md"), + expected_entry_hash: [0x11; 32], + description: "soul surface must remain anchored".into(), + }; + let threads = vec![hash_thread("SOUL.md", [0x22; 32])]; + match inv.coherent(&threads) { + WeaveCoherence::Broken { reason } => { + assert!(reason.contains("mismatch"), "{reason}"); + } + other => panic!("expected Broken, got {other:?}"), + } + } + + #[test] + fn manifest_anchored_invariant_requires_serialization_survival() { + let expected = [0x11; 32]; + let inv = ManifestAnchoredInvariant { + surface: SurfaceId::new("SOUL.md"), + expected_entry_hash: expected, + description: "manifest must survive serialization".into(), + }; + let mut thread = hash_thread("SOUL.md", expected); + thread + .strands + .retain(|strand| !matches!(strand, Strand::SerializationMarker { .. })); + + assert!(matches!( + inv.coherent(&[thread]), + WeaveCoherence::Broken { .. } + )); + } + + #[test] + fn manifest_anchored_invariant_rejects_conflicting_threads() { + let expected = [0x11; 32]; + let inv = ManifestAnchoredInvariant { + surface: SurfaceId::new("SOUL.md"), + expected_entry_hash: expected, + description: "manifest must not conflict".into(), + }; + let mut conflicting = hash_thread("SOUL.md", [0x22; 32]); + conflicting.writer = WriterId::new("principal:other"); + + assert!(matches!( + inv.coherent(&[hash_thread("SOUL.md", expected), conflicting]), + WeaveCoherence::Broken { .. } + )); + } + + // ---- CompositeIdentityInvariant ---- + + #[test] + fn composite_coherent_when_all_components_coherent() { + let hash = [0xaa; 32]; + let composite = CompositeIdentityInvariant { + name: "full-identity".into(), + components: vec![ + Box::new(FamiliarNameInvariant { + surface: SurfaceId::new("IDENTITY.md"), + expected_hash: hash, + description: "name".into(), + }), + Box::new(ManifestAnchoredInvariant { + surface: SurfaceId::new("SOUL.md"), + expected_entry_hash: hash, + description: "soul".into(), + }), + ], + }; + let threads = vec![ + hash_thread("IDENTITY.md", hash), + hash_thread("SOUL.md", hash), + ]; + assert_eq!(composite.coherent(&threads), WeaveCoherence::Coherent); + } + + #[test] + fn composite_preserves_degraded_result_with_no_surfaces() { + let composite = CompositeIdentityInvariant { + name: "degraded".into(), + components: vec![Box::new(EmptyDegradedPredicate)], + }; + assert!(matches!( + composite.coherent(&[]), + WeaveCoherence::Degraded { .. } + )); + } + + #[test] + fn composite_broken_when_any_component_broken() { + let hash = [0xaa; 32]; + let composite = CompositeIdentityInvariant { + name: "full-identity".into(), + components: vec![ + Box::new(FamiliarNameInvariant { + surface: SurfaceId::new("IDENTITY.md"), + expected_hash: hash, + description: "name".into(), + }), + Box::new(ManifestAnchoredInvariant { + surface: SurfaceId::new("SOUL.md"), + expected_entry_hash: hash, + description: "soul".into(), + }), + ], + }; + // Only IDENTITY.md thread present, SOUL.md missing → composite Broken. + let threads = vec![hash_thread("IDENTITY.md", hash)]; + match composite.coherent(&threads) { + WeaveCoherence::Broken { .. } => {} + other => panic!("expected Broken when component missing, got {other:?}"), + } + } + + #[test] + fn composite_describe_merges_all_components() { + let hash = [0x00; 32]; + let composite = CompositeIdentityInvariant { + name: "full-identity".into(), + components: vec![ + Box::new(FamiliarNameInvariant { + surface: SurfaceId::new("IDENTITY.md"), + expected_hash: hash, + description: "name".into(), + }), + Box::new(ManifestAnchoredInvariant { + surface: SurfaceId::new("SOUL.md"), + expected_entry_hash: hash, + description: "soul".into(), + }), + ], + }; + let d = composite.describe(); + assert!(d + .protected_surfaces + .contains(&SurfaceId::new("IDENTITY.md"))); + assert!(d.protected_surfaces.contains(&SurfaceId::new("SOUL.md"))); + assert!(d.channels_required.contains(&Channel::Mutation)); + assert!(d.channels_required.contains(&Channel::Forced)); + } + + // ---- AdvisoryProbeResult / AdvisoryProbes ---- + + #[test] + fn advisory_probe_result_is_never_authoritative() { + // decision 4: advisory probes are Gate-3 supplementary evidence only. + let probe = AdvisoryProbeResult::new( + "semantic_drift", + "Semantic drift detector", + Some(0.82), + Some("name field appears changed".into()), + true, + ); + assert!( + !probe.is_authoritative, + "advisory probes must never be authoritative" + ); + assert!(probe.validate().is_ok()); + } + + #[test] + fn advisory_probe_rejects_out_of_range_confidence() { + let mut probe = AdvisoryProbeResult::new("test", "test", Some(1.5), None, false); + assert!(probe.validate().is_err()); + probe.confidence = Some(-0.1); + assert!(probe.validate().is_err()); + probe.confidence = Some(1.0); + assert!(probe.validate().is_ok()); + } + + #[test] + fn advisory_probe_rejects_authoritative_flag() { + let probe = AdvisoryProbeResult { + kind: "test".into(), + label: "test".into(), + confidence: None, + signal: None, + flagged: false, + is_authoritative: true, // wire-received violation + }; + assert!(probe.validate().is_err()); + } + + #[test] + fn advisory_probes_block_empty_means_no_signals_ran() { + // Q4 decision: empty advisory_probes != no probes at all. + // It means "no advisory signals ran." + let block = AdvisoryProbes::empty(); + assert!(block.results.is_empty()); + assert!(!block.any_flagged()); + assert!(block.validate().is_ok()); + } + + #[test] + fn advisory_probes_block_any_flagged() { + let mut block = AdvisoryProbes::empty(); + block.results.push(AdvisoryProbeResult::new( + "confidence_score", + "Low confidence", + Some(0.3), + None, + true, + )); + assert!(block.any_flagged()); + assert!(block.validate().is_ok()); + } + + #[test] + fn advisory_probes_roundtrips_json() { + let block = AdvisoryProbes { + results: vec![ + AdvisoryProbeResult::new( + "semantic_drift", + "Drift", + Some(0.75), + Some("shifted".into()), + false, + ), + AdvisoryProbeResult::new("pattern_match", "Pattern", None, None, true), + ], + }; + let json = serde_json::to_string_pretty(&block).unwrap(); + let back: AdvisoryProbes = serde_json::from_str(&json).unwrap(); + assert_eq!(block, back); + // Confirm is_authoritative is never true after round-trip. + for r in &back.results { + assert!(!r.is_authoritative); + } + } +} diff --git a/crates/coven-threads-core/src/lib.rs b/crates/coven-threads-core/src/lib.rs index 235a840..03c4de8 100644 --- a/crates/coven-threads-core/src/lib.rs +++ b/crates/coven-threads-core/src/lib.rs @@ -53,22 +53,41 @@ //! writes, no staging. Those are the daemon's lane (Phase 2). This crate answers //! the gate question and names the verdict. +pub mod approval; pub mod audit; pub mod channel; pub mod fray; +pub mod identity_invariants; pub mod ids; pub mod manifest; pub mod pattern; pub mod portability; pub mod staging; pub mod strand; +pub mod surface_regions; pub mod thread; pub mod validate; pub mod weave; -pub use audit::{AuditEventType, WardAuditRecord, WARD_AUDIT_SCHEMA_SQL}; +pub use approval::{ + ApprovalPath, ApprovalPathKind, ApprovalPathWireEnvelope, ProposalApprovalAuditDetail, + ProposalClassification, ProposalWindowAuditDetail, ProposalWindowCloseAuditDetail, + SurfaceRegionId, VetoWindow, WindowCloseReason, +}; +pub use audit::{ + ward_audit_migration_sql, ward_audit_schema_action, AuditEventType, + MemoryEntryAdmissionAuditDetail, PrincipalAuthorizedWriteAuditDetail, WardAuditRecord, + WardAuditSchemaAction, WARD_AUDIT_SCHEMA_SQL, WARD_AUDIT_SCHEMA_VERSION, + WARD_AUDIT_STAMP_V020_SQL, +}; pub use channel::Channel; pub use fray::{FrayOrSnap, FrayReason, SnapReason}; +pub use identity_invariants::{ + AdvisoryProbeResult, AdvisoryProbes, CandidateIdentityContext, CandidateIdentityFact, + CandidateIdentityFacts, CompositeIdentityInvariant, FamiliarNameInvariant, + IdentityAwarePattern, IdentityFact, IdentityInvariantDeclaration, IdentityInvariantOperator, + IdentityInvariantSet, ManifestAnchoredInvariant, +}; pub use ids::{ CovenId, EventRef, FamiliarId, ManifestId, ProposalId, StrandId, SurfaceId, ThreadId, WeaveId, WriterId, @@ -86,6 +105,11 @@ pub use portability::{ }; pub use staging::{PendingProposal, StagedContents, StagedEdit}; pub use strand::{HashAlgo, SigKind, Strand, StrandKind}; +pub use surface_regions::{ + evidence_replay_hash, ExecutionPromptRegion, HeartbeatBehaviorRegion, MaterializedDiff, + RegionEvidence, SurfaceDiff, SurfaceRegionDescriptor, SurfaceRegionPredicate, + SurfaceRegionRegistry, ToolDefaultsRegion, +}; pub use thread::{TensionState, Thread}; pub use validate::{validate, validate_fail_closed, MutationRequest, RejectReason, Verdict}; pub use weave::{Weave, WeaveError, WeaveRecord}; diff --git a/crates/coven-threads-core/src/pattern.rs b/crates/coven-threads-core/src/pattern.rs index 865b8b6..5849ae3 100644 --- a/crates/coven-threads-core/src/pattern.rs +++ b/crates/coven-threads-core/src/pattern.rs @@ -21,6 +21,7 @@ use serde::{Deserialize, Serialize}; use crate::channel::Channel; +use crate::identity_invariants::CandidateIdentityContext; use crate::ids::SurfaceId; use crate::strand::StrandKind; use crate::thread::Thread; @@ -61,6 +62,19 @@ pub trait PatternPredicate: std::fmt::Debug { /// satisfy the pattern? fn coherent(&self, threads: &[Thread]) -> WeaveCoherence; + /// Authoritative Gate-4 evaluation with complete-candidate identity + /// evidence. Structural-only predicates inherit their ordinary coherence; + /// identity-aware predicates override this method and fail closed when the + /// context is absent or stale. + fn coherent_with_context( + &self, + threads: &[Thread], + identity: Option<&CandidateIdentityContext>, + ) -> WeaveCoherence { + let _ = identity; + self.coherent(threads) + } + /// Derived, non-authoritative structural summary. For humans, tools, and /// CovenCave rendering. MUST NOT be gated on downstream — if anything ever /// enforces on the descriptor instead of the predicate, that is the diff --git a/crates/coven-threads-core/src/surface_regions.rs b/crates/coven-threads-core/src/surface_regions.rs new file mode 100644 index 0000000..a4b3cf7 --- /dev/null +++ b/crates/coven-threads-core/src/surface_regions.rs @@ -0,0 +1,926 @@ +//! Surface-region design — `SurfaceRegionPredicate` + Gate-4 replay (`threads-uqx.5`). +//! +//! ## Motivation (spec §2.2, §3.3, decision 3) +//! +//! Phase 2 protects files via `SurfaceId`. Phase 5 introduces a finer-grained +//! abstraction: a **`SurfaceRegion`** is a typed semantic area within a surface +//! (or across surfaces) that the daemon can classify from a materialized diff. +//! +//! The rule: **classify first; promote to thread later** (decision 3). By +//! default, regions attach their evidence to `ProposalClassification`, not to +//! new thread/strand entries. A region becomes a thread only when it has a +//! stable source-authoritative projection. +//! +//! ## The trait +//! +//! ```rust,ignore +//! pub trait SurfaceRegionPredicate { +//! fn materialize(&self, proposal: &MaterializedDiff) -> RegionEvidence; +//! fn describe(&self) -> SurfaceRegionDescriptor; +//! } +//! ``` +//! +//! - `materialize` — authoritative. The daemon calls this to produce +//! `RegionEvidence` that feeds `ProposalClassification.affected_regions` and +//! Gate-4 deadline replay. +//! - `describe` — derived. Human-readable; Cave-renderable; never enforced on. +//! +//! ## Gate-4 replay constraint +//! +//! The predicate **cannot** depend on: +//! - Cave state +//! - Agent self-report +//! - Stale metadata cached outside the diff +//! +//! It must be a pure function of `MaterializedDiff`. The daemon replays it at +//! the Gate-4 deadline; if the output changes, the proposal is rejected +//! (WARD-C7 generalised — the `evidence_replay_hash` on +//! [`ProposalClassification`] covers region evidence). +//! +//! ## Forward-only reclassification +//! +//! If a region reclassifies mid-session (e.g. a proposal is amended), the new +//! classification applies forward only. Retroactive projection would corrupt +//! the authority trail. +//! +//! [`ProposalClassification`]: crate::approval::ProposalClassification + +use serde::{Deserialize, Serialize}; + +use crate::approval::SurfaceRegionId; +use crate::ids::SurfaceId; + +// --------------------------------------------------------------------------- +// MaterializedDiff +// --------------------------------------------------------------------------- + +/// A materialized diff — the input to `SurfaceRegionPredicate::materialize`. +/// +/// This represents the computed before/after state of every surface a proposal +/// would modify. The daemon materializes this from staged edits before calling +/// any predicate; predicates are pure over it. +/// +/// ## Purity requirement +/// +/// Predicates MUST be pure functions of `MaterializedDiff`. No Cave state, +/// no network calls, no agent self-report, no stale metadata. The daemon must +/// be able to replay the same computation at Gate-4 deadline. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "MaterializedDiffWire")] +pub struct MaterializedDiff { + /// The per-surface before/after pairs. + surfaces: Vec, +} + +/// Before/after for a single surface. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SurfaceDiff { + /// The surface being modified. + pub surface: SurfaceId, + /// Content before the proposal (bytes). `None` if the surface is new. + pub before: Option>, + /// Content after the proposal (bytes). `None` if the surface is deleted. + pub after: Option>, +} + +impl SurfaceDiff { + /// Whether this surface is being added (no prior content). + pub fn is_addition(&self) -> bool { + self.before.is_none() && self.after.is_some() + } + + /// Whether this surface is being deleted. + pub fn is_deletion(&self) -> bool { + self.before.is_some() && self.after.is_none() + } + + /// Whether this surface content actually changed. + pub fn is_modified(&self) -> bool { + self.before != self.after + } +} + +impl MaterializedDiff { + /// Construct a materialized diff, rejecting duplicate surface entries. + pub fn try_new(surfaces: Vec) -> Result { + let mut seen = std::collections::BTreeSet::new(); + for surface in &surfaces { + if !seen.insert(surface.surface.as_str()) { + return Err(format!( + "materialized diff contains duplicate surface {:?}", + surface.surface.as_str() + )); + } + if surface.before.is_none() && surface.after.is_none() { + return Err(format!( + "materialized diff surface {:?} has neither before nor after content", + surface.surface.as_str() + )); + } + if surface.before == surface.after { + return Err(format!( + "materialized diff surface {:?} is unchanged", + surface.surface.as_str() + )); + } + } + Ok(Self { surfaces }) + } + + /// All surface entries in this materialized diff. + pub fn surfaces(&self) -> &[SurfaceDiff] { + &self.surfaces + } + + /// Look up the diff for a specific surface. + pub fn for_surface(&self, surface: &SurfaceId) -> Option<&SurfaceDiff> { + self.surfaces.iter().find(|s| &s.surface == surface) + } + + /// All surfaces that are modified (added, changed, or deleted). + pub fn modified_surfaces(&self) -> impl Iterator { + self.surfaces.iter().filter(|s| s.is_modified()) + } +} + +#[derive(Deserialize)] +struct MaterializedDiffWire { + surfaces: Vec, +} + +impl TryFrom for MaterializedDiff { + type Error = String; + + fn try_from(value: MaterializedDiffWire) -> Result { + Self::try_new(value.surfaces) + } +} + +// --------------------------------------------------------------------------- +// RegionEvidence +// --------------------------------------------------------------------------- + +/// The output of `SurfaceRegionPredicate::materialize` — evidence that a +/// specific semantic region was affected by a proposal. +/// +/// This feeds `ProposalClassification.affected_regions` and is replayed at +/// Gate-4 deadline. The daemon hashes all `RegionEvidence` entries into +/// `ProposalClassification.evidence_replay_hash`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RegionEvidence { + /// The region this evidence belongs to. + pub region_id: SurfaceRegionId, + /// The surfaces on which this region was detected as affected. + pub affected_surfaces: Vec, + /// The minimum approval path tier this region requires. + /// Feeds `ProposalClassification.path_tier_floor` (highest floor wins). + pub min_path_tier: u8, + /// Stable bytes that the daemon can hash for `evidence_replay_hash`. + /// Must be deterministic over the same `MaterializedDiff`. + pub replay_bytes: Vec, + /// Human-readable explanation of why this region was triggered. + pub rationale: String, +} + +/// Canonical Blake3 commitment over the materialized diff and region evidence. +/// +/// Diff, evidence, and affected-surface ordering do not affect the result. +/// Human rationale is deliberately excluded because it is derived display +/// text, not authority. Every committed field is length-prefixed to prevent +/// boundary ambiguity. +pub fn evidence_replay_hash(diff: &MaterializedDiff, evidence: &[RegionEvidence]) -> [u8; 32] { + let mut entries: Vec> = evidence + .iter() + .map(|item| { + let mut encoded = Vec::new(); + push_len_prefixed(&mut encoded, item.region_id.as_str().as_bytes()); + encoded.push(item.min_path_tier); + + let mut surfaces: Vec<&str> = item + .affected_surfaces + .iter() + .map(SurfaceId::as_str) + .collect(); + surfaces.sort_unstable(); + encoded.extend_from_slice(&(surfaces.len() as u64).to_le_bytes()); + for surface in surfaces { + push_len_prefixed(&mut encoded, surface.as_bytes()); + } + push_len_prefixed(&mut encoded, &item.replay_bytes); + encoded + }) + .collect(); + entries.sort(); + + let mut hasher = blake3::Hasher::new(); + hasher.update(b"coven-threads:proposal-evidence:v2"); + let diff_commitment = materialized_diff_commitment(diff); + hasher.update(&(diff_commitment.len() as u64).to_le_bytes()); + hasher.update(&diff_commitment); + hasher.update(&(entries.len() as u64).to_le_bytes()); + for entry in entries { + hasher.update(&(entry.len() as u64).to_le_bytes()); + hasher.update(&entry); + } + *hasher.finalize().as_bytes() +} + +fn materialized_diff_commitment(diff: &MaterializedDiff) -> Vec { + let mut surfaces: Vec<_> = diff.surfaces().iter().collect(); + surfaces.sort_by(|left, right| left.surface.as_str().cmp(right.surface.as_str())); + + let mut commitment = Vec::new(); + push_len_prefixed( + &mut commitment, + b"coven-threads:materialized-diff-commitment:v1", + ); + commitment.extend_from_slice(&(surfaces.len() as u64).to_le_bytes()); + for surface in surfaces { + push_len_prefixed(&mut commitment, surface.surface.as_str().as_bytes()); + push_optional_content_commitment(&mut commitment, surface.before.as_deref()); + push_optional_content_commitment(&mut commitment, surface.after.as_deref()); + } + commitment +} + +fn push_len_prefixed(output: &mut Vec, bytes: &[u8]) { + output.extend_from_slice(&(bytes.len() as u64).to_le_bytes()); + output.extend_from_slice(bytes); +} + +fn materialized_surface_replay(proposal: &MaterializedDiff, affected: &[SurfaceId]) -> Vec { + let mut surfaces = affected.to_vec(); + surfaces.sort_by(|left, right| left.as_str().cmp(right.as_str())); + + let mut replay = Vec::new(); + push_len_prefixed(&mut replay, b"coven-threads:surface-region-replay:v1"); + replay.extend_from_slice(&(surfaces.len() as u64).to_le_bytes()); + for surface in surfaces { + push_len_prefixed(&mut replay, surface.as_str().as_bytes()); + let diff = proposal + .for_surface(&surface) + .expect("affected surfaces are selected from the materialized diff"); + push_optional_content_commitment(&mut replay, diff.before.as_deref()); + push_optional_content_commitment(&mut replay, diff.after.as_deref()); + } + replay +} + +fn push_optional_content_commitment(output: &mut Vec, content: Option<&[u8]>) { + match content { + Some(bytes) => { + output.push(1); + output.extend_from_slice(&(bytes.len() as u64).to_le_bytes()); + output.extend_from_slice(blake3::hash(bytes).as_bytes()); + } + None => output.push(0), + } +} + +// --------------------------------------------------------------------------- +// SurfaceRegionDescriptor +// --------------------------------------------------------------------------- + +/// Derived, human-readable description of a `SurfaceRegionPredicate`. +/// +/// **Never enforced on.** This is for Cave rendering, tool output, and audit +/// legibility only. The predicate output is authoritative. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SurfaceRegionDescriptor { + /// Stable region id. + pub region_id: SurfaceRegionId, + /// Human-readable label (e.g. "execution_prompt", "output_formats"). + pub label: String, + /// Which surfaces this region can span. + pub candidate_surfaces: Vec, + /// Minimum approval tier floor this region typically requires. + pub typical_min_tier: u8, + /// Free-text description for audit / UI. + pub description: String, +} + +// --------------------------------------------------------------------------- +// SurfaceRegionPredicate trait +// --------------------------------------------------------------------------- + +/// Authoritative extractor predicate for a typed semantic surface region. +/// +/// ## Contract +/// +/// - `materialize` MUST be a pure function of `MaterializedDiff`. No external +/// state, no agent self-report, no Cave queries. +/// - `describe` is derived and non-authoritative. +/// - If extraction fails or is ambiguous, `materialize` MUST return evidence +/// with `min_path_tier` elevated (fail-conservative), or the daemon must +/// treat the missing evidence as a block (fail-closed, decision 3). +/// - A region reclassification applies forward only — never retroactively. +pub trait SurfaceRegionPredicate: std::fmt::Debug + Send + Sync { + /// Authoritative materialization: extract region evidence from the diff. + /// Returns `None` if this region is not affected by the diff at all. + fn materialize(&self, proposal: &MaterializedDiff) -> Option; + + /// Derived, non-authoritative descriptor. Cave-renderable. Never enforced + /// on. + fn describe(&self) -> SurfaceRegionDescriptor; +} + +// --------------------------------------------------------------------------- +// Built-in region predicates +// --------------------------------------------------------------------------- + +/// Region: execution prompt surface (e.g. `SOUL.md`, `AGENTS.md` system prompt +/// sections). Highest-protection semantic region — any modification requires at +/// minimum human review (tier floor 0). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExecutionPromptRegion { + /// The surfaces that carry execution prompt content. + pub prompt_surfaces: Vec, +} + +impl ExecutionPromptRegion { + /// Default: `SOUL.md` and `AGENTS.md` as execution prompt surfaces. + pub fn default_protected() -> Self { + Self { + prompt_surfaces: vec![SurfaceId::new("SOUL.md"), SurfaceId::new("AGENTS.md")], + } + } +} + +impl SurfaceRegionPredicate for ExecutionPromptRegion { + fn materialize(&self, proposal: &MaterializedDiff) -> Option { + let affected: Vec = self + .prompt_surfaces + .iter() + .filter(|s| { + proposal + .for_surface(s) + .map(|d| d.is_modified()) + .unwrap_or(false) + }) + .cloned() + .collect(); + + if affected.is_empty() { + return None; + } + + let replay_bytes = materialized_surface_replay(proposal, &affected); + + Some(RegionEvidence { + region_id: SurfaceRegionId::new("execution_prompt"), + affected_surfaces: affected, + min_path_tier: 0, // execution prompt = highest protection = tier floor 0 + replay_bytes, + rationale: "Execution prompt surface modified — human review required".into(), + }) + } + + fn describe(&self) -> SurfaceRegionDescriptor { + SurfaceRegionDescriptor { + region_id: SurfaceRegionId::new("execution_prompt"), + label: "Execution Prompt".into(), + candidate_surfaces: self.prompt_surfaces.clone(), + typical_min_tier: 0, + description: "System-prompt and soul surfaces that define familiar behavior. \ + Any modification triggers human review." + .into(), + } + } +} + +/// Region: tool defaults / skill config (e.g. `TOOLS.md`, `skills/*.toml`). +/// Requires at least familiar review (tier floor 1). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolDefaultsRegion { + /// Surfaces that carry tool or skill config content. + pub config_surfaces: Vec, +} + +impl ToolDefaultsRegion { + /// Default: `TOOLS.md`. + pub fn default_protected() -> Self { + Self { + config_surfaces: vec![SurfaceId::new("TOOLS.md")], + } + } +} + +impl SurfaceRegionPredicate for ToolDefaultsRegion { + fn materialize(&self, proposal: &MaterializedDiff) -> Option { + let affected: Vec = self + .config_surfaces + .iter() + .filter(|s| { + proposal + .for_surface(s) + .map(|d| d.is_modified()) + .unwrap_or(false) + }) + .cloned() + .collect(); + + if affected.is_empty() { + return None; + } + + let replay_bytes = materialized_surface_replay(proposal, &affected); + + Some(RegionEvidence { + region_id: SurfaceRegionId::new("tool_defaults"), + affected_surfaces: affected, + min_path_tier: 1, // familiar review + replay_bytes, + rationale: "Tool config surface modified — familiar review required".into(), + }) + } + + fn describe(&self) -> SurfaceRegionDescriptor { + SurfaceRegionDescriptor { + region_id: SurfaceRegionId::new("tool_defaults"), + label: "Tool Defaults".into(), + candidate_surfaces: self.config_surfaces.clone(), + typical_min_tier: 1, + description: "Tool allowlists, skill config, and capability defaults. \ + Modifications require familiar coherence review." + .into(), + } + } +} + +/// Region: heartbeat behavior (e.g. `HEARTBEAT.md`). +/// Requires at least familiar review (tier floor 1). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HeartbeatBehaviorRegion { + /// Surfaces that define heartbeat behavior. + pub heartbeat_surfaces: Vec, +} + +impl HeartbeatBehaviorRegion { + /// Default heartbeat behavior surface. + pub fn default_protected() -> Self { + Self { + heartbeat_surfaces: vec![SurfaceId::new("HEARTBEAT.md")], + } + } +} + +impl SurfaceRegionPredicate for HeartbeatBehaviorRegion { + fn materialize(&self, proposal: &MaterializedDiff) -> Option { + let affected: Vec = self + .heartbeat_surfaces + .iter() + .filter(|s| { + proposal + .for_surface(s) + .map(|d| d.is_modified()) + .unwrap_or(false) + }) + .cloned() + .collect(); + + if affected.is_empty() { + return None; + } + + let replay_bytes = materialized_surface_replay(proposal, &affected); + + Some(RegionEvidence { + region_id: SurfaceRegionId::new("heartbeat_behavior"), + affected_surfaces: affected, + min_path_tier: 1, + replay_bytes, + rationale: "Heartbeat behavior surface modified".into(), + }) + } + + fn describe(&self) -> SurfaceRegionDescriptor { + SurfaceRegionDescriptor { + region_id: SurfaceRegionId::new("heartbeat_behavior"), + label: "Heartbeat Behavior".into(), + candidate_surfaces: self.heartbeat_surfaces.clone(), + typical_min_tier: 1, + description: "Surfaces controlling familiar heartbeat schedule and checks.".into(), + } + } +} + +/// A registry of region predicates. The daemon holds one of these and calls +/// `classify_all` at proposal intake to populate +/// `ProposalClassification.affected_regions`. +pub struct SurfaceRegionRegistry { + predicates: Vec>, +} + +impl std::fmt::Debug for SurfaceRegionRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SurfaceRegionRegistry") + .field("predicate_count", &self.predicates.len()) + .finish() + } +} + +impl SurfaceRegionRegistry { + /// Construct with an explicit list of predicates. + pub fn new(predicates: Vec>) -> Self { + Self { predicates } + } + + /// Default registry: execution prompt + tool defaults + heartbeat. + pub fn default_registry() -> Self { + Self::new(vec![ + Box::new(ExecutionPromptRegion::default_protected()), + Box::new(ToolDefaultsRegion::default_protected()), + Box::new(HeartbeatBehaviorRegion::default_protected()), + ]) + } + + /// Classify all affected regions for a diff. Returns evidence for every + /// region that `materialize` returns `Some` for. + /// + /// The results are in predicate registration order (stable for replay). + pub fn classify_all(&self, diff: &MaterializedDiff) -> Vec { + self.predicates + .iter() + .filter_map(|p| p.materialize(diff)) + .collect() + } + + /// Derive descriptors for all registered regions (for Cave / display). + pub fn descriptors(&self) -> Vec { + self.predicates.iter().map(|p| p.describe()).collect() + } + + /// The highest (lowest number = most protected) path tier floor across all + /// evidence. Returns `u8::MAX` if no evidence (no floor imposed). + pub fn path_tier_floor(evidence: &[RegionEvidence]) -> u8 { + evidence + .iter() + .map(|e| e.min_path_tier) + .min() + .unwrap_or(u8::MAX) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn diff_with(surface: &str, before: Option<&[u8]>, after: Option<&[u8]>) -> MaterializedDiff { + MaterializedDiff::try_new(vec![SurfaceDiff { + surface: SurfaceId::new(surface), + before: before.map(|b| b.to_vec()), + after: after.map(|b| b.to_vec()), + }]) + .unwrap() + } + + // ---- SurfaceDiff helpers ---- + + #[test] + fn surface_diff_flags_modification_correctly() { + let modified = SurfaceDiff { + surface: SurfaceId::new("SOUL.md"), + before: Some(b"old".to_vec()), + after: Some(b"new".to_vec()), + }; + assert!(modified.is_modified()); + assert!(!modified.is_addition()); + assert!(!modified.is_deletion()); + + let added = SurfaceDiff { + surface: SurfaceId::new("new.md"), + before: None, + after: Some(b"content".to_vec()), + }; + assert!(added.is_addition()); + + let deleted = SurfaceDiff { + surface: SurfaceId::new("old.md"), + before: Some(b"content".to_vec()), + after: None, + }; + assert!(deleted.is_deletion()); + } + + #[test] + fn materialized_diff_rejects_duplicate_surfaces() { + let duplicate = vec![ + SurfaceDiff { + surface: SurfaceId::new("SOUL.md"), + before: Some(b"old".to_vec()), + after: Some(b"unchanged".to_vec()), + }, + SurfaceDiff { + surface: SurfaceId::new("SOUL.md"), + before: Some(b"old".to_vec()), + after: Some(b"changed".to_vec()), + }, + ]; + + assert!(MaterializedDiff::try_new(duplicate).is_err()); + } + + #[test] + fn materialized_diff_rejects_absent_before_and_after() { + let error = MaterializedDiff::try_new(vec![SurfaceDiff { + surface: SurfaceId::new("SOUL.md"), + before: None, + after: None, + }]) + .unwrap_err(); + + assert!(error.contains("neither before nor after content")); + } + + #[test] + fn materialized_diff_rejects_unchanged_surfaces() { + let error = MaterializedDiff::try_new(vec![SurfaceDiff { + surface: SurfaceId::new("SOUL.md"), + before: Some(b"same".to_vec()), + after: Some(b"same".to_vec()), + }]) + .unwrap_err(); + + assert!(error.contains("unchanged")); + } + + #[test] + fn materialized_diff_deserialization_rejects_duplicate_surfaces() { + let json = r#"{"surfaces":[ + {"surface":"SOUL.md","before":null,"after":[97]}, + {"surface":"SOUL.md","before":[97],"after":[98]} + ]}"#; + assert!(serde_json::from_str::(json).is_err()); + } + + // ---- ExecutionPromptRegion ---- + + #[test] + fn execution_prompt_region_triggers_on_soul_md_change() { + let region = ExecutionPromptRegion::default_protected(); + let diff = diff_with("SOUL.md", Some(b"old soul"), Some(b"new soul")); + let evidence = region.materialize(&diff); + assert!(evidence.is_some()); + let ev = evidence.unwrap(); + assert_eq!(ev.region_id.as_str(), "execution_prompt"); + assert_eq!(ev.min_path_tier, 0, "execution prompt is tier-0 protected"); + assert!(ev.affected_surfaces.contains(&SurfaceId::new("SOUL.md"))); + assert!(!ev.replay_bytes.is_empty()); + } + + #[test] + fn execution_prompt_region_returns_none_for_unrelated_surface() { + let region = ExecutionPromptRegion::default_protected(); + let diff = diff_with("README.md", Some(b"old"), Some(b"new")); + assert!(region.materialize(&diff).is_none()); + } + + #[test] + fn execution_prompt_region_replay_bytes_are_deterministic() { + // Gate-4 replay: same diff → same replay_bytes. + let region = ExecutionPromptRegion::default_protected(); + let diff = MaterializedDiff::try_new(vec![ + SurfaceDiff { + surface: SurfaceId::new("SOUL.md"), + before: Some(b"A".to_vec()), + after: Some(b"B".to_vec()), + }, + SurfaceDiff { + surface: SurfaceId::new("AGENTS.md"), + before: Some(b"X".to_vec()), + after: Some(b"Y".to_vec()), + }, + ]) + .unwrap(); + let ev1 = region.materialize(&diff).unwrap(); + let ev2 = region.materialize(&diff).unwrap(); + assert_eq!( + ev1.replay_bytes, ev2.replay_bytes, + "replay must be deterministic" + ); + } + + #[test] + fn execution_prompt_replay_commits_the_before_state() { + let region = ExecutionPromptRegion::default_protected(); + let first = diff_with("SOUL.md", Some(b"old-a"), Some(b"same-result")); + let second = diff_with("SOUL.md", Some(b"old-b"), Some(b"same-result")); + + assert_ne!( + region.materialize(&first).unwrap().replay_bytes, + region.materialize(&second).unwrap().replay_bytes, + "intervening live-state drift must change replay evidence" + ); + } + + #[test] + fn execution_prompt_replay_distinguishes_delete_from_empty_replace() { + let region = ExecutionPromptRegion::default_protected(); + let deleted = diff_with("SOUL.md", Some(b"old"), None); + let emptied = diff_with("SOUL.md", Some(b"old"), Some(b"")); + + assert_ne!( + region.materialize(&deleted).unwrap().replay_bytes, + region.materialize(&emptied).unwrap().replay_bytes + ); + } + + #[test] + fn execution_prompt_region_describe_is_derived_and_non_authoritative() { + let region = ExecutionPromptRegion::default_protected(); + let d = region.describe(); + assert_eq!(d.region_id.as_str(), "execution_prompt"); + assert_eq!(d.typical_min_tier, 0); + // Descriptor roundtrips (for Cave rendering). + let json = serde_json::to_string(&d).unwrap(); + let back: SurfaceRegionDescriptor = serde_json::from_str(&json).unwrap(); + assert_eq!(d, back); + } + + // ---- ToolDefaultsRegion ---- + + #[test] + fn tool_defaults_region_triggers_on_tools_md() { + let region = ToolDefaultsRegion::default_protected(); + let diff = diff_with("TOOLS.md", Some(b"old tools"), Some(b"new tools")); + let ev = region.materialize(&diff).unwrap(); + assert_eq!(ev.region_id.as_str(), "tool_defaults"); + assert_eq!(ev.min_path_tier, 1); + } + + // ---- SurfaceRegionRegistry ---- + + #[test] + fn registry_classify_all_returns_only_triggered_regions() { + let registry = SurfaceRegionRegistry::default_registry(); + // Only HEARTBEAT.md changed → only heartbeat region triggers. + let diff = diff_with("HEARTBEAT.md", Some(b"old"), Some(b"new")); + let evidence = registry.classify_all(&diff); + assert_eq!(evidence.len(), 1); + assert_eq!(evidence[0].region_id.as_str(), "heartbeat_behavior"); + } + + #[test] + fn registry_classify_all_multiple_regions() { + let registry = SurfaceRegionRegistry::default_registry(); + let diff = MaterializedDiff::try_new(vec![ + SurfaceDiff { + surface: SurfaceId::new("SOUL.md"), + before: Some(b"old".to_vec()), + after: Some(b"new".to_vec()), + }, + SurfaceDiff { + surface: SurfaceId::new("TOOLS.md"), + before: Some(b"old".to_vec()), + after: Some(b"new".to_vec()), + }, + ]) + .unwrap(); + let evidence = registry.classify_all(&diff); + assert_eq!(evidence.len(), 2); + } + + #[test] + fn registry_path_tier_floor_is_minimum_across_evidence() { + let evidence = vec![ + RegionEvidence { + region_id: SurfaceRegionId::new("execution_prompt"), + affected_surfaces: vec![], + min_path_tier: 0, + replay_bytes: vec![], + rationale: "".into(), + }, + RegionEvidence { + region_id: SurfaceRegionId::new("tool_defaults"), + affected_surfaces: vec![], + min_path_tier: 1, + replay_bytes: vec![], + rationale: "".into(), + }, + ]; + // Floor = min = 0 (most protective wins). + assert_eq!(SurfaceRegionRegistry::path_tier_floor(&evidence), 0); + } + + #[test] + fn registry_path_tier_floor_max_when_no_evidence() { + // No regions triggered → no floor imposed. + assert_eq!( + SurfaceRegionRegistry::path_tier_floor(&[]), + u8::MAX, + "no evidence = no floor" + ); + } + + #[test] + fn registry_descriptors_covers_all_registered_regions() { + let registry = SurfaceRegionRegistry::default_registry(); + let descs = registry.descriptors(); + assert_eq!(descs.len(), 3); + let ids: Vec<&str> = descs.iter().map(|d| d.region_id.as_str()).collect(); + assert!(ids.contains(&"execution_prompt")); + assert!(ids.contains(&"tool_defaults")); + assert!(ids.contains(&"heartbeat_behavior")); + } + + #[test] + fn classify_all_returns_empty_when_nothing_matches() { + let registry = SurfaceRegionRegistry::default_registry(); + let diff = diff_with("CHANGELOG.md", Some(b"old"), Some(b"new")); + assert!(registry.classify_all(&diff).is_empty()); + } + + #[test] + fn evidence_replay_hash_is_independent_of_evidence_order() { + let first = RegionEvidence { + region_id: SurfaceRegionId::new("execution_prompt"), + affected_surfaces: vec![SurfaceId::new("SOUL.md")], + min_path_tier: 0, + replay_bytes: b"alpha".to_vec(), + rationale: "display only".into(), + }; + let second = RegionEvidence { + region_id: SurfaceRegionId::new("tool_defaults"), + affected_surfaces: vec![SurfaceId::new("TOOLS.md")], + min_path_tier: 1, + replay_bytes: b"beta".to_vec(), + rationale: "display only".into(), + }; + + assert_eq!( + evidence_replay_hash( + &diff_with("README.md", Some(b"a"), Some(b"b")), + &[first.clone(), second.clone()] + ), + evidence_replay_hash( + &diff_with("README.md", Some(b"a"), Some(b"b")), + &[second, first] + ) + ); + } + + #[test] + fn evidence_replay_hash_commits_field_boundaries() { + let split = RegionEvidence { + region_id: SurfaceRegionId::new("ab"), + affected_surfaces: vec![SurfaceId::new("c")], + min_path_tier: 1, + replay_bytes: b"d".to_vec(), + rationale: String::new(), + }; + let joined = RegionEvidence { + region_id: SurfaceRegionId::new("a"), + affected_surfaces: vec![SurfaceId::new("bc")], + min_path_tier: 1, + replay_bytes: b"d".to_vec(), + rationale: String::new(), + }; + + assert_ne!( + evidence_replay_hash(&diff_with("README.md", Some(b"a"), Some(b"b")), &[split]), + evidence_replay_hash(&diff_with("README.md", Some(b"a"), Some(b"b")), &[joined]) + ); + } + + #[test] + fn evidence_replay_hash_commits_unclassified_surfaces() { + let first = MaterializedDiff::try_new(vec![ + SurfaceDiff { + surface: SurfaceId::new("SOUL.md"), + before: Some(b"old".to_vec()), + after: Some(b"new".to_vec()), + }, + SurfaceDiff { + surface: SurfaceId::new("README.md"), + before: Some(b"a".to_vec()), + after: Some(b"b".to_vec()), + }, + ]) + .unwrap(); + let second = MaterializedDiff::try_new(vec![ + SurfaceDiff { + surface: SurfaceId::new("SOUL.md"), + before: Some(b"old".to_vec()), + after: Some(b"new".to_vec()), + }, + SurfaceDiff { + surface: SurfaceId::new("README.md"), + before: Some(b"a".to_vec()), + after: Some(b"c".to_vec()), + }, + ]) + .unwrap(); + let region = ExecutionPromptRegion::default_protected(); + let first_evidence = vec![region.materialize(&first).unwrap()]; + let second_evidence = vec![region.materialize(&second).unwrap()]; + + assert_ne!( + evidence_replay_hash(&first, &first_evidence), + evidence_replay_hash(&second, &second_evidence) + ); + } +} diff --git a/crates/coven-threads-core/src/validate.rs b/crates/coven-threads-core/src/validate.rs index e3da317..6783c8e 100644 --- a/crates/coven-threads-core/src/validate.rs +++ b/crates/coven-threads-core/src/validate.rs @@ -27,6 +27,7 @@ use serde::{Deserialize, Serialize}; use crate::channel::Channel; use crate::fray::{FrayOrSnap, SnapReason}; +use crate::identity_invariants::CandidateIdentityContext; use crate::ids::{SurfaceId, ThreadId, WriterId}; use crate::pattern::WeaveCoherence; use crate::weave::Weave; @@ -40,6 +41,11 @@ pub struct MutationRequest { pub writer: WriterId, /// The channel the mutation arrives on (§2.4). pub channel: Channel, + /// Complete-candidate identity evidence for identity-aware patterns. + /// + /// Structural-only patterns ignore this. Identity-aware patterns reject + /// when it is absent, stale, or semantically inconsistent. + pub identity_context: Option, } /// The validator's verdict (§5). The daemon acts on this and audits it. @@ -183,7 +189,7 @@ pub fn validate(weave: &Weave, request: &MutationRequest) -> Verdict { } // 4. The weave-level gate: predicate authoritative (§2.2). - match weave.coherence() { + match weave.coherence_with_context(request.identity_context.as_ref()) { WeaveCoherence::Broken { reason } => Verdict::Reject { reason: RejectReason::WeaveBroken { reason }, }, @@ -245,6 +251,10 @@ impl Verdict { mod tests { use super::*; use crate::fray::FrayReason; + use crate::identity_invariants::{ + CandidateIdentityFact, CandidateIdentityFacts, IdentityAwarePattern, IdentityFact, + IdentityInvariantSet, + }; use crate::ids::{FamiliarId, ManifestId, StrandId, WeaveId}; use crate::pattern::{AllSurfacesHoldOnChannels, PatternDescriptor, PatternPredicate}; use crate::strand::{HashAlgo, Strand}; @@ -302,6 +312,7 @@ mod tests { surface: SurfaceId::new(surface), writer: WriterId::new(writer), channel, + identity_context: None, } } @@ -312,6 +323,66 @@ mod tests { assert!(v.permits_write(), "expected Permit, got {v:?}"); } + #[test] + fn identity_aware_pattern_gates_authoritative_validation() { + let invariants = + IdentityInvariantSet::compile(["familiar.name == 'Nova'", "familiar.person == 'Val'"]) + .unwrap(); + let weave = Weave::new( + WeaveId::new(), + FamiliarId::new(), + ["SOUL.md", "IDENTITY.md", "MEMORY.md", "ward.toml"] + .into_iter() + .map(|surface| floor_thread(surface, "principal:val")) + .collect(), + Box::new(IdentityAwarePattern { + structural: Box::new(AllSurfacesHoldOnChannels::rfc0001_floor()), + invariants, + }), + None, + ) + .unwrap(); + let mut mutation = request("SOUL.md", "principal:val", Channel::Mutation); + + assert!(matches!( + validate(&weave, &mutation), + Verdict::Reject { + reason: RejectReason::WeaveBroken { .. } + } + )); + + mutation.identity_context = Some(CandidateIdentityContext { + candidate_commitment: [0x42; 32], + facts: CandidateIdentityFacts::try_new( + [0x42; 32], + vec![ + CandidateIdentityFact { + fact: IdentityFact::Name, + value: "Nova".into(), + }, + CandidateIdentityFact { + fact: IdentityFact::Person, + value: "Val".into(), + }, + ], + ) + .unwrap(), + }); + assert!(validate(&weave, &mutation).permits_write()); + + mutation + .identity_context + .as_mut() + .unwrap() + .candidate_commitment = [0x99; 32]; + assert!(matches!( + validate(&weave, &mutation), + Verdict::Reject { + reason: RejectReason::WeaveBroken { .. } + } + )); + } + #[test] fn unknown_surface_rejects() { // §5: Unknown surface path → Reject. diff --git a/crates/coven-threads-core/src/weave.rs b/crates/coven-threads-core/src/weave.rs index 5fa9714..9e952af 100644 --- a/crates/coven-threads-core/src/weave.rs +++ b/crates/coven-threads-core/src/weave.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; +use crate::identity_invariants::CandidateIdentityContext; use crate::ids::{CovenId, FamiliarId, SurfaceId, ThreadId, WeaveId, WriterId}; use crate::manifest::merkle_root; use crate::pattern::{PatternDescriptor, PatternPredicate, WeaveCoherence}; @@ -160,6 +161,14 @@ impl Weave { self.pattern.coherent(&self.threads) } + /// Evaluate the authoritative pattern for a materialized Gate-4 candidate. + pub fn coherence_with_context( + &self, + identity: Option<&CandidateIdentityContext>, + ) -> WeaveCoherence { + self.pattern.coherent_with_context(&self.threads, identity) + } + /// The derived, non-authoritative descriptor of this weave's pattern. /// For rendering and audit only — never enforcement (§2.2). pub fn describe_pattern(&self) -> PatternDescriptor { diff --git a/crates/coven-threads-core/tests/phase5_retired_ward_corpus.rs b/crates/coven-threads-core/tests/phase5_retired_ward_corpus.rs new file mode 100644 index 0000000..ca695c2 --- /dev/null +++ b/crates/coven-threads-core/tests/phase5_retired_ward_corpus.rs @@ -0,0 +1,239 @@ +//! Conformance coverage for the public synthetic retired-Ward corpus generator. + +#[path = "support/phase5_retired_ward_corpus.rs"] +mod corpus; + +use std::time::Duration; + +use coven_threads_core::approval::{ApprovalPath, ApprovalPathKind, VetoWindow}; +use coven_threads_core::identity_invariants::{ + CandidateIdentityFact, CandidateIdentityFacts, IdentityFact, IdentityInvariantSet, +}; +use coven_threads_core::ids::SurfaceId; +use coven_threads_core::pattern::WeaveCoherence; +use coven_threads_core::surface_regions::{MaterializedDiff, SurfaceDiff, SurfaceRegionRegistry}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +fn valid_cases() -> Vec { + corpus::synthetic_retired_ward_corpus()["valid_cases"] + .as_array() + .expect("valid_cases is an array") + .clone() +} + +fn unsupported_cases() -> Vec { + corpus::synthetic_retired_ward_corpus()["unsupported_cases"] + .as_array() + .expect("unsupported_cases is an array") + .clone() +} + +fn identity_fact(name: &str) -> IdentityFact { + match name { + "name" => IdentityFact::Name, + "person" => IdentityFact::Person, + "pronouns" => IdentityFact::Pronouns, + "purpose" => IdentityFact::Purpose, + "coven" => IdentityFact::Coven, + other => panic!("unsupported synthetic identity fact {other}"), + } +} + +fn approval_path(case: &Value) -> Result { + let approval = &case["approval"]; + let veto = || { + let veto = &approval["veto"]; + VetoWindow::try_new( + Duration::from_secs(veto["duration_seconds"].as_u64().unwrap()), + Duration::from_secs(veto["min_visible_seconds"].as_u64().unwrap()), + ) + }; + match approval["kind"].as_str().unwrap() { + "auto_regression" => Ok(ApprovalPath::AutoRegression { + veto: approval["veto"].is_object().then(veto).transpose()?, + }), + "familiar_coherence" => Ok(ApprovalPath::FamiliarCoherence { veto: veto()? }), + "human_approval" => Ok(ApprovalPath::HumanApproval), + "human_approval_with_rationale" => Ok(ApprovalPath::HumanApprovalWithRationale), + other => Err(format!("unsupported approval path kind {other}")), + } +} + +fn materialized_diff(case: &Value) -> Result { + let surfaces = case["surfaces"] + .as_array() + .unwrap() + .iter() + .map(|entry| SurfaceDiff { + surface: SurfaceId::new(entry["path"].as_str().unwrap()), + before: entry["before"] + .as_str() + .map(|value| value.as_bytes().to_vec()), + after: entry["after"] + .as_str() + .map(|value| value.as_bytes().to_vec()), + }) + .collect(); + MaterializedDiff::try_new(surfaces) +} + +#[test] +fn corpus_documents_synthetic_provenance_without_historical_data() { + let corpus = corpus::synthetic_retired_ward_corpus(); + assert_eq!(corpus["schema_version"], "phase5-retired-ward-synthetic-v1"); + assert_eq!(corpus["provenance"]["kind"], "synthetic"); + assert_eq!(corpus["provenance"]["historical_data_used"], false); + assert!(corpus["provenance"]["notice"] + .as_str() + .unwrap() + .contains("repository-authored")); +} + +#[test] +fn valid_cases_cover_identity_approval_veto_and_region_fidelity() { + let cases = valid_cases(); + assert_eq!(cases.len(), 4); + + let labels: Vec<_> = cases + .iter() + .map(|case| case["approval"]["label"].as_str().unwrap()) + .collect(); + assert_eq!( + labels, + ["auto", "familiar_review", "human_review", "human_required"] + ); + + let mut covered_regions = Vec::new(); + for case in cases { + let declarations: Vec<_> = case["declarations"] + .as_array() + .unwrap() + .iter() + .map(|value| value.as_str().unwrap()) + .collect(); + let invariants = IdentityInvariantSet::compile(declarations).unwrap(); + assert_eq!(invariants.declarations().len(), 5); + + let commitment = [case["candidate_commitment_byte"].as_u64().unwrap() as u8; 32]; + let facts = case["candidate_facts"] + .as_array() + .unwrap() + .iter() + .map(|entry| CandidateIdentityFact { + fact: identity_fact(entry["fact"].as_str().unwrap()), + value: entry["value"].as_str().unwrap().to_string(), + }) + .collect(); + let facts = CandidateIdentityFacts::try_new(commitment, facts).unwrap(); + assert!(matches!( + invariants.evaluate(commitment, Some(&facts)), + WeaveCoherence::Coherent + )); + + let approval = approval_path(&case).unwrap(); + assert_eq!(approval.display_label(), case["approval"]["label"]); + assert_eq!( + ApprovalPath::from_display_label(approval.display_label()), + Some(match case["approval"]["kind"].as_str().unwrap() { + "auto_regression" => ApprovalPathKind::AutoRegression, + "familiar_coherence" => ApprovalPathKind::FamiliarCoherence, + "human_approval" => ApprovalPathKind::HumanApproval, + "human_approval_with_rationale" => { + ApprovalPathKind::HumanApprovalWithRationale + } + _ => unreachable!(), + }) + ); + + let diff = materialized_diff(&case).unwrap(); + let evidence = SurfaceRegionRegistry::default_registry().classify_all(&diff); + let regions: Vec<_> = evidence + .iter() + .map(|entry| entry.region_id.as_str()) + .collect(); + assert_eq!( + regions, + case["expected"]["regions"] + .as_array() + .unwrap() + .iter() + .map(|value| value.as_str().unwrap()) + .collect::>() + ); + assert_eq!( + SurfaceRegionRegistry::path_tier_floor(&evidence), + case["expected"]["path_tier_floor"].as_u64().unwrap() as u8 + ); + covered_regions.extend(regions.into_iter().map(str::to_string)); + } + + covered_regions.sort(); + covered_regions.dedup(); + assert_eq!( + covered_regions, + ["execution_prompt", "heartbeat_behavior", "tool_defaults"] + ); +} + +#[test] +fn unsupported_cases_fail_closed_for_each_authority_input() { + let cases = unsupported_cases(); + assert_eq!(cases.len(), 6); + + for case in cases { + let error = match case["kind"].as_str().unwrap() { + "identity_declaration" => { + let declarations = case["declarations"] + .as_array() + .unwrap() + .iter() + .map(|value| value.as_str().unwrap()); + IdentityInvariantSet::compile(declarations) + .unwrap_err() + .join("; ") + } + "candidate_facts" => { + let source = valid_cases().remove(0); + let declarations = source["declarations"] + .as_array() + .unwrap() + .iter() + .map(|value| value.as_str().unwrap()); + let invariants = IdentityInvariantSet::compile(declarations).unwrap(); + let commitment = [0x41; 32]; + let facts = case["candidate_facts"] + .as_array() + .unwrap() + .iter() + .map(|entry| CandidateIdentityFact { + fact: identity_fact(entry["fact"].as_str().unwrap()), + value: entry["value"].as_str().unwrap().to_string(), + }) + .collect(); + let facts = CandidateIdentityFacts::try_new(commitment, facts).unwrap(); + match invariants.evaluate(commitment, Some(&facts)) { + WeaveCoherence::Broken { reason, .. } => reason, + other => panic!("expected fail-closed identity result, got {other:?}"), + } + } + "veto_window" => approval_path(&case).unwrap_err(), + "materialized_diff" => materialized_diff(&case).unwrap_err(), + other => panic!("unsupported synthetic failure kind {other}"), + }; + assert!( + error.contains(case["expected_error_contains"].as_str().unwrap()), + "{error:?} did not match {:?}", + case["expected_error_contains"] + ); + } +} + +#[test] +fn canonical_generator_output_has_a_pinned_sha256_digest() { + let digest = Sha256::digest(corpus::canonical_corpus_json().as_bytes()); + assert_eq!( + format!("{digest:x}"), + "b3c5f156896ed4ef03b3f57bb8e65a33a5cf6fe52582ccd8403972a20299db44" + ); +} diff --git a/crates/coven-threads-core/tests/rfc0001_s5_conformance.rs b/crates/coven-threads-core/tests/rfc0001_s5_conformance.rs index e01d65e..96b8327 100644 --- a/crates/coven-threads-core/tests/rfc0001_s5_conformance.rs +++ b/crates/coven-threads-core/tests/rfc0001_s5_conformance.rs @@ -89,6 +89,7 @@ fn request(surface: &str, writer: &str, channel: Channel) -> MutationRequest { surface: SurfaceId::new(surface), writer: WriterId::new(writer), channel, + identity_context: None, } } diff --git a/crates/coven-threads-core/tests/support/phase5_retired_ward_corpus.rs b/crates/coven-threads-core/tests/support/phase5_retired_ward_corpus.rs new file mode 100644 index 0000000..63968c8 --- /dev/null +++ b/crates/coven-threads-core/tests/support/phase5_retired_ward_corpus.rs @@ -0,0 +1,228 @@ +use serde_json::{json, Value}; + +fn declarations(name: &str, person: &str, purpose: &str) -> Value { + json!([ + format!("familiar.name == {name:?}"), + format!("familiar.person == {person:?}"), + "familiar.pronouns == \"they/them\"", + format!("familiar.purpose includes {purpose:?}"), + "familiar.coven == \"ExampleCoven\"" + ]) +} + +fn candidate_facts(name: &str, person: &str, purpose: &str) -> Value { + json!([ + {"fact": "name", "value": name}, + {"fact": "person", "value": person}, + {"fact": "pronouns", "value": "they/them"}, + {"fact": "purpose", "value": format!("This synthetic familiar exists to {purpose}.")}, + {"fact": "coven", "value": "ExampleCoven"} + ]) +} + +fn valid_case( + id: &str, + commitment_byte: u8, + approval: Value, + surfaces: Value, + regions: Value, + path_tier_floor: u8, +) -> Value { + let name = format!("Synthetic-{id}"); + let person = format!("Example principal {commitment_byte}"); + let purpose = format!("exercise {id} migration fidelity"); + json!({ + "id": id, + "declarations": declarations(&name, &person, &purpose), + "candidate_commitment_byte": commitment_byte, + "candidate_facts": candidate_facts(&name, &person, &purpose), + "approval": approval, + "surfaces": surfaces, + "expected": { + "regions": regions, + "path_tier_floor": path_tier_floor + } + }) +} + +pub fn synthetic_retired_ward_corpus() -> Value { + json!({ + "schema_version": "phase5-retired-ward-synthetic-v1", + "provenance": { + "kind": "synthetic", + "historical_data_used": false, + "authorization_basis": "Repository-authored synthetic values require no retired or private source corpus.", + "notice": "This repository-authored corpus is fictional and must not be represented as recovered Ward v0.1 user data.", + "generator": "crates/coven-threads-core/tests/support/phase5_retired_ward_corpus.rs" + }, + "valid_cases": [ + valid_case( + "auto", + 0x11, + json!({ + "kind": "auto_regression", + "label": "auto", + "veto": { + "duration_seconds": 3600, + "min_visible_seconds": 900 + } + }), + json!([ + { + "path": "MEMORY.md", + "before": "synthetic memory v1", + "after": "synthetic memory v2" + } + ]), + json!([]), + u8::MAX + ), + valid_case( + "familiar-review", + 0x22, + json!({ + "kind": "familiar_coherence", + "label": "familiar_review", + "veto": { + "duration_seconds": 7200, + "min_visible_seconds": 1800 + } + }), + json!([ + { + "path": "TOOLS.md", + "before": "synthetic tool defaults v1", + "after": "synthetic tool defaults v2" + }, + { + "path": "HEARTBEAT.md", + "before": "synthetic heartbeat v1", + "after": "synthetic heartbeat v2" + } + ]), + json!(["tool_defaults", "heartbeat_behavior"]), + 1 + ), + valid_case( + "human-review", + 0x33, + json!({ + "kind": "human_approval", + "label": "human_review" + }), + json!([ + { + "path": "SOUL.md", + "before": "synthetic execution prompt v1", + "after": "synthetic execution prompt v2" + } + ]), + json!(["execution_prompt"]), + 0 + ), + valid_case( + "human-required", + 0x44, + json!({ + "kind": "human_approval_with_rationale", + "label": "human_required" + }), + json!([ + { + "path": "AGENTS.md", + "before": "synthetic agent prompt v1", + "after": "synthetic agent prompt v2" + }, + { + "path": "TOOLS.md", + "before": "synthetic tool defaults v1", + "after": "synthetic tool defaults v3" + } + ]), + json!(["execution_prompt", "tool_defaults"]), + 0 + ) + ], + "unsupported_cases": [ + { + "id": "unknown-identity-fact", + "kind": "identity_declaration", + "declarations": [ + "familiar.name == \"Synthetic-Unknown\"", + "familiar.person == \"Example principal\"", + "familiar.favorite_color == \"purple\"" + ], + "expected_error_contains": "unsupported identity fact" + }, + { + "id": "missing-candidate-pronouns", + "kind": "candidate_facts", + "candidate_facts": [ + {"fact": "name", "value": "Synthetic-auto"}, + {"fact": "person", "value": "Example principal 17"}, + {"fact": "purpose", "value": "This synthetic familiar exists to exercise auto migration fidelity."}, + {"fact": "coven", "value": "ExampleCoven"} + ], + "expected_error_contains": "Pronouns identity fact" + }, + { + "id": "invalid-veto-visibility", + "kind": "veto_window", + "approval": { + "kind": "familiar_coherence", + "label": "familiar_review", + "veto": { + "duration_seconds": 60, + "min_visible_seconds": 61 + } + }, + "expected_error_contains": "min_visible" + }, + { + "id": "duplicate-materialized-surface", + "kind": "materialized_diff", + "surfaces": [ + { + "path": "TOOLS.md", + "before": "synthetic v1", + "after": "synthetic v2" + }, + { + "path": "TOOLS.md", + "before": "synthetic v2", + "after": "synthetic v3" + } + ], + "expected_error_contains": "duplicate surface" + }, + { + "id": "absent-before-and-after", + "kind": "materialized_diff", + "surfaces": [ + { + "path": "SOUL.md", + "before": null, + "after": null + } + ], + "expected_error_contains": "neither before nor after content" + }, + { + "id": "unchanged-materialized-surface", + "kind": "materialized_diff", + "surfaces": [ + { + "path": "SOUL.md", + "before": "synthetic unchanged", + "after": "synthetic unchanged" + } + ], + "expected_error_contains": "unchanged" + } + ] + }) +} + +pub fn canonical_corpus_json() -> String { + serde_json::to_string(&synthetic_retired_ward_corpus()).expect("synthetic corpus serializes") +} diff --git a/specs/PHASE-4-CAVE-SURFACES.md b/specs/PHASE-4-CAVE-SURFACES.md index e4432f3..5c47191 100644 --- a/specs/PHASE-4-CAVE-SURFACES.md +++ b/specs/PHASE-4-CAVE-SURFACES.md @@ -258,7 +258,7 @@ never apply edits, never touch `pending/`, never write sqlite. | # | Route | Method | Returns | |---|---|---|---| -| 1 | `/api/weaves` | GET | `WeaveSummary[]` — optional `?familiar=` filter; degraded familiars surface per §2.7 + §4.R12 | +| 1 | `/api/weaves` | GET | `WeaveListView` — `{ "weaves": WeaveSummary[], "degraded": DegradedFamiliarView[] }` (§2.7); optional `?familiar=` filter; degraded familiars render per §4.R12 | | 2 | `/api/weaves/[id]` | GET | `WeaveDetail` | | 3 | `/api/threads/[id]` | GET | `ThreadView` | | 4 | `/api/threads/[id]/strands` | GET | `StrandView[]` (with `fray` diff blocks where blamed) | diff --git a/specs/PHASE-5-APPROVAL-SEMANTICS.md b/specs/PHASE-5-APPROVAL-SEMANTICS.md new file mode 100644 index 0000000..b823926 --- /dev/null +++ b/specs/PHASE-5-APPROVAL-SEMANTICS.md @@ -0,0 +1,432 @@ +# PHASE-5 PROPOSAL — Approval semantics over the Phase-2 Ward + +**Status:** DECISION RECORD (Sections 0–5 are the reviewed proposal; Sections +6–8 record the decision to open implementation. Freeze remains gated.) +**Date:** 2026-07-18 +**Decision date:** 2026-07-18 +**Bead:** `threads-uqx` +**Authors:** Sage lane (drafted by coordinator session agent) +**Decided by:** Val Alexander + Nova (decision commit `b085fc8`, authored by +Val and carrying Nova's co-author attestation) + +--- + +## 0. Scope and sources + +Sections 0–5 preserve the proposal as reviewed. They do not amend frozen Phase +0 or change the daemon. Sections 6–8 are the subsequent Val + Nova decision +record that opened Phase 5 implementation; they do not declare Phase 5 frozen. + +Sources read and cited: + +- `specs/PHASE-0-DESIGN.md`: frozen v0.2; Thread (authority relationship: + surface -> writer), Weave (enforced pattern of threads), Strand (fiber inside + a thread), Channel (axis of load), predicate-authoritative / descriptor- + derived rule, Gate-4 fail-closed posture, and WARD-C1..C7 lineage. +- `../familiar-contract/rfcs/RFC-0001-familiar-contract.md`: §4.2 protected + invariants, §4.3 editable surface, §5.3 approval tiers, §5.4 gates, §5.5 + probes. RFC amendments for closure precondition and provenance predicate are + drafted in OpenCoven/familiar-contract PR #3 and remain an upstream freeze + gate until Nova + Val approve and merge them. +- `../coven-grimoire/articles/drafts/2026-06-24-ward-layer-spec-brief.md` §9: + canonical home of WARD-C1..C7. +- `specs/PHASE-4-CAVE-SURFACES.md`: approval flow §3.7, fail-closed rendering + rules §4, and non-goals §6. +- `../coven/crates/coven-cli/src/ward.rs`, `threads_gate.rs`, `api.rs`: current + daemon reality — path tiers, Gate 1-4 comments, protected-surface weave + construction, and `decide_threads_proposal` as the only tier-0 commit point. +- `~/.coven/workspaces/familiars/nova/ward.toml.v01.bak`: read-only example of + the retired dialect. This draft paraphrases its invariant shape and does not + reproduce Val's exact invariant strings. + +--- + +## 1. What v0.1 expressed that Phase 2 cannot + +Phase 2 reduced Ward configuration to path-tier mapping: tier 0 protected, +tier 1 reviewed, tier 2 logged, tier 3 free. In the daemon, `WardConfig` stores +surface globs and `protected_surface`; `Ward::apply` refuses blocked proposals +as a unit, holds Tier 1 and Gate-1-authorized Tier 0 for Gate 3, and writes only +Tier 2/3. `apply_after_threads_approval` still reruns Gates 1 and 2, and +`api.rs::decide_threads_proposal` revalidates a pending proposal, calls the +threads gate, appends audit rows, advances baselines, removes the pending file, +and is the only current tier-0 approval commit point. + +The retired v0.1 dialect carried three semantic groups Phase 2 cannot express. + +### 1.1 Approval tiers as named governance lanes + +v0.1 distinguished four proposal lanes: automatic regression-gated promotion +with a human veto window; familiar-coherence review with a shorter veto window; +human pre-approval; and human approval with rationale plus audit. Each lane +bound named semantic blocks to a gate. + +Phase 2 can say "logged" or "reviewed." It cannot say "this semantic region is +auto-promoted after regression but vetoable for 48 hours," nor distinguish +human approval from human approval with rationale. RFC-0001 §5.3 still names +these approval tiers and forbids auto-promotion of protected-surface proposals. + +### 1.2 Editable harness blocks as semantic regions + +v0.1 named editable blocks such as execution and recovery prompt regions, tool +defaults, skill configuration, subagent templates, output formats, heartbeat +behavior, tool grants, skill activations, memory conventions, session +introduction, and protected-surface adjacency. These are not file paths. One +block may cut across files; one file may contain several blocks. + +Phase 2 only classifies materialized paths. It cannot answer which semantic +region a diff changed unless that region aligns with a glob. That loses the +RFC-0001 §4.3 editable-surface shape. + +### 1.3 Protected invariants as cross-file identity predicates + +v0.1 carried textual identity assertions over familiar metadata and purpose. +The real backup shows the shape: compact predicate-like statements about name, +person binding, pronouns, purpose, and Coven membership. This document does not +copy them. + +Phase 2 protects files and globs. It does not protect an identity fact that can +be invalidated through an allowed path, prompt block, derived context, or future +portability envelope. RFC-0001 §4.2 says path-only protection is insufficient: +identity-probe inconsistency must reject a proposal regardless of target path. + +--- + +## 2. Carry forward vs deliberately dead + +### 2.1 Approval tiers: carry forward, old encoding dead + +Carry forward the governance distinction; do not revive the v0.1 table shape. +The useful semantic is four approval paths: deterministic low-risk gate, +familiar-coherence gate, human approval, and human approval with rationale. +That matches RFC-0001 §5.3 and explains why Phase 4 already models +`proposal_approved`, `proposal_rejected`, and `proposal_vetoed` audit events. + +What stays dead is stringly coupling among `blocks = [...]`, `gate = "..."`, +and ad hoc veto fields. RFC-0001's TOML Ward declaration remains normative: +`[protected].invariants` entries compile into typed, daemon-owned predicates at +load. An unknown or uncompilable declaration fails closed. The old strings do +not become enforcement objects, and the Cave must not become a policy engine. + +Recommended name: `ApprovalPath`, not `Tier`. "Tier" is already the path-trust +axis in Phase 2. Reusing it would collapse where a write lands with which +ceremony promotes it. + +### 2.2 Harness blocks: carry forward as regions, not authority + +Carry forward the region abstraction; kill any claim that labels are +authoritative by themselves. A block label is a descriptor unless a +parser/extractor maps bytes to that region deterministically and the daemon can +replay that mapping at apply time. Phase 0 §2.2 binds the rule: predicate +authoritative, descriptor derived. + +Recommended name: `SurfaceRegion` above `SurfaceId`. A `SurfaceRegion` is a +typed semantic region with an extractor predicate over materialized content. +Its descriptor may say "output formats" for the Cave, but the daemon gates on +extractor evidence and final materialized diff. + +### 2.3 Protected invariants: carry forward as predicates, strings dead + +Carry forward strongly. RFC-0001 §4.2 still requires semantic invariants, and +Phase 0 already gives them a home: `PatternPredicate::coherent` is the +authoritative gate; `describe()` yields a derived descriptor. Identity +invariants should become predicate implementations in the weave, not strings +interpreted by clients. + +The v0.1 invariant interpretation should stay dead. RFC-0001 still requires a +TOML `[protected].invariants` declaration surface, so Phase 5 must define a +deterministic compiler from supported declarations into `PatternPredicate` +implementations. Parser semantics, normalization, evidence, and failure modes +belong to that compiler; unsupported declarations fail closed. The corrected +RFC PR #3 must land before Phase 5 freezes invariant interfaces. + +--- + +## 3. Mapping sketches inside the frozen metaphor + +### 3.1 Approval paths: gate policy over existing channels + +Do not model approval paths as new `Channel` variants by default. Channel (axis +of load) says why a thread is stressed: deliberate, forced, serialization, or +mutation. Approval path says which promotion ceremony is required. A mutation +that needs human rationale is still `Channel::Mutation`. + +Recommended default: + +```rust +pub enum ApprovalPath { + AutoRegression { veto: Option }, + FamiliarCoherence { veto: Option }, + HumanApproval, + HumanApprovalWithRationale, +} + +pub struct ProposalClassification { + pub channel: Channel, + pub affected_regions: Vec, + pub path_tier_floor: Tier, + pub approval_path: ApprovalPath, +} +``` + +Path tier remains a floor. A Tier 0 target still requires principal authority +and the threads gate. A Tier 2 path touching a high-risk semantic region may +still require human review. Highest ceremony wins for the proposal as a unit, +matching current all-or-nothing Ward behavior. + +Only add a new channel if Val/Nova decide approval-policy edits themselves need +a distinct load axis. That should not be the default. + +### 3.2 Invariants: PatternPredicate implementations + +Identity invariants should compile into `PatternPredicate` implementations. +The predicate takes relevant threads, strands, and verified proposal evidence; +its result is authoritative. Its descriptor is Cave-readable, labeled derived, +and never enforced on. + +Concrete referent mapping: + +- Thread (authority relationship: surface -> writer): protected identity + surfaces keep principal-writer threads. +- Strand (fiber inside a thread): invariant evidence may use hashes, manifest + entries, audit anchors, serialization markers, and only later a new strand if + evidence forces one. +- Weave (enforced pattern of threads): identity predicates are part of the weave + pattern, not side-table rows. +- Channel (axis of load): invariants must hold under mutation and serialization + at minimum; forced compaction connects through WARD-C1..C6. + +Recommended default: predicate-first; no new strand kind until implementation +evidence requires it. + +### 3.3 Harness blocks: SurfaceRegion above SurfaceId + +A `SurfaceRegion` should be daemon-replayable over a materialized diff: + +```rust +pub trait SurfaceRegionPredicate { + fn materialize(&self, proposal: &MaterializedDiff) -> RegionEvidence; + fn describe(&self) -> SurfaceRegionDescriptor; +} +``` + +The descriptor is for humans. The predicate output feeds classification and +Gate 4 replay. It cannot depend on Cave state, agent self-report, or stale +metadata. + +Thread options: + +- one thread per block: inspectable, but risks threads detached from + source-authoritative surfaces; +- one thread per source surface, with region evidence on the mutation request: + closer to Phase 2 and safer by default. + +Recommended default: keep threads source-bound; attach region evidence to +proposal classification. Promote a region to thread status only when it has a +stable source-authoritative projection. + +--- + +## 4. Veto windows vs synchronous gates + +A synchronous gate answers before write: permit, stage, or reject. Phase 2's +protected-target flow stages on fray, waits for principal approval, revalidates, +and applies. Nothing writes before authority clears. + +A veto window has two possible meanings: + +1. delayed apply: gates pass, the proposal becomes pending-visible, and the + daemon applies only after the window expires with no veto; +2. provisional apply: gates pass, the daemon applies immediately, and a later + veto triggers rollback. + +Recommended default: delayed apply. Provisional apply should be forbidden until +Val/Nova explicitly accept rollback semantics. Gate-4 fail-closed and Phase 4's +"no optimistic UI, no queued decisions" posture both prefer visible pending +state over speculative mutation. + +Delayed apply needs audit moments: + +- `proposal_submitted`: classification, diff hash, affected paths/regions, + predicate evidence, deadline; +- `proposal_window_opened`: gate evidence and veto deadline recorded; +- one terminal event after deadline or explicit action: + +| Terminal event | Close reason | Meaning | +| --- | --- | --- | +| `proposal_approved` | `applied` | Deadline and minimum visibility elapsed, no veto exists, replay matched, and the write committed. | +| `proposal_vetoed` | `vetoed` | A principal veto closed the window before apply. | +| `proposal_rejected` | `evidence_diverged` | Deadline triggered replay and the evidence hash changed. | +| `proposal_rejected` | `revalidation_failed` | Replay could not produce authoritative evidence. | +| `proposal_rejected` | `superseded` | A newer proposal replaced this pending proposal before apply. | + +Deadline expiry is a trigger for revalidation, not a successful or failed +terminal state by itself. There is no `proposal_expired` terminal event. + +WARD-C6 matters by analogy: a time-separated, lossy or delayed boundary needs a +ledger. A proposal whose evidence cannot be replayed at deadline fails closed. +Future Cave surfaces should extend `ProposalView` only after daemon evidence +exists; until then unknown or stale veto state renders blocked under Phase 4 §4. + +--- + +## 5. Explicit non-goals + +- No general policy engine; README anti-goals and Phase 4 §6 bind. +- No client-side approval authority; Cave forwards, daemon decides. +- No revival of v0.1 stringly TOML semantics. RFC-0001's Ward TOML declaration + format remains normative and compiles into typed predicates. +- No Phase-5 freeze and no changes to frozen Phase 0. The later implementation + opening is recorded separately in Sections 6–8. +- No weakening of Gate 4; every path ends in live daemon re-materialization. +- No `.af` compatibility work; Phase 3 already decided `.weave` canonical plus + lossy one-way `.af` exporter. +- No broad runtime capability system; tool grants may classify proposals, but + runtime chamber authority remains separate. + +--- + +## 6. Decisions (Val + Nova, 2026-07-18) + +All eight questions resolved on Sage's recommended defaults. The RFC dependency +shape is resolved in corrected familiar-contract PR #3, but its Nova + Val +approval and merge remain a Phase 5 freeze gate. + +Decision evidence: commit `b085fc8` (`spec(phase-5): record Val+Nova decisions, +open Phase 5`), authored by Val Alexander and carrying +`Co-authored-by: Nova `. This amendment corrects factual and +authority-model defects in that record without changing the attested decision +to open implementation. + +1. **`ApprovalPath` separate from `Channel`? → YES.** + Channel remains the load axis (why a thread is stressed). ApprovalPath is + the promotion ceremony. Conflating them would make Phase 0 load-axis + semantics ambiguous. `ApprovalPath` proceeds as a distinct type. + *Both are authoritative in their own dimensions: Channel remains the + first-class Phase-0 load/enforcement axis, while ApprovalPath selects the + promotion ceremony. Never derive ApprovalPath from Channel. If this + separation shifts, revisit all eight decisions.* (Echo, Sage 2026-07-18; + corrected after independent coherence review 2026-07-19) + +2. **Veto windows: delayed-apply or provisional-apply? → DELAYED APPLY ONLY.** + Matches Gate-4 fail-closed posture and Phase 4 "no optimistic UI, no queued + decisions." Provisional apply + rollback is a distinct threat model; not + opening that door in Phase 5. + *Audit implication: the veto window close event needs an explicit reason + field — `applied | vetoed | evidence_diverged | revalidation_failed | + superseded`. Without it, + delayed-apply is a post-hoc audit black hole. Confirm `ApplyAudit` (issue + #5) captures the close event, not just the apply event. (Echo 2026-07-18)* + +3. **Harness regions: classify ceremony or become threads? → CLASSIFY FIRST.** + Region → thread promotion only when a stable source-authoritative projection + exists. Default: threads source-bound, region evidence on proposal + classification. No premature thread proliferation. + *Forward-only promotion: if a region reclassifies mid-session, promotion + applies forward only. Retroactive projection would corrupt the authority + trail with apparently-authored writes from before the promotion decision. + (Echo 2026-07-18)* + +4. **Invariants: deterministic extraction, identity probes, or both? + → DETERMINISTIC WHERE POSSIBLE; probes as Gate-3 evidence, not sole authority.** + Consistent with RFC-0001 rule that LLM-judge-only is forbidden for auto-tier. + Same logic applies to invariant checking. + *When deterministic extraction fails or is ambiguous, default is + fail-closed (no promotion) — not silent fallback to LLM judgment. + Ambiguity is an explicit ignored/blocked state. (Echo 2026-07-18)* + +5. **Must the RFC #3/#4 amendments (familiar-contract PR #3) land before + freeze? → YES.** + The corrected amendments are drafted in OpenCoven/familiar-contract PR #3. + Implementation may proceed against that reviewed shape, but Phase 5 cannot + freeze until Nova + Val approve and merge the upstream normative text. + +6. **Cave `ProposalView` extension: daemon contract first or simultaneous? + → DAEMON CONTRACT FIRST.** + Cave extension only after daemon read models exist. Consistent with Phase 4 + pattern. Cave ProposalView for Phase 5 starts as `[DESIGNED, NOT SHIPPED]` + until release evidence exists. (Echo 2026-07-18) + +7. **Preserve display labels `auto`, `familiar_review`, `human_review`, + `human_required`? → YES — display names preserved, typed variants internal.** + Daemon owns the typed `ApprovalPath` enum; Cave renders the human-readable + labels. No mental-model breakage. + *Label-variant round-trip must be a daemon wire contract, not Cave + convention. Daemon emits `{variant, label, veto_deadline}`; Cave has zero + policy freedom over label strings. Daemon should reject at load if a variant + has no corresponding display label or vice versa. (Sage 2026-07-18)* + +8. **Add `proposal_window_opened` audit event? → YES, when delayed apply ships.** + Minimal ledger entry for auditable veto windows. Connects to issue #5 + (`ward_audit` / `ApplyAudit`); that lane (Cody) can consume both in the same + schema pass. + *Also requires a corresponding close event with reason field (see decision + #2 audit note). The window is a first-class audit interval, not a gap.* + +--- + +## 7. Phase shape — Phase 5 is open + +Phase 5 opened 2026-07-18 (Val + Nova decision). Beads are live. + +- `threads-uqx` — [EPIC] approval semantics over the Phase-2 Ward. +- `threads-uqx.1` — ✅ RESOLVED: `ApprovalPath` separate from `Channel`; delayed + apply; classify-first for regions; deterministic+probe for invariants. +- `threads-uqx.2` — RFC #3/#4 amendment gate: corrected DRAFT + familiar-contract PR #3 must receive Nova + Val approval and merge before + Phase 5 freeze. +- `threads-uqx.3` — Core type sketch: `ApprovalPath`, `VetoWindow`, + `ProposalClassification`, region evidence, audit event shape. + *Design constraints from Sage/Echo (2026-07-18) to incorporate:* + *(a) `evidence_replay_hash` on `ProposalClassification` — delayed-apply* + *revalidation at deadline must prove it replays the same evidence that* + *opened the window (WARD-C7 generalizes: evidence must survive the time gap).* + *(b) `VetoWindow.min_visible: Duration` — veto window is only fail-closed if* + *pending state was actually visible long enough for a human to act on it* + *(same shape as two-compaction contract).* + *(c) Label mapping as daemon wire contract with load-time reject on drift.* +- `threads-uqx.4` — Predicate design: identity invariant + `PatternPredicate`; descriptor-derived proof. +- `threads-uqx.5` — Surface-region design: extractor predicates over + materialized diffs and Gate-4 replay. +- `threads-uqx.6` — Daemon integration design: classification, delayed-apply + scheduler, revalidation at deadline, append-only audit in `coven.sqlite3`. +- `threads-uqx.7` — Cave contract amendment after daemon read models exist; + add blocked fixtures for veto states. +- `threads-uqx.8` — Cody implementation lane: core crate and daemon tests, + migration coverage, and fidelity coverage proving the retired name, person, + pronouns, purpose, and Coven-membership invariant shapes are either compiled + deterministically or rejected explicitly. +- `threads-uqx.13` — Public synthetic retired-Ward corpus: a repository-authored, + digest-pinned generator covers the five normative identity fields, all four + approval labels, veto settings, built-in harness regions, and fail-closed + unsupported cases without using historical or private Ward data. Generate it + with `cargo run -q -p coven-threads-core --example + generate_phase5_retired_ward_corpus`. +- `threads-uqx.9` — Nova sign-off: RFC round-trip, Gate-4 fail-closed proof, + descriptor-not-authority review. +- `threads-uqx.10` — Val freeze: Phase-5 design frozen or rejected. + +Suggested gates: proposal review; Nova design gate; Val authority gate; RFC +amendment gate; implementation gate proving no apply bypass; Cave fail-closed +rendering gate; final Val freeze after Nova sign-off. + +--- + +## 8. Recommendation — executed + +Phase 5 opened. Val and Nova confirmed: we want to recover semantic approval +behavior, not just document why it was lost. + +Carry forward: approval paths as typed promotion ceremony over existing +channels; harness blocks as daemon-replayable surface-region classifiers; +protected invariants as predicate-authoritative identity checks. + +Leave dead: v0.1 stringly gate/block coupling; invariant strings as enforcement +objects; client-side or descriptor-based authority; provisional apply during +veto windows unless explicitly accepted. + +The conservative design is not to make Ward more general. It is to restore the +minimum semantic distinctions RFC-0001 still names while preserving the Phase-0 +spine: source-authoritative surfaces, predicate-authoritative patterns, +daemon-owned audit, and fail-closed Gate 4.