From fdefed775d169966c41d190beed4def676480cce Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 18 Jul 2026 04:18:46 -0500 Subject: [PATCH 01/12] spec(proposal): draft Phase 5 approval semantics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- specs/PHASE-5-PROPOSAL-approval-semantics.md | 337 +++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 specs/PHASE-5-PROPOSAL-approval-semantics.md diff --git a/specs/PHASE-5-PROPOSAL-approval-semantics.md b/specs/PHASE-5-PROPOSAL-approval-semantics.md new file mode 100644 index 0000000..362ca9c --- /dev/null +++ b/specs/PHASE-5-PROPOSAL-approval-semantics.md @@ -0,0 +1,337 @@ +# PHASE-5 PROPOSAL — Approval semantics over the Phase-2 Ward + +**Status:** PROPOSAL (draft — Phase 5 has not opened; phase gates per `AGENTS.md` still bind) +**Date:** 2026-07-18 +**Bead:** `threads-uqx` +**Authors:** Sage lane (drafted by coordinator session agent) + +--- + +## 0. Scope and sources + +This is a proposal document only. It does not start Phase 5, amend frozen +Phase 0, or change the daemon. Val and Nova decide whether Phase 5 opens. + +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 + pending on issues #3/#4 and are treated as upstream-in-flight. +- `../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. If Phase 5 opens, policy should be typed and +daemon-owned. The Cave may render it; it 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 syntax should stay dead. It was legible but underspecified: +parser semantics, evidence collection, normalization, provenance, and failure +handling were not defined. Pending RFC issues #3/#4 should land, or be waived +by Val/Nova, 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; +- final `proposal_approved`, `proposal_vetoed`, `proposal_expired`, or + `proposal_rejected`: daemon revalidation result immediately before apply or + refusal. + +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 TOML as normative format. +- No Phase-5 start and no changes to frozen Phase 0. +- 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. Open questions for Val/Nova + +1. Should approval semantics be `ApprovalPath`, separate from `Channel`? + Recommended default: yes; channels remain load axes. +2. Are veto windows delayed-apply or provisional-apply? + Recommended default: delayed apply only. +3. Do harness regions classify proposal ceremony only, or become threads? + Recommended default: classify first; promote only with stable source + projection. +4. Are invariants checked by deterministic extraction, identity probes, or both? + Recommended default: deterministic where possible; probes are Gate-3 + evidence, not sole auto authority. +5. Must RFC issues #3/#4 land before freeze? + Recommended default: yes for freeze, no for exploratory design. +6. Should Phase 5 extend Cave `ProposalView`, or wait for daemon policy? + Recommended default: daemon contract first, Cave extension second. +7. Preserve labels `auto`, `familiar_review`, `human_review`, `human_required`? + Recommended default: preserve display names, encode typed variants. +8. Add `proposal_window_opened` audit event? + Recommended default: yes if delayed apply ships. + +--- + +## 7. Rough phase shape, if Phase 5 opens + +Skeleton only; do not create these beads until Val/Nova open Phase 5. + +- `threads-uqx` — [EPIC] approval semantics over the Phase-2 Ward. +- `threads-uqx.1` — Sage/Nova gate: decide `ApprovalPath` vs `Channel` and veto + semantics. +- `threads-uqx.2` — RFC dependency check: issues #3/#4; freeze blocked unless + landed or waived. +- `threads-uqx.3` — Core type sketch: `ApprovalPath`, `VetoWindow`, + `ProposalClassification`, region evidence, audit event shape. +- `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. +- `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 + +Open Phase 5 only if Val/Nova 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. From 6829e5d4fe57f8270df3cce2e68ca9d3bb66a772 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 18 Jul 2026 05:27:18 -0500 Subject: [PATCH 02/12] spec(phase-4): align route-1 Returns with WeaveListView shape (threads-k9s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cave implementation wraps route-1 data as { weaves, degraded } rather than overloading a WeaveSummary[] with degraded entries — the wrapped shape avoids any fabricated health fields on degraded familiars. Same-day alignment of the 2026-07-18 §2.7/R12 amendment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- specs/PHASE-4-CAVE-SURFACES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) | From b085fc81a1f623bddf8b6b56bf35418c405bb290 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 18 Jul 2026 15:37:50 -0500 Subject: [PATCH 03/12] spec(phase-5): record Val+Nova decisions, open Phase 5 (threads-uqx) All 8 open questions resolved on Sage's recommended defaults: - ApprovalPath separate from Channel (load axis preserved) - Delayed apply only for veto windows (fail-closed posture) - Classify-first for harness regions - Deterministic + probe for invariants (not LLM-judge-only) - RFC #3/#4 blocker resolved (familiar-contract@2a5bb98) - Daemon contract before Cave ProposalView extension - Display labels preserved, typed variants internal - proposal_window_opened audit event lands with delayed apply Rename PHASE-5-PROPOSAL -> PHASE-5-APPROVAL-SEMANTICS to reflect open status. Co-authored-by: Nova --- ...ntics.md => PHASE-5-APPROVAL-SEMANTICS.md} | 85 ++++++++++++------- 1 file changed, 55 insertions(+), 30 deletions(-) rename specs/{PHASE-5-PROPOSAL-approval-semantics.md => PHASE-5-APPROVAL-SEMANTICS.md} (82%) diff --git a/specs/PHASE-5-PROPOSAL-approval-semantics.md b/specs/PHASE-5-APPROVAL-SEMANTICS.md similarity index 82% rename from specs/PHASE-5-PROPOSAL-approval-semantics.md rename to specs/PHASE-5-APPROVAL-SEMANTICS.md index 362ca9c..fb1b8d6 100644 --- a/specs/PHASE-5-PROPOSAL-approval-semantics.md +++ b/specs/PHASE-5-APPROVAL-SEMANTICS.md @@ -1,9 +1,11 @@ # PHASE-5 PROPOSAL — Approval semantics over the Phase-2 Ward -**Status:** PROPOSAL (draft — Phase 5 has not opened; phase gates per `AGENTS.md` still bind) +**Status:** PROPOSAL (decisions recorded — awaiting PR merge to open Phase 5) **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 (main session) --- @@ -265,38 +267,61 @@ exists; until then unknown or stale veto state renders blocked under Phase 4 §4 --- -## 6. Open questions for Val/Nova - -1. Should approval semantics be `ApprovalPath`, separate from `Channel`? - Recommended default: yes; channels remain load axes. -2. Are veto windows delayed-apply or provisional-apply? - Recommended default: delayed apply only. -3. Do harness regions classify proposal ceremony only, or become threads? - Recommended default: classify first; promote only with stable source - projection. -4. Are invariants checked by deterministic extraction, identity probes, or both? - Recommended default: deterministic where possible; probes are Gate-3 - evidence, not sole auto authority. -5. Must RFC issues #3/#4 land before freeze? - Recommended default: yes for freeze, no for exploratory design. -6. Should Phase 5 extend Cave `ProposalView`, or wait for daemon policy? - Recommended default: daemon contract first, Cave extension second. -7. Preserve labels `auto`, `familiar_review`, `human_review`, `human_required`? - Recommended default: preserve display names, encode typed variants. -8. Add `proposal_window_opened` audit event? - Recommended default: yes if delayed apply ships. +## 6. Decisions (Val + Nova, 2026-07-18) + +All eight questions resolved on Sage's recommended defaults. RFC dependency +blocker (issues #3/#4) resolved: both landed in OpenCoven/familiar-contract@2a5bb98 +and issues closed before this decision record was written. + +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. + +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. + +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. + +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. + +5. **Must RFC issues #3/#4 land before freeze? → MOOT; both landed.** + OpenCoven/coven-threads#3 and #4 closed 2026-07-18. Phase 5 freeze has no + RFC amendment blocker. + +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. + +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. + +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. --- -## 7. Rough phase shape, if Phase 5 opens +## 7. Phase shape — Phase 5 is open -Skeleton only; do not create these beads until Val/Nova open Phase 5. +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` — Sage/Nova gate: decide `ApprovalPath` vs `Channel` and veto - semantics. -- `threads-uqx.2` — RFC dependency check: issues #3/#4; freeze blocked unless - landed or waived. +- `threads-uqx.1` — ✅ RESOLVED: `ApprovalPath` separate from `Channel`; delayed + apply; classify-first for regions; deterministic+probe for invariants. +- `threads-uqx.2` — ✅ RESOLVED: RFC #3/#4 landed (familiar-contract@2a5bb98). - `threads-uqx.3` — Core type sketch: `ApprovalPath`, `VetoWindow`, `ProposalClassification`, region evidence, audit event shape. - `threads-uqx.4` — Predicate design: identity invariant @@ -318,10 +343,10 @@ rendering gate; final Val freeze after Nova sign-off. --- -## 8. Recommendation +## 8. Recommendation — executed -Open Phase 5 only if Val/Nova want to recover semantic approval behavior, not -just document why it was lost. +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; From 3fe72caf5b6524d5fca816adb2447045f84da486 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 18 Jul 2026 15:41:13 -0500 Subject: [PATCH 04/12] spec(phase-5): fold Sage + Echo review into decision record (threads-uqx) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add substantive annotations from Sage and Echo review (2026-07-18): Decision #1: rationale callback — ApprovalPath is authoritative, Channel is derived; if enforcement rule shifts, revisit all eight decisions. Decision #2: audit implication — close event needs reason field (applied|vetoed|expired|superseded); delayed-apply without it is a post-hoc audit black hole. Decision #3: forward-only promotion — retroactive region projection corrupts authority trail with apparently-authored writes from before promotion. Decision #4: fail-closed on extraction ambiguity — not silent LLM fallback; ambiguity is an explicit ignored/blocked state. Decision #5: RFC provenance pointer added. Decision #6: Cave ProposalView labeled [DESIGNED, NOT SHIPPED] from the start. Decision #7: label-variant round-trip is daemon wire contract (load-time reject on drift); Cave has zero policy freedom over label strings. Decision #8: proposal_window_opened paired with close event; window is a first-class audit interval. threads-uqx.3 design constraints (Sage): evidence_replay_hash on ProposalClassification; VetoWindow.min_visible: Duration; label mapping as daemon wire contract. Co-authored-by: Sage Co-authored-by: Echo Co-authored-by: Nova --- specs/PHASE-5-APPROVAL-SEMANTICS.md | 34 +++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/specs/PHASE-5-APPROVAL-SEMANTICS.md b/specs/PHASE-5-APPROVAL-SEMANTICS.md index fb1b8d6..5f5fdb0 100644 --- a/specs/PHASE-5-APPROVAL-SEMANTICS.md +++ b/specs/PHASE-5-APPROVAL-SEMANTICS.md @@ -277,40 +277,62 @@ and issues closed before this decision record was written. 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. + *Follows the source-authoritative-enforcement rule: ApprovalPath is + authoritative; Channel is a derived load descriptor. Never gate on Channel. + If this rule shifts, revisit all eight decisions.* (Echo, Sage 2026-07-18) 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 | expired | 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 RFC issues #3/#4 land before freeze? → MOOT; both landed.** OpenCoven/coven-threads#3 and #4 closed 2026-07-18. Phase 5 freeze has no - RFC amendment blocker. + RFC amendment blocker. See familiar-contract@2a5bb98 for the resolved shape; + issues #3/#4 preserved in git history for provenance. 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. + 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.* --- @@ -324,6 +346,14 @@ Phase 5 opened 2026-07-18 (Val + Nova decision). Beads are live. - `threads-uqx.2` — ✅ RESOLVED: RFC #3/#4 landed (familiar-contract@2a5bb98). - `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 From 19436aa206867b74dcc4c5228fe21b846ef7186d Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 18 Jul 2026 16:24:35 -0500 Subject: [PATCH 05/12] feat(audit): add ApplyAudit event type, detail column, migration SQL (v0.1.4) Closes coven-threads#5. Unblocks coven#414. Changes: - AuditEventType::ApplyAudit variant + apply_audit CHECK tag - WardAuditRecord.detail: nullable JSON column for event-specific payload - WardAuditRecord::for_apply() constructor - apply_prev_sha256_hex() / apply_bytes_written() decode helpers - WARD_AUDIT_MIGRATION_V014_SQL: transaction rebuild for v0.1.3 stores - APPLY_AUDIT_DETAIL_KEY_PREV / _BYTES stable constants - Exhaustiveness test extended; 3 new tests (shape, roundtrip, migration) - Workspace version bumped 0.1.3 -> 0.1.4 - CHANGELOG.md created Design: prev_sha256+bytes_written ride in detail JSON (Option A); migration exports SQL constant, no rusqlite dep added (Option B-lite). Bead: threads-3ym --- .beads/interactions.jsonl | 13 ++ CHANGELOG.md | 27 +++ Cargo.toml | 2 +- crates/coven-threads-core/src/audit.rs | 291 ++++++++++++++++++++++++- 4 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index c00e752..359a09c 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -23,3 +23,16 @@ {"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"}} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..71ba5e2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to `coven-threads-core` are documented here. + +## [0.1.4] — 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. +- `WARD_AUDIT_MIGRATION_V014_SQL` — transaction SQL to rebuild `ward_audit` for stores created against v0.1.3 (which lacked the `apply_audit` CHECK entry and the `detail` column). SQLite cannot `ALTER` a CHECK; the daemon should run this migration at startup when `sqlite_master` shows the old schema (i.e. no `apply_audit` in the `ward_audit` DDL). +- 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 `WARD_AUDIT_MIGRATION_V014_SQL` so the daemon can perform a quiet guarded rebuild on upgrade; does not add a rusqlite dependency to coven-threads-core. + +## [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.toml b/Cargo.toml index e1c41ea..633c4b6 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.1.4" edition = "2021" rust-version = "1.75" license = "MIT" diff --git a/crates/coven-threads-core/src/audit.rs b/crates/coven-threads-core/src/audit.rs index fab7fc0..52192cf 100644 --- a/crates/coven-threads-core/src/audit.rs +++ b/crates/coven-threads-core/src/audit.rs @@ -14,6 +14,33 @@ //! //! 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 including the `apply_audit` +//! CHECK tag. Stores created against v0.1.3 (which lacked the `apply_audit` +//! CHECK entry) must be rebuilt: the daemon should run +//! `WARD_AUDIT_MIGRATION_V014_SQL` once at startup when it detects the old +//! CHECK text is still in the schema. That migration rebuilds `ward_audit` +//! in a single transaction (create, copy, drop, rename) — 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; @@ -22,6 +49,11 @@ 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"; + /// Event types recorded in `ward.audit`. /// /// The first five are RFC-0001 §5.6's named set, verbatim. The last two are the @@ -44,6 +76,18 @@ pub enum AuditEventType { 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 { @@ -57,12 +101,17 @@ impl AuditEventType { AuditEventType::WardUpdated => "ward_updated", 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 +135,16 @@ 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. + /// + /// For `ApplyAudit` events: `{"prev_sha256":"","bytes_written":N}`. + /// Absent for all other event types (non-`ApplyAudit` rows leave this + /// `None`). + 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 +195,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 +203,82 @@ 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.unwrap_or_default(), + 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, + } + } + + /// 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 bytes_to_hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() } fn reject_tag(reason: &RejectReason) -> &'static str { @@ -163,13 +298,92 @@ 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). +/// DDL migration for v0.1.3 → v0.1.4: rebuilds `ward_audit` so its CHECK +/// constraint includes the new `apply_audit` tag. +/// +/// 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. +/// +/// **Run condition:** execute this only when +/// `SELECT sql FROM sqlite_master WHERE type='table' AND name='ward_audit'` +/// returns a string that does NOT contain the literal `'apply_audit'` — i.e. +/// the store was created against v0.1.3. Idempotent if re-run on a v0.1.4 +/// store because `ward_audit_new` won't contain `apply_audit` in that schema. +/// The daemon should gate this migration on the presence/absence of the tag +/// in `sqlite_master`. +pub const WARD_AUDIT_MIGRATION_V014_SQL: &str = r#" +BEGIN; + +CREATE TABLE IF NOT EXISTS ward_audit_new ( + 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','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')) +); + +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, NULL, 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; + +COMMIT; +"#; + +/// 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')), + 'compaction_ledger','apply_audit')), proposal_id TEXT, familiar_id TEXT NOT NULL, ward_version TEXT, @@ -178,6 +392,7 @@ 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, @@ -266,6 +481,7 @@ mod tests { AuditEventType::WardUpdated, AuditEventType::ValidationVerdict, AuditEventType::CompactionLedger, + AuditEventType::ApplyAudit, ] { assert!( WARD_AUDIT_SCHEMA_SQL.contains(&format!("'{}'", et.tag())), @@ -284,4 +500,77 @@ mod tests { assert!(WARD_AUDIT_SCHEMA_SQL.contains("ward_audit_append_only_delete")); assert!(WARD_AUDIT_SCHEMA_SQL.contains("append-only (RFC-0001 §5.6)")); } + + #[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); + } + + #[test] + fn migration_sql_contains_apply_audit_and_detail_column() { + assert!( + WARD_AUDIT_MIGRATION_V014_SQL.contains("'apply_audit'"), + "migration CHECK must contain apply_audit tag" + ); + assert!( + WARD_AUDIT_MIGRATION_V014_SQL.contains("detail"), + "migration must add detail column" + ); + assert!( + WARD_AUDIT_MIGRATION_V014_SQL.contains("BEGIN;"), + "migration must be a transaction" + ); + assert!( + WARD_AUDIT_MIGRATION_V014_SQL.contains("COMMIT;"), + "migration must be a transaction" + ); + } } From db20c623169a441c17efa8e37979d7e27fbd47b6 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 18 Jul 2026 16:27:00 -0500 Subject: [PATCH 06/12] review(audit): add user_version ladder + explicit detail invariant doc Addresses two Sage review notes (neither was blocking): 1. WARD_AUDIT_MIGRATION_V014_SQL now stamps PRAGMA user_version = 14 inside the transaction. Future migrations gate on 'user_version < N' instead of substring-sniffing sqlite_master DDL. Migration doc-comment updated to name the gate rule and explain the intentional non-idempotence of the CREATE step (double-run fails loudly, not silently). 2. WardAuditRecord::detail doc-comment now names the full invariant for ApplyAudit rows (prev_sha256 hex 64 chars + bytes_written u64) and explicitly states other event types MAY leave it None or use their own documented shape. Turns the implicit decoder contract into a searchable spec. CHANGELOG updated. Tests still green, clippy clean. --- .beads/interactions.jsonl | 1 + CHANGELOG.md | 2 +- Cargo.lock | 2 +- crates/coven-threads-core/src/audit.rs | 35 ++++++++++++++++++-------- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 359a09c..54e4c21 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -36,3 +36,4 @@ {"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 index 71ba5e2..bc34d24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ All notable changes to `coven-threads-core` are documented here. - `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. -- `WARD_AUDIT_MIGRATION_V014_SQL` — transaction SQL to rebuild `ward_audit` for stores created against v0.1.3 (which lacked the `apply_audit` CHECK entry and the `detail` column). SQLite cannot `ALTER` a CHECK; the daemon should run this migration at startup when `sqlite_master` shows the old schema (i.e. no `apply_audit` in the `ward_audit` DDL). +- `WARD_AUDIT_MIGRATION_V014_SQL` — transaction SQL to rebuild `ward_audit` for stores created against v0.1.3 (which lacked the `apply_audit` CHECK entry and the `detail` column). Bumps `PRAGMA user_version = 14` inside the transaction so future migrations can gate on `user_version < N` instead of substring-sniffing `sqlite_master` DDL. The daemon should run this migration when `PRAGMA user_version < 14`. - 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`. diff --git a/Cargo.lock b/Cargo.lock index a5a514b..4caca61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,7 +67,7 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "coven-threads-core" -version = "0.1.0" +version = "0.1.4" dependencies = [ "blake3", "serde", diff --git a/crates/coven-threads-core/src/audit.rs b/crates/coven-threads-core/src/audit.rs index 52192cf..19ecd5f 100644 --- a/crates/coven-threads-core/src/audit.rs +++ b/crates/coven-threads-core/src/audit.rs @@ -141,9 +141,15 @@ pub struct WardAuditRecord { pub diff_hash: Option>, /// Opaque JSON detail payload, event-type–specific. /// - /// For `ApplyAudit` events: `{"prev_sha256":"","bytes_written":N}`. - /// Absent for all other event types (non-`ApplyAudit` rows leave this - /// `None`). + /// **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 `""` 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, @@ -309,13 +315,17 @@ fn reject_tag(reason: &RejectReason) -> &'static str { /// 4. Renames the new table into place. /// 5. Re-creates indexes and append-only triggers. /// -/// **Run condition:** execute this only when -/// `SELECT sql FROM sqlite_master WHERE type='table' AND name='ward_audit'` -/// returns a string that does NOT contain the literal `'apply_audit'` — i.e. -/// the store was created against v0.1.3. Idempotent if re-run on a v0.1.4 -/// store because `ward_audit_new` won't contain `apply_audit` in that schema. -/// The daemon should gate this migration on the presence/absence of the tag -/// in `sqlite_master`. +/// **Run condition:** execute this only when `PRAGMA user_version` returns a +/// value less than `14` — i.e. the store was created before this migration. +/// `PRAGMA user_version` is `0` for stores that predate any explicit versioning +/// (all v0.1.3 stores). After this migration runs, `user_version` is `14` +/// (matching `0.1.4`), giving future migrations a clean integer ladder to gate +/// on instead of substring-sniffing `sqlite_master` DDL. +/// +/// Idempotent in the sense that if `user_version >= 14` the daemon must not +/// re-run it; the `ward_audit_new` table name is not guarded with `IF NOT +/// EXISTS`, so a double-run would fail on the `CREATE` step (intentional: +/// prevents silent data loss from a buggy migration controller). pub const WARD_AUDIT_MIGRATION_V014_SQL: &str = r#" BEGIN; @@ -371,6 +381,11 @@ BEGIN SELECT RAISE(ABORT, 'ward_audit is append-only (RFC-0001 §5.6)'); END; +-- Stamp schema version so future migrations can gate on `PRAGMA user_version` +-- instead of substring-sniffing the CHECK DDL. Gate rule for callers: +-- run this migration when `PRAGMA user_version < 14`. +PRAGMA user_version = 14; + COMMIT; "#; From f8ec9537977f6e895888e5b9117660a68c428a86 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 19 Jul 2026 14:42:28 -0500 Subject: [PATCH 07/12] =?UTF-8?q?feat(phase-5):=20core=20approval=20types?= =?UTF-8?q?=20=E2=80=94=20ApprovalPath,=20VetoWindow,=20ProposalClassifica?= =?UTF-8?q?tion=20(threads-uqx.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds crates/coven-threads-core/src/approval.rs with the Phase-5 core type sketch from specs/PHASE-5-APPROVAL-SEMANTICS.md §3, decisions 1/2/7/8. Types introduced: - ApprovalPath (AutoRegression | FamiliarCoherence | HumanApproval | HumanApprovalWithRationale) — promotion ceremony, separate from Channel - ApprovalPathKind — variant without inner fields for label round-trip - VetoWindow — delayed-apply only; min_visible + duration; deadline helpers - ProposalClassification — channel, regions, tier floor, approval_path, evidence_replay_hash (WARD-C7 generalised) - SurfaceRegionId — opaque region ref, full predicate in uqx.5 - WindowCloseReason — applied | vetoed | expired | superseded (decision 2+8) - ProposalWindowAuditDetail / ProposalWindowCloseAuditDetail — detail payloads for proposal_window_opened and close events - ApprovalPathWireEnvelope — daemon wire format {variant, label, veto_deadline} Key constraints enforced: - ApprovalPath != Channel (decision 1); highest ceremony wins - VetoWindow is delayed-apply only; min_visible <= duration (decision 2) - display_label() + from_display_label() round-trip is exhaustive; unknown labels return None so daemon rejects at load (decision 7) - evidence_replay_hash on ProposalClassification (WARD-C7) Tests: 15 passing (all approval tests green; full suite 90 passing) Closes #8 --- crates/coven-threads-core/src/approval.rs | 717 ++++++++++++++++++++++ crates/coven-threads-core/src/lib.rs | 6 + 2 files changed, 723 insertions(+) create mode 100644 crates/coven-threads-core/src/approval.rs diff --git a/crates/coven-threads-core/src/approval.rs b/crates/coven-threads-core/src/approval.rs new file mode 100644 index 0000000..4087b4e --- /dev/null +++ b/crates/coven-threads-core/src/approval.rs @@ -0,0 +1,717 @@ +//! 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 { + if other.ceremony_ordinal() > self.ceremony_ordinal() { + other + } else { + self + } + } + + /// 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, + } + } + + /// 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 { + AutoRegression, + FamiliarCoherence, + HumanApproval, + 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)] +pub struct VetoWindow { + /// How long the window stays open from the moment the proposal is staged. + pub 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`. + pub min_visible: Duration, +} + +impl VetoWindow { + /// Construct a veto window, panicking if `min_visible > duration`. + pub fn new(duration: Duration, min_visible: Duration) -> Self { + assert!( + min_visible <= duration, + "VetoWindow: min_visible ({min_visible:?}) must be ≤ duration ({duration:?})" + ); + Self { + duration, + min_visible, + } + } + + /// Calculate the absolute deadline given a staged-at timestamp. + pub fn deadline(&self, staged_at: OffsetDateTime) -> OffsetDateTime { + staged_at + + time::Duration::try_from(self.duration) + .expect("VetoWindow duration too large for time::Duration") + } + + /// The earliest time at which the window may close (staged_at + min_visible). + pub fn earliest_close(&self, staged_at: OffsetDateTime) -> OffsetDateTime { + staged_at + + time::Duration::try_from(self.min_visible) + .expect("VetoWindow min_visible too large for time::Duration") + } + + /// Whether the window may now be closed given the current time and when it + /// was staged. + /// + /// Returns `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) -> bool { + now >= self.earliest_close(staged_at) + } +} + +// --------------------------------------------------------------------------- +// 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, + /// 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, + /// The window expired and revalidation failed; proposal was rejected. + Expired, + /// 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::Expired => "expired", + 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). + pub deadline: OffsetDateTime, + /// Earliest moment the window may close (RFC 3339). + 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 (for `Applied` and + /// `Expired` paths — `None` for `Vetoed` / `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, +} + +// --------------------------------------------------------------------------- +// 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, Deserialize)] +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. + pub veto_deadline: Option, + /// The surfaces affected by this proposal (for display). + pub affected_surfaces: Vec, +} + +impl ApprovalPathWireEnvelope { + /// Construct from a classification and optional staged-at timestamp. + pub fn from_classification( + classification: &ProposalClassification, + staged_at: Option, + ) -> Self { + 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 = staged_at.and_then(|at| { + classification + .approval_path + .veto_window() + .map(|w| w.deadline(at)) + }); + Self { + variant, + label, + veto_deadline, + affected_surfaces: vec![], + } + } + + /// 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 + )); + } + 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()); + } + + // ---- 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); + 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); + 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)); + // After min_visible → eligible. + let after = staged + time::Duration::seconds(600); + assert!(w.is_min_visible_elapsed(staged, after)); + } + + #[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)); + } + + // ---- WindowCloseReason ---- + + #[test] + fn window_close_reason_tags_are_stable() { + let pairs = [ + (WindowCloseReason::Applied, "applied"), + (WindowCloseReason::Vetoed, "vetoed"), + (WindowCloseReason::Expired, "expired"), + (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_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_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, None); + assert_eq!(env.label, "human_required"); + assert_eq!(env.variant, ApprovalPathKind::HumanApprovalWithRationale); + assert!(env.validate_label_round_trip().is_ok()); + } + + #[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 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/lib.rs b/crates/coven-threads-core/src/lib.rs index 235a840..178fcba 100644 --- a/crates/coven-threads-core/src/lib.rs +++ b/crates/coven-threads-core/src/lib.rs @@ -53,6 +53,7 @@ //! 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; @@ -66,6 +67,11 @@ pub mod thread; pub mod validate; pub mod weave; +pub use approval::{ + ApprovalPath, ApprovalPathKind, ApprovalPathWireEnvelope, ProposalClassification, + ProposalWindowAuditDetail, ProposalWindowCloseAuditDetail, SurfaceRegionId, VetoWindow, + WindowCloseReason, +}; pub use audit::{AuditEventType, WardAuditRecord, WARD_AUDIT_SCHEMA_SQL}; pub use channel::Channel; pub use fray::{FrayOrSnap, FrayReason, SnapReason}; From 944f4311d1badeb0017d28a75bc3daaef2ce84b4 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 19 Jul 2026 14:46:19 -0500 Subject: [PATCH 08/12] feat(phase-5): identity invariant predicates + advisory probes (threads-uqx.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds crates/coven-threads-core/src/identity_invariants.rs. Types / implementations: - FamiliarNameInvariant: PatternPredicate over Blake3 content hash of an identity surface (e.g. IDENTITY.md). Fails closed on mismatch, snapped thread, or missing thread (decision 4: ambiguity → reject, not fallback). - ManifestAnchoredInvariant: PatternPredicate over manifest entry hash; holds under Forced channel (WARD-C1..C6: must survive compaction). - CompositeIdentityInvariant: all-components-must-hold composite predicate; identity invariant is a unit — no partial pass. - AdvisoryProbeResult: Gate-3 supplementary evidence shape; is_authoritative always false; confidence validated in [0.0, 1.0]. - AdvisoryProbes: the advisory_probes block (Q4 decision) — separate from deterministic probes block; empty means 'no advisory signals ran'. Key constraints enforced: - Predicate-first; no new strand kind (decision 3/4) - Fail-closed on ambiguity (decision 4) - advisory_probes structurally separate from probes; never sole authority - descriptor is derived, never enforced on (§2.3) Tests: 16 new (identity_invariants::tests), all passing. Full suite: 106 tests, 0 failures. Closes #9 --- .../src/identity_invariants.rs | 793 ++++++++++++++++++ crates/coven-threads-core/src/lib.rs | 5 + 2 files changed, 798 insertions(+) create mode 100644 crates/coven-threads-core/src/identity_invariants.rs 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..4e5a47e --- /dev/null +++ b/crates/coven-threads-core/src/identity_invariants.rs @@ -0,0 +1,793 @@ +//! Identity invariant `PatternPredicate` implementations (`threads-uqx.4`). +//! +//! This module carries forward the Phase-0/RFC-0001 identity invariants as +//! **predicate implementations**, not strings. The v0.1 textual invariant +//! syntax is dead; these are its typed successors. +//! +//! ## 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 serde::{Deserialize, Serialize}; + +use crate::channel::Channel; +use crate::ids::SurfaceId; +use crate::pattern::{PatternDescriptor, PatternPredicate, StrandRequirement, WeaveCoherence}; +use crate::strand::{HashAlgo, Strand, StrandKind}; +use crate::thread::Thread; + +// --------------------------------------------------------------------------- +// FamiliarNameInvariant +// --------------------------------------------------------------------------- + +/// Invariant: the familiar's declared name has not changed from the pinned +/// value, as proven by the content hash of its primary identity surface. +/// +/// ## How it works +/// +/// At weave construction time, the principal records the `expected_hash` of the +/// identity surface (e.g. `IDENTITY.md`) using [`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 string comparison of the familiar name. It is a commitment to +/// the entire identity surface. If the name changes, so does the hash; the +/// invariant fails and the proposal must clear human review. +#[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 + ), + }; + } + + // Check each thread for a ContentHash strand that matches the expected + // hash. Any single matching, intact thread is sufficient. + for thread in &surface_threads { + if thread.holds_under(Channel::Mutation).is_err() { + // This thread is frayed/snapped; skip it. + continue; + } + for strand in &thread.strands { + if let Strand::ContentHash { value, .. } = strand { + if value.as_slice() == self.expected_hash.as_slice() { + // Hash matches on an intact thread — invariant holds. + return WeaveCoherence::Coherent; + } else { + // Hash mismatch → fail closed; the identity surface has + // been mutated without this invariant's knowledge. + return WeaveCoherence::Broken { + reason: format!( + "FamiliarNameInvariant: content hash mismatch on {:?} — \ + expected {:?}, found {:?}. Identity surface mutation \ + requires human review.", + self.surface, + &self.expected_hash[..4], + &value[..4.min(value.len())], + ), + }; + } + } + } + } + + // Surface thread exists but carries no ContentHash strand — fail closed. + WeaveCoherence::Broken { + reason: format!( + "FamiliarNameInvariant: thread for {:?} carries no ContentHash strand — \ + fail closed (decision 4: ambiguity → reject, not fallback to probe)", + self.surface + ), + } + } + + 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 + ), + }; + } + + for thread in &surface_threads { + // Forced channel is the strictest; if the thread doesn't hold + // under Forced, it cannot prove survival across compaction. + if thread.holds_under(Channel::Forced).is_err() { + continue; + } + for strand in &thread.strands { + if let Strand::ManifestEntry { entry_hash, .. } = strand { + if entry_hash.as_slice() == self.expected_entry_hash.as_slice() { + return WeaveCoherence::Coherent; + } else { + return WeaveCoherence::Broken { + reason: format!( + "ManifestAnchoredInvariant: manifest entry hash mismatch \ + on {:?} — pinned {:?}, found {:?}. Requires principal review.", + self.surface, + &self.expected_entry_hash[..4], + &entry_hash[..4.min(entry_hash.len())], + ), + }; + } + } + } + } + + WeaveCoherence::Broken { + reason: format!( + "ManifestAnchoredInvariant: no Forced-intact ManifestEntry strand for \ + {:?} — fail closed (WARD-C1..C6: must survive compaction)", + self.surface + ), + } + } + + 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(); + + for component in &self.components { + match component.coherent(threads) { + WeaveCoherence::Coherent => {} + WeaveCoherence::Degraded { + degraded_surfaces: mut s, + reason, + } => { + 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 !degraded_surfaces.is_empty() { + 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, + } + } + + // ---- 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_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:?}"), + } + } + + // ---- 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_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 178fcba..765f59a 100644 --- a/crates/coven-threads-core/src/lib.rs +++ b/crates/coven-threads-core/src/lib.rs @@ -56,6 +56,7 @@ pub mod approval; pub mod audit; pub mod channel; +pub mod identity_invariants; pub mod fray; pub mod ids; pub mod manifest; @@ -67,6 +68,10 @@ pub mod thread; pub mod validate; pub mod weave; +pub use identity_invariants::{ + AdvisoryProbeResult, AdvisoryProbes, CompositeIdentityInvariant, FamiliarNameInvariant, + ManifestAnchoredInvariant, +}; pub use approval::{ ApprovalPath, ApprovalPathKind, ApprovalPathWireEnvelope, ProposalClassification, ProposalWindowAuditDetail, ProposalWindowCloseAuditDetail, SurfaceRegionId, VetoWindow, From b6aecce011e0eb7ada50e2b6e78c852d989b0e06 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 19 Jul 2026 14:49:29 -0500 Subject: [PATCH 09/12] feat(phase-5): SurfaceRegionPredicate + Gate-4 replay (threads-uqx.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds crates/coven-threads-core/src/surface_regions.rs. Types introduced: - MaterializedDiff / SurfaceDiff: pure diff input; predicates are pure functions of this (Gate-4 replay constraint — no Cave state, no stale metadata, no agent self-report) - RegionEvidence: predicate output; feeds ProposalClassification.affected_regions and evidence_replay_hash; includes replay_bytes for Gate-4 deadline check - SurfaceRegionDescriptor: derived, Cave-renderable, never enforced on - SurfaceRegionPredicate trait: materialize (authoritative) + describe (derived) - ExecutionPromptRegion: SOUL.md / AGENTS.md, tier-0 floor, human review - ToolDefaultsRegion: TOOLS.md, tier-1 floor, familiar review - HeartbeatBehaviorRegion: HEARTBEAT.md, tier-1 floor - SurfaceRegionRegistry: classify_all + descriptors + path_tier_floor helper Key constraints enforced: - Classify-first; threads stay source-bound (decision 3) - materialize is pure over MaterializedDiff — daemon-replayable at Gate-4 - replay_bytes are deterministic (sorted surface order) - path_tier_floor = min across evidence (most protective wins) - SurfaceRegionDescriptor never enforced on Tests: 12 new (surface_regions::tests), all passing. Full suite: 118 tests, 0 failures. Closes #10 --- crates/coven-threads-core/src/lib.rs | 6 + .../coven-threads-core/src/surface_regions.rs | 651 ++++++++++++++++++ 2 files changed, 657 insertions(+) create mode 100644 crates/coven-threads-core/src/surface_regions.rs diff --git a/crates/coven-threads-core/src/lib.rs b/crates/coven-threads-core/src/lib.rs index 765f59a..11158b0 100644 --- a/crates/coven-threads-core/src/lib.rs +++ b/crates/coven-threads-core/src/lib.rs @@ -57,6 +57,7 @@ pub mod approval; pub mod audit; pub mod channel; pub mod identity_invariants; +pub mod surface_regions; pub mod fray; pub mod ids; pub mod manifest; @@ -68,6 +69,11 @@ pub mod thread; pub mod validate; pub mod weave; +pub use surface_regions::{ + ExecutionPromptRegion, HeartbeatBehaviorRegion, MaterializedDiff, RegionEvidence, + SurfaceDiff, SurfaceRegionDescriptor, SurfaceRegionPredicate, SurfaceRegionRegistry, + ToolDefaultsRegion, +}; pub use identity_invariants::{ AdvisoryProbeResult, AdvisoryProbes, CompositeIdentityInvariant, FamiliarNameInvariant, ManifestAnchoredInvariant, 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..c214de5 --- /dev/null +++ b/crates/coven-threads-core/src/surface_regions.rs @@ -0,0 +1,651 @@ +//! 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)] +pub struct MaterializedDiff { + /// The per-surface before/after pairs. + pub 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 { + /// 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()) + } +} + +// --------------------------------------------------------------------------- +// 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, +} + +// --------------------------------------------------------------------------- +// 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; + } + + // Deterministic replay bytes: sorted surface ids + content hashes. + let mut replay_bytes = Vec::new(); + let mut sorted = affected.clone(); + sorted.sort_by(|a, b| a.as_str().cmp(b.as_str())); + for surface in &sorted { + replay_bytes.extend_from_slice(surface.as_str().as_bytes()); + replay_bytes.push(b':'); + if let Some(diff) = proposal.for_surface(surface) { + if let Some(after) = &diff.after { + replay_bytes.extend_from_slice(after); + } + } + replay_bytes.push(b'\n'); + } + + 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 mut replay_bytes = Vec::new(); + let mut sorted = affected.clone(); + sorted.sort_by(|a, b| a.as_str().cmp(b.as_str())); + for surface in &sorted { + replay_bytes.extend_from_slice(surface.as_str().as_bytes()); + replay_bytes.push(b':'); + if let Some(diff) = proposal.for_surface(surface) { + if let Some(after) = &diff.after { + replay_bytes.extend_from_slice(after); + } + } + replay_bytes.push(b'\n'); + } + + 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 { + pub heartbeat_surfaces: Vec, +} + +impl HeartbeatBehaviorRegion { + 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 mut replay_bytes = Vec::new(); + for surface in &affected { + replay_bytes.extend_from_slice(surface.as_str().as_bytes()); + if let Some(diff) = proposal.for_surface(surface) { + if let Some(after) = &diff.after { + replay_bytes.extend_from_slice(after); + } + } + } + + 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 { + surfaces: vec![SurfaceDiff { + surface: SurfaceId::new(surface), + before: before.map(|b| b.to_vec()), + after: after.map(|b| b.to_vec()), + }], + } + } + + // ---- 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()); + } + + // ---- 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 { + surfaces: 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()), + }, + ], + }; + 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_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 { + surfaces: 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()), + }, + ], + }; + 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()); + } +} From 521b72309cc8a64581b1482ec83058579500449c Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 19 Jul 2026 20:58:07 -0500 Subject: [PATCH 10/12] spec(phase-5): resolve coherence review findings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- specs/PHASE-5-APPROVAL-SEMANTICS.md | 92 ++++++++++++++++++++--------- 1 file changed, 63 insertions(+), 29 deletions(-) diff --git a/specs/PHASE-5-APPROVAL-SEMANTICS.md b/specs/PHASE-5-APPROVAL-SEMANTICS.md index 5f5fdb0..7f686f3 100644 --- a/specs/PHASE-5-APPROVAL-SEMANTICS.md +++ b/specs/PHASE-5-APPROVAL-SEMANTICS.md @@ -1,18 +1,21 @@ # PHASE-5 PROPOSAL — Approval semantics over the Phase-2 Ward -**Status:** PROPOSAL (decisions recorded — awaiting PR merge to open Phase 5) +**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 (main session) +**Decided by:** Val Alexander + Nova (decision commit `b085fc8`, authored by +Val and carrying Nova's co-author attestation) --- ## 0. Scope and sources -This is a proposal document only. It does not start Phase 5, amend frozen -Phase 0, or change the daemon. Val and Nova decide whether Phase 5 opens. +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: @@ -23,7 +26,8 @@ Sources read and cited: - `../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 - pending on issues #3/#4 and are treated as upstream-in-flight. + 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 @@ -99,8 +103,10 @@ 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. If Phase 5 opens, policy should be typed and -daemon-owned. The Cave may render it; it must not become a policy engine. +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 @@ -127,10 +133,12 @@ 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 syntax should stay dead. It was legible but underspecified: -parser semantics, evidence collection, normalization, provenance, and failure -handling were not defined. Pending RFC issues #3/#4 should land, or be waived -by Val/Nova, before Phase 5 freezes invariant interfaces. +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. --- @@ -242,9 +250,18 @@ 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; -- final `proposal_approved`, `proposal_vetoed`, `proposal_expired`, or - `proposal_rejected`: daemon revalidation result immediately before apply or - refusal. +- 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. @@ -257,8 +274,10 @@ exists; until then unknown or stale veto state renders blocked under Phase 4 §4 - 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 TOML as normative format. -- No Phase-5 start and no changes to frozen Phase 0. +- 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. @@ -269,24 +288,33 @@ exists; until then unknown or stale veto state renders blocked under Phase 4 §4 ## 6. Decisions (Val + Nova, 2026-07-18) -All eight questions resolved on Sage's recommended defaults. RFC dependency -blocker (issues #3/#4) resolved: both landed in OpenCoven/familiar-contract@2a5bb98 -and issues closed before this decision record was written. +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. - *Follows the source-authoritative-enforcement rule: ApprovalPath is - authoritative; Channel is a derived load descriptor. Never gate on Channel. - If this rule shifts, revisit all eight decisions.* (Echo, Sage 2026-07-18) + *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 | expired | superseded`. Without it, + 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)* @@ -307,10 +335,11 @@ and issues closed before this decision record was written. fail-closed (no promotion) — not silent fallback to LLM judgment. Ambiguity is an explicit ignored/blocked state. (Echo 2026-07-18)* -5. **Must RFC issues #3/#4 land before freeze? → MOOT; both landed.** - OpenCoven/coven-threads#3 and #4 closed 2026-07-18. Phase 5 freeze has no - RFC amendment blocker. See familiar-contract@2a5bb98 for the resolved shape; - issues #3/#4 preserved in git history for provenance. +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.** @@ -343,7 +372,9 @@ 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` — ✅ RESOLVED: RFC #3/#4 landed (familiar-contract@2a5bb98). +- `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:* @@ -362,7 +393,10 @@ Phase 5 opened 2026-07-18 (Val + Nova decision). Beads are live. 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. +- `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.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. From 7da030d63468e87c986e74d12e589f4c6283e8fe Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 19 Jul 2026 22:24:43 -0500 Subject: [PATCH 11/12] feat(phase-5): harden authority-boundary core Bind semantic identity evidence into Gate-4 validation, commit replay to the complete materialized diff, validate wire contracts, and make proposal audit lifecycle persistence fail closed while preserving legacy history. Bump the breaking public API to 0.2.0 and cover the retired invariant corpus, delayed approval semantics, schema migration paths, and append-only terminal behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 9 +- Cargo.lock | 2 +- Cargo.toml | 4 +- crates/coven-threads-core/src/approval.rs | 370 +++++- crates/coven-threads-core/src/audit.rs | 1042 ++++++++++++++++- .../src/identity_invariants.rs | 856 ++++++++++++-- crates/coven-threads-core/src/lib.rs | 37 +- crates/coven-threads-core/src/pattern.rs | 14 + .../coven-threads-core/src/surface_regions.rs | 385 ++++-- crates/coven-threads-core/src/validate.rs | 73 +- crates/coven-threads-core/src/weave.rs | 9 + .../tests/rfc0001_s5_conformance.rs | 1 + 12 files changed, 2522 insertions(+), 280 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc34d24..1c07867 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to `coven-threads-core` are documented here. -## [0.1.4] — unreleased +## [0.2.0] — unreleased ### Added @@ -13,14 +13,17 @@ All notable changes to `coven-threads-core` are documented here. - `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. -- `WARD_AUDIT_MIGRATION_V014_SQL` — transaction SQL to rebuild `ward_audit` for stores created against v0.1.3 (which lacked the `apply_audit` CHECK entry and the `detail` column). Bumps `PRAGMA user_version = 14` inside the transaction so future migrations can gate on `user_version < N` instead of substring-sniffing `sqlite_master` DDL. The daemon should run this migration when `PRAGMA user_version < 14`. +- 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 `WARD_AUDIT_MIGRATION_V014_SQL` so the daemon can perform a quiet guarded rebuild on upgrade; does not add a rusqlite dependency to coven-threads-core. +- **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 diff --git a/Cargo.lock b/Cargo.lock index 4caca61..6090a21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,7 +67,7 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "coven-threads-core" -version = "0.1.4" +version = "0.2.0" dependencies = [ "blake3", "serde", diff --git a/Cargo.toml b/Cargo.toml index 633c4b6..3696a88 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/coven-threads-core"] [workspace.package] -version = "0.1.4" +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/src/approval.rs b/crates/coven-threads-core/src/approval.rs index 4087b4e..6a8ac54 100644 --- a/crates/coven-threads-core/src/approval.rs +++ b/crates/coven-threads-core/src/approval.rs @@ -148,10 +148,26 @@ impl ApprovalPath { /// Returns the higher-ceremony path of `self` and `other`. pub fn highest(self, other: ApprovalPath) -> ApprovalPath { - if other.ceremony_ordinal() > self.ceremony_ordinal() { - other - } else { - self + 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 + } + } } } @@ -164,6 +180,17 @@ impl ApprovalPath { } } + 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 { @@ -178,9 +205,13 @@ impl ApprovalPath { #[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, } @@ -222,53 +253,103 @@ impl ApprovalPathKind { /// /// [`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. - pub duration: Duration, + 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`. - pub min_visible: Duration, + min_visible: Duration, } impl VetoWindow { /// Construct a veto window, panicking if `min_visible > duration`. pub fn new(duration: Duration, min_visible: Duration) -> Self { - assert!( - min_visible <= duration, - "VetoWindow: min_visible ({min_visible:?}) must be ≤ duration ({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) -> OffsetDateTime { - staged_at - + time::Duration::try_from(self.duration) - .expect("VetoWindow duration too large for time::Duration") + 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) -> OffsetDateTime { - staged_at - + time::Duration::try_from(self.min_visible) - .expect("VetoWindow min_visible too large for time::Duration") + 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 `true` iff `now >= earliest_close(staged_at)`. The scheduler + /// 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) -> bool { - now >= self.earliest_close(staged_at) + 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) } } @@ -291,6 +372,8 @@ pub struct ProposalClassification { 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, @@ -354,8 +437,11 @@ pub enum WindowCloseReason { Applied, /// A principal vetoed the proposal before the deadline. Vetoed, - /// The window expired and revalidation failed; proposal was rejected. - Expired, + /// 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, @@ -367,7 +453,8 @@ impl WindowCloseReason { match self { WindowCloseReason::Applied => "applied", WindowCloseReason::Vetoed => "vetoed", - WindowCloseReason::Expired => "expired", + WindowCloseReason::EvidenceDiverged => "evidence_diverged", + WindowCloseReason::RevalidationFailed => "revalidation_failed", WindowCloseReason::Superseded => "superseded", } } @@ -394,8 +481,10 @@ 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, @@ -411,15 +500,28 @@ pub struct ProposalWindowAuditDetail { pub struct ProposalWindowCloseAuditDetail { /// Why the window closed. pub reason: WindowCloseReason, - /// Whether the evidence replay hash matched at deadline (for `Applied` and - /// `Expired` paths — `None` for `Vetoed` / `Superseded` where replay is not - /// attempted). + /// 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) // --------------------------------------------------------------------------- @@ -429,7 +531,7 @@ pub struct ProposalWindowCloseAuditDetail { /// /// 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, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ApprovalPathWireEnvelope { /// Stable enum variant name (snake_case, for machine use). pub variant: ApprovalPathKind, @@ -437,36 +539,73 @@ pub struct ApprovalPathWireEnvelope { /// 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, - ) -> Self { + ) -> Result { let variant = match &classification.approval_path { ApprovalPath::AutoRegression { .. } => ApprovalPathKind::AutoRegression, ApprovalPath::FamiliarCoherence { .. } => ApprovalPathKind::FamiliarCoherence, ApprovalPath::HumanApproval => ApprovalPathKind::HumanApproval, - ApprovalPath::HumanApprovalWithRationale => ApprovalPathKind::HumanApprovalWithRationale, + ApprovalPath::HumanApprovalWithRationale => { + ApprovalPathKind::HumanApprovalWithRationale + } }; let label = classification.approval_path.display_label().to_string(); - let veto_deadline = staged_at.and_then(|at| { - classification - .approval_path - .veto_window() - .map(|w| w.deadline(at)) - }); - Self { + 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: vec![], - } + 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 @@ -484,6 +623,17 @@ impl ApprovalPathWireEnvelope { 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(()) } } @@ -560,13 +710,42 @@ mod tests { 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); + let dl = w.deadline(staged).unwrap(); assert_eq!( dl, staged + time::Duration::hours(1), @@ -578,7 +757,7 @@ mod tests { 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); + let ec = w.earliest_close(staged).unwrap(); assert_eq!(ec, staged + time::Duration::minutes(10)); } @@ -588,10 +767,19 @@ mod tests { 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)); + 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)); + 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] @@ -601,6 +789,24 @@ mod tests { 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] @@ -608,7 +814,8 @@ mod tests { let pairs = [ (WindowCloseReason::Applied, "applied"), (WindowCloseReason::Vetoed, "vetoed"), - (WindowCloseReason::Expired, "expired"), + (WindowCloseReason::EvidenceDiverged, "evidence_diverged"), + (WindowCloseReason::RevalidationFailed, "revalidation_failed"), (WindowCloseReason::Superseded, "superseded"), ]; for (reason, expected_tag) in pairs { @@ -641,6 +848,7 @@ mod tests { 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 { @@ -664,18 +872,47 @@ mod tests { 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, None); + 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 { @@ -700,6 +937,45 @@ mod tests { 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 { diff --git a/crates/coven-threads-core/src/audit.rs b/crates/coven-threads-core/src/audit.rs index 19ecd5f..c573ddc 100644 --- a/crates/coven-threads-core/src/audit.rs +++ b/crates/coven-threads-core/src/audit.rs @@ -21,12 +21,10 @@ //! ## Schema versioning and migration //! //! `WARD_AUDIT_SCHEMA_SQL` uses `CREATE TABLE IF NOT EXISTS`, so new empty -//! stores automatically receive the current DDL including the `apply_audit` -//! CHECK tag. Stores created against v0.1.3 (which lacked the `apply_audit` -//! CHECK entry) must be rebuilt: the daemon should run -//! `WARD_AUDIT_MIGRATION_V014_SQL` once at startup when it detects the old -//! CHECK text is still in the schema. That migration rebuilds `ward_audit` -//! in a single transaction (create, copy, drop, rename) — SQLite cannot +//! 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 @@ -34,7 +32,7 @@ //! `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 +//! 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 @@ -45,6 +43,10 @@ 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}; @@ -53,6 +55,98 @@ use crate::validate::{MutationRequest, RejectReason, Verdict}; 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`. /// @@ -64,6 +158,8 @@ pub const APPLY_AUDIT_DETAIL_KEY_BYTES: &str = "bytes_written"; 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). @@ -72,6 +168,10 @@ 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). @@ -95,10 +195,13 @@ 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", @@ -144,7 +247,7 @@ pub struct WardAuditRecord { /// **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 `""` when unknown. + /// 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- @@ -238,7 +341,7 @@ impl WardAuditRecord { ) -> Self { let prev_hex = prev_hash.map(bytes_to_hex); let detail = serde_json::json!({ - APPLY_AUDIT_DETAIL_KEY_PREV: prev_hex.unwrap_or_default(), + APPLY_AUDIT_DETAIL_KEY_PREV: prev_hex, APPLY_AUDIT_DETAIL_KEY_BYTES: bytes_written, }) .to_string(); @@ -261,6 +364,150 @@ impl WardAuditRecord { } } + /// 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. @@ -283,6 +530,87 @@ impl WardAuditRecord { } } +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() } @@ -304,8 +632,7 @@ 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). -/// DDL migration for v0.1.3 → v0.1.4: rebuilds `ward_audit` so its CHECK -/// constraint includes the new `apply_audit` tag. +/// 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: @@ -315,25 +642,23 @@ fn reject_tag(reason: &RejectReason) -> &'static str { /// 4. Renames the new table into place. /// 5. Re-creates indexes and append-only triggers. /// -/// **Run condition:** execute this only when `PRAGMA user_version` returns a -/// value less than `14` — i.e. the store was created before this migration. -/// `PRAGMA user_version` is `0` for stores that predate any explicit versioning -/// (all v0.1.3 stores). After this migration runs, `user_version` is `14` -/// (matching `0.1.4`), giving future migrations a clean integer ladder to gate -/// on instead of substring-sniffing `sqlite_master` DDL. +/// 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`. /// -/// Idempotent in the sense that if `user_version >= 14` the daemon must not -/// re-run it; the `ward_audit_new` table name is not guarded with `IF NOT -/// EXISTS`, so a double-run would fail on the `CREATE` step (intentional: -/// prevents silent data loss from a buggy migration controller). -pub const WARD_AUDIT_MIGRATION_V014_SQL: &str = r#" +/// 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 IF NOT EXISTS ward_audit_new ( +CREATE TABLE ward_audit_new ( 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', + '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, @@ -349,7 +674,35 @@ CREATE TABLE IF NOT EXISTS ward_audit_new ( 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')) + 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 ( @@ -359,7 +712,7 @@ INSERT INTO ward_audit_new ( ) SELECT id, event_type, proposal_id, familiar_id, ward_version, ward_hash, - tier, decision, approver, diff_hash, NULL, files_touched, channel, + tier, decision, approver, diff_hash, __SOURCE_DETAIL__, files_touched, channel, thread_id, submitted_at, decided_at, recorded_at FROM ward_audit; @@ -368,7 +721,6 @@ 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 @@ -381,14 +733,164 @@ BEGIN SELECT RAISE(ABORT, 'ward_audit is append-only (RFC-0001 §5.6)'); END; --- Stamp schema version so future migrations can gate on `PRAGMA user_version` --- instead of substring-sniffing the CHECK DDL. Gate rule for callers: --- run this migration when `PRAGMA user_version < 14`. -PRAGMA user_version = 14; +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. @@ -396,8 +898,10 @@ 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', + '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, @@ -413,12 +917,39 @@ CREATE TABLE IF NOT EXISTS ward_audit ( 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 @@ -430,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)] @@ -442,6 +1108,7 @@ mod tests { surface: SurfaceId::new("SOUL.md"), writer: WriterId::new("principal:val"), channel: Channel::Mutation, + identity_context: None, } } @@ -490,10 +1157,13 @@ 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, @@ -514,6 +1184,7 @@ 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] @@ -567,25 +1238,316 @@ mod tests { 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!( - WARD_AUDIT_MIGRATION_V014_SQL.contains("'apply_audit'"), + migration.contains("'apply_audit'"), "migration CHECK must contain apply_audit tag" ); assert!( - WARD_AUDIT_MIGRATION_V014_SQL.contains("detail"), + migration.contains("detail"), "migration must add detail column" ); assert!( - WARD_AUDIT_MIGRATION_V014_SQL.contains("BEGIN;"), + migration.contains("BEGIN;"), "migration must be a transaction" ); assert!( - WARD_AUDIT_MIGRATION_V014_SQL.contains("COMMIT;"), + 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 index 4e5a47e..db98224 100644 --- a/crates/coven-threads-core/src/identity_invariants.rs +++ b/crates/coven-threads-core/src/identity_invariants.rs @@ -1,8 +1,9 @@ -//! Identity invariant `PatternPredicate` implementations (`threads-uqx.4`). +//! Identity invariant compilation and predicate implementations (`threads-uqx.4`). //! -//! This module carries forward the Phase-0/RFC-0001 identity invariants as -//! **predicate implementations**, not strings. The v0.1 textual invariant -//! syntax is dead; these are its typed successors. +//! [`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) //! @@ -39,31 +40,400 @@ //! 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::{HashAlgo, Strand, StrandKind}; +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 // --------------------------------------------------------------------------- -/// Invariant: the familiar's declared name has not changed from the pinned -/// value, as proven by the content hash of its primary identity surface. +/// 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 [`HashAlgo::Blake3`]. At gate +/// 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 string comparison of the familiar name. It is a commitment to -/// the entire identity surface. If the name changes, so does the hash; the -/// invariant fails and the proposal must clear human review. +/// 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`). @@ -93,52 +463,64 @@ impl PatternPredicate for FamiliarNameInvariant { }; } - // Check each thread for a ContentHash strand that matches the expected - // hash. Any single matching, intact thread is sufficient. - for thread in &surface_threads { - if thread.holds_under(Channel::Mutation).is_err() { - // This thread is frayed/snapped; skip it. - continue; - } - for strand in &thread.strands { - if let Strand::ContentHash { value, .. } = strand { - if value.as_slice() == self.expected_hash.as_slice() { - // Hash matches on an intact thread — invariant holds. - return WeaveCoherence::Coherent; - } else { - // Hash mismatch → fail closed; the identity surface has - // been mutated without this invariant's knowledge. - return WeaveCoherence::Broken { - reason: format!( - "FamiliarNameInvariant: content hash mismatch on {:?} — \ - expected {:?}, found {:?}. Identity surface mutation \ - requires human review.", - self.surface, - &self.expected_hash[..4], - &value[..4.min(value.len())], - ), - }; - } - } + 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 + ), + }; } } - - // Surface thread exists but carries no ContentHash strand — fail closed. - WeaveCoherence::Broken { - reason: format!( - "FamiliarNameInvariant: thread for {:?} carries no ContentHash strand — \ - fail closed (decision 4: ambiguity → reject, not fallback to probe)", - 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() - ), + 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 { @@ -188,46 +570,63 @@ impl PatternPredicate for ManifestAnchoredInvariant { }; } - for thread in &surface_threads { - // Forced channel is the strictest; if the thread doesn't hold - // under Forced, it cannot prove survival across compaction. - if thread.holds_under(Channel::Forced).is_err() { - continue; - } - for strand in &thread.strands { - if let Strand::ManifestEntry { entry_hash, .. } = strand { - if entry_hash.as_slice() == self.expected_entry_hash.as_slice() { - return WeaveCoherence::Coherent; - } else { - return WeaveCoherence::Broken { - reason: format!( - "ManifestAnchoredInvariant: manifest entry hash mismatch \ - on {:?} — pinned {:?}, found {:?}. Requires principal review.", - self.surface, - &self.expected_entry_hash[..4], - &entry_hash[..4.min(entry_hash.len())], - ), - }; - } - } + 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 + ), + }; } } - - WeaveCoherence::Broken { - reason: format!( - "ManifestAnchoredInvariant: no Forced-intact ManifestEntry strand for \ - {:?} — fail closed (WARD-C1..C6: must survive compaction)", - 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() - ), + 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![ @@ -278,6 +677,7 @@ impl PatternPredicate for CompositeIdentityInvariant { 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) { @@ -286,6 +686,7 @@ impl PatternPredicate for CompositeIdentityInvariant { degraded_surfaces: mut s, reason, } => { + saw_degraded = true; degraded_surfaces.append(&mut s); degraded_reasons.push(reason); } @@ -299,7 +700,7 @@ impl PatternPredicate for CompositeIdentityInvariant { WeaveCoherence::Broken { reason: broken_reasons.join("; "), } - } else if !degraded_surfaces.is_empty() { + } else if saw_degraded { WeaveCoherence::Degraded { degraded_surfaces, reason: degraded_reasons.join("; "), @@ -418,11 +819,9 @@ impl AdvisoryProbeResult { /// wire-received values) pub fn validate(&self) -> Result<(), String> { if self.is_authoritative { - return Err( - "AdvisoryProbeResult.is_authoritative must be false — \ + return Err("AdvisoryProbeResult.is_authoritative must be false — \ advisory probes are never authoritative (decision 4)" - .into(), - ); + .into()); } if let Some(c) = self.confidence { if !(0.0..=1.0).contains(&c) { @@ -507,16 +906,176 @@ mod tests { contract_hash: vec![0xcc; 32], }, ], - holds_under: vec![ - Channel::Forced, - Channel::Serialization, - Channel::Mutation, - ], + 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] @@ -574,7 +1133,11 @@ mod tests { description: "name must not change".into(), }; let mut thread = hash_thread("IDENTITY.md", expected); - thread.snap(Channel::Mutation, SnapReason::Revoked, OffsetDateTime::now_utc()); + 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]) { @@ -583,6 +1146,42 @@ mod tests { } } + #[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 { @@ -591,7 +1190,9 @@ mod tests { description: "test".into(), }; let d = inv.describe(); - assert!(d.protected_surfaces.contains(&SurfaceId::new("IDENTITY.md"))); + assert!(d + .protected_surfaces + .contains(&SurfaceId::new("IDENTITY.md"))); assert!(d.channels_required.contains(&Channel::Mutation)); assert!(d .strand_requirements @@ -633,6 +1234,42 @@ mod tests { } } + #[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] @@ -660,6 +1297,18 @@ mod tests { 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]; @@ -705,7 +1354,9 @@ mod tests { ], }; let d = composite.describe(); - assert!(d.protected_surfaces.contains(&SurfaceId::new("IDENTITY.md"))); + 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)); @@ -723,7 +1374,10 @@ mod tests { Some("name field appears changed".into()), true, ); - assert!(!probe.is_authoritative, "advisory probes must never be authoritative"); + assert!( + !probe.is_authoritative, + "advisory probes must never be authoritative" + ); assert!(probe.validate().is_ok()); } @@ -778,7 +1432,13 @@ mod tests { fn advisory_probes_roundtrips_json() { let block = AdvisoryProbes { results: vec![ - AdvisoryProbeResult::new("semantic_drift", "Drift", Some(0.75), Some("shifted".into()), false), + AdvisoryProbeResult::new( + "semantic_drift", + "Drift", + Some(0.75), + Some("shifted".into()), + false, + ), AdvisoryProbeResult::new("pattern_match", "Pattern", None, None, true), ], }; diff --git a/crates/coven-threads-core/src/lib.rs b/crates/coven-threads-core/src/lib.rs index 11158b0..03c4de8 100644 --- a/crates/coven-threads-core/src/lib.rs +++ b/crates/coven-threads-core/src/lib.rs @@ -56,36 +56,38 @@ pub mod approval; pub mod audit; pub mod channel; -pub mod identity_invariants; -pub mod surface_regions; 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 surface_regions::{ - ExecutionPromptRegion, HeartbeatBehaviorRegion, MaterializedDiff, RegionEvidence, - SurfaceDiff, SurfaceRegionDescriptor, SurfaceRegionPredicate, SurfaceRegionRegistry, - ToolDefaultsRegion, -}; -pub use identity_invariants::{ - AdvisoryProbeResult, AdvisoryProbes, CompositeIdentityInvariant, FamiliarNameInvariant, - ManifestAnchoredInvariant, -}; pub use approval::{ - ApprovalPath, ApprovalPathKind, ApprovalPathWireEnvelope, ProposalClassification, - ProposalWindowAuditDetail, ProposalWindowCloseAuditDetail, SurfaceRegionId, VetoWindow, - WindowCloseReason, + 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 audit::{AuditEventType, WardAuditRecord, WARD_AUDIT_SCHEMA_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, @@ -103,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 index c214de5..141ac90 100644 --- a/crates/coven-threads-core/src/surface_regions.rs +++ b/crates/coven-threads-core/src/surface_regions.rs @@ -66,9 +66,10 @@ use crate::ids::SurfaceId; /// 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. - pub surfaces: Vec, + surfaces: Vec, } /// Before/after for a single surface. @@ -100,6 +101,25 @@ impl SurfaceDiff { } 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() + )); + } + } + 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) @@ -111,6 +131,19 @@ impl MaterializedDiff { } } +#[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 // --------------------------------------------------------------------------- @@ -137,6 +170,101 @@ pub struct RegionEvidence { 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 // --------------------------------------------------------------------------- @@ -224,20 +352,7 @@ impl SurfaceRegionPredicate for ExecutionPromptRegion { return None; } - // Deterministic replay bytes: sorted surface ids + content hashes. - let mut replay_bytes = Vec::new(); - let mut sorted = affected.clone(); - sorted.sort_by(|a, b| a.as_str().cmp(b.as_str())); - for surface in &sorted { - replay_bytes.extend_from_slice(surface.as_str().as_bytes()); - replay_bytes.push(b':'); - if let Some(diff) = proposal.for_surface(surface) { - if let Some(after) = &diff.after { - replay_bytes.extend_from_slice(after); - } - } - replay_bytes.push(b'\n'); - } + let replay_bytes = materialized_surface_replay(proposal, &affected); Some(RegionEvidence { region_id: SurfaceRegionId::new("execution_prompt"), @@ -296,19 +411,7 @@ impl SurfaceRegionPredicate for ToolDefaultsRegion { return None; } - let mut replay_bytes = Vec::new(); - let mut sorted = affected.clone(); - sorted.sort_by(|a, b| a.as_str().cmp(b.as_str())); - for surface in &sorted { - replay_bytes.extend_from_slice(surface.as_str().as_bytes()); - replay_bytes.push(b':'); - if let Some(diff) = proposal.for_surface(surface) { - if let Some(after) = &diff.after { - replay_bytes.extend_from_slice(after); - } - } - replay_bytes.push(b'\n'); - } + let replay_bytes = materialized_surface_replay(proposal, &affected); Some(RegionEvidence { region_id: SurfaceRegionId::new("tool_defaults"), @@ -336,10 +439,12 @@ impl SurfaceRegionPredicate for ToolDefaultsRegion { /// 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")], @@ -365,15 +470,7 @@ impl SurfaceRegionPredicate for HeartbeatBehaviorRegion { return None; } - let mut replay_bytes = Vec::new(); - for surface in &affected { - replay_bytes.extend_from_slice(surface.as_str().as_bytes()); - if let Some(diff) = proposal.for_surface(surface) { - if let Some(after) = &diff.after { - replay_bytes.extend_from_slice(after); - } - } - } + let replay_bytes = materialized_surface_replay(proposal, &affected); Some(RegionEvidence { region_id: SurfaceRegionId::new("heartbeat_behavior"), @@ -461,13 +558,12 @@ mod tests { use super::*; fn diff_with(surface: &str, before: Option<&[u8]>, after: Option<&[u8]>) -> MaterializedDiff { - MaterializedDiff { - surfaces: vec![SurfaceDiff { - surface: SurfaceId::new(surface), - before: before.map(|b| b.to_vec()), - after: after.map(|b| b.to_vec()), - }], - } + 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 ---- @@ -498,6 +594,33 @@ mod tests { 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_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] @@ -524,23 +647,50 @@ mod tests { fn execution_prompt_region_replay_bytes_are_deterministic() { // Gate-4 replay: same diff → same replay_bytes. let region = ExecutionPromptRegion::default_protected(); - let diff = MaterializedDiff { - surfaces: 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()), - }, - ], - }; + 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"); + 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] @@ -581,20 +731,19 @@ mod tests { #[test] fn registry_classify_all_multiple_regions() { let registry = SurfaceRegionRegistry::default_registry(); - let diff = MaterializedDiff { - surfaces: 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()), - }, - ], - }; + 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); } @@ -648,4 +797,94 @@ mod tests { 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/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, } } From c6cdb603c7a2efc8a8c927548c84df5d72b6c33b Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 21 Jul 2026 07:59:53 -0500 Subject: [PATCH 12/12] Add synthetic retired-Ward Phase 5 corpus Introduces a public, repository-authored synthetic corpus for Phase 5 retired-Ward approval semantics, including a canonical JSON generator example and digest-pinned conformance tests. Coverage now spans identity invariants, approval paths/labels, veto windows, region classification fidelity, and fail-closed unsupported cases. Also tightens `MaterializedDiff::try_new` to reject surfaces with neither `before` nor `after`, and unchanged surfaces, with matching unit tests and design-spec tracking updated in `PHASE-5-APPROVAL-SEMANTICS.md`. --- .../generate_phase5_retired_ward_corpus.rs | 8 + .../coven-threads-core/src/surface_regions.rs | 36 +++ .../tests/phase5_retired_ward_corpus.rs | 239 ++++++++++++++++++ .../support/phase5_retired_ward_corpus.rs | 228 +++++++++++++++++ specs/PHASE-5-APPROVAL-SEMANTICS.md | 6 + 5 files changed, 517 insertions(+) create mode 100644 crates/coven-threads-core/examples/generate_phase5_retired_ward_corpus.rs create mode 100644 crates/coven-threads-core/tests/phase5_retired_ward_corpus.rs create mode 100644 crates/coven-threads-core/tests/support/phase5_retired_ward_corpus.rs 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/surface_regions.rs b/crates/coven-threads-core/src/surface_regions.rs index 141ac90..a4b3cf7 100644 --- a/crates/coven-threads-core/src/surface_regions.rs +++ b/crates/coven-threads-core/src/surface_regions.rs @@ -111,6 +111,18 @@ impl MaterializedDiff { 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 }) } @@ -612,6 +624,30 @@ mod tests { 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":[ 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/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-5-APPROVAL-SEMANTICS.md b/specs/PHASE-5-APPROVAL-SEMANTICS.md index 7f686f3..b823926 100644 --- a/specs/PHASE-5-APPROVAL-SEMANTICS.md +++ b/specs/PHASE-5-APPROVAL-SEMANTICS.md @@ -397,6 +397,12 @@ Phase 5 opened 2026-07-18 (Val + Nova decision). Beads are live. 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.