diff --git a/.s5d/aliases.yaml b/.s5d/aliases.yaml index be7ff2f..194500c 100644 --- a/.s5d/aliases.yaml +++ b/.s5d/aliases.yaml @@ -534,3 +534,21 @@ packages: package_id: feat.s5d.self-update owning_package: null deprecated: false +- uuid: ec8a5081-8f37-48ac-a69a-4ea1aa36a53e + artifact_id: cap.ownership-derivation + artifact_type: Capability + package_id: feat.s5d.ownership-derivation + owning_package: null + deprecated: false +- uuid: 7982d3b1-4774-4128-ba80-65dbb00652e7 + artifact_id: feat.s5d.ownership-derivation + artifact_type: Feature + package_id: feat.s5d.ownership-derivation + owning_package: null + deprecated: false +- uuid: cf86a094-f413-4eb0-9ede-46ad61623707 + artifact_id: comp.ownership-derivation + artifact_type: Component + package_id: feat.s5d.ownership-derivation + owning_package: null + deprecated: false diff --git a/.s5d/evidence/feat.s5d.ownership-derivation/graph_1.log b/.s5d/evidence/feat.s5d.ownership-derivation/graph_1.log new file mode 100644 index 0000000..37c7f4c --- /dev/null +++ b/.s5d/evidence/feat.s5d.ownership-derivation/graph_1.log @@ -0,0 +1 @@ +graph check passed (built-in) \ No newline at end of file diff --git a/.s5d/evidence/feat.s5d.ownership-derivation/schema_1.log b/.s5d/evidence/feat.s5d.ownership-derivation/schema_1.log new file mode 100644 index 0000000..db4bd7d --- /dev/null +++ b/.s5d/evidence/feat.s5d.ownership-derivation/schema_1.log @@ -0,0 +1 @@ +schema validation passed (built-in) \ No newline at end of file diff --git a/.s5d/evidence/feat.s5d.ownership-derivation/tribunal-round1.md b/.s5d/evidence/feat.s5d.ownership-derivation/tribunal-round1.md new file mode 100644 index 0000000..19f6063 --- /dev/null +++ b/.s5d/evidence/feat.s5d.ownership-derivation/tribunal-round1.md @@ -0,0 +1,66 @@ +# Tribunal round 1 — Codex adversarial review (2026-06-12) + +Scope: PR #13 diff (branch voroninr/ran-491-ownership-derivation vs main). +Reviewer ran: `git diff --check`, `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test --all` — all passed. + +## VERDICT: REJECT (both blockers confirmed by maintainer review and fixed in round 2) + +### Blocking 1 — derivation not anchored to imported content +`derive_global_owners` applied CURRENT package files to HISTORICAL ledger entries, +ignoring `LedgerEntry.spec_sha256`. A backdated package edit could fabricate a +false owner mismatch and veto a legitimate rollback. + +**Fix:** per-entry sha gating. An import/reconcile entry contributes claims only +when the current file's sha256 matches the entry. A mismatching/missing file +poisons every key unowned at that replay point (→ `Unverifiable`), cleared only +by that package's own rollback entry. Unverifiable never routes to the +destructive fallback — a two-file tamper (alias field + any package file) must +not be able to downgrade the veto into a tombstone. Pinned by +`rollback_skips_tombstoning_when_ownership_unverifiable`. + +### Blocking 2 — rollback epochs ignored +`AliasTable::resolve` is first-creator-wins **within the active epoch** (rollback +tombstones the entry; the next import creates a fresh one with a new owner). The +original replay derived "first ever importer" and would false-flag the legitimate +second-epoch owner on an untampered repo (A imports X → A rolled back → B imports +X → B rollback vetoed). + +**Fix:** the replay releases a package's ownerships (and its poison entries) when +its ledger `rollback` entry is seen. Pinned by +`rollback_after_reimport_does_not_false_flag_new_owner`. + +### Non-blocking notes (accepted) +- Fingerprint untouched — confirmed. +- Tombstoning still gated by stored claim — derivation never expands destruction. +- CLI/MCP mutation order centralized, matches old shape. +- Remaining test-gap notes (MCP warning parity, reconcile-derived ownership) + acknowledged; MCP shares the lib function and report serialization verbatim. + +--- + +# Tribunal round 2 — Codex (2026-06-12) + +## VERDICT: REJECT (both lifecycle blockers confirmed and fixed in round 3) + +### Blocking 1 — self-poisoning on normal re-import +Edit → re-approve → re-import leaves the FIRST ledger entry with a stale sha; +that self-stale entry poisoned the package's own keys and degraded its own +rollback to "unverifiable" skip. + +**Fix:** self-poison is harmless by construction — whichever of a package's +imports was first, the owner is the same package. Read-rule: a verified +Owner stands unless the poison set contains a FOREIGN package. Pinned by +`rollback_after_edit_and_reimport_tombstones_own_globals`. + +### Blocking 2 — rolled-back spec file pins globals via referenced-guard +`referenced_globals` walked all package files regardless of record status, so +a rolled-back spec left on disk kept its globals alive forever (pre-existing +behavior, now inconsistent with epoch semantics). + +**Fix:** specs whose record status is Deprecated no longer count as +referencing. Pinned by +`rollback_epoch_transition_works_with_old_spec_file_left_on_disk`. + +Also introduced `DerivedOwnership::UnverifiedCandidate` — a key whose only +ledger candidate is unverified maps to fallback-tombstone when consistent +with the stored field, and to non-destructive skip otherwise. diff --git a/.s5d/index.yaml b/.s5d/index.yaml index 3832bef..7272afc 100644 --- a/.s5d/index.yaml +++ b/.s5d/index.yaml @@ -109,3 +109,13 @@ features: status: applied product: s5d version: 1.0.0 +- id: decision.s5d.ownership-derivation + spec_path: packages/decision.s5d.ownership-derivation__20260612.s5d.yaml + status: applied + product: s5d + version: 1.0.0 +- id: feat.s5d.ownership-derivation + spec_path: packages/feat.s5d.ownership-derivation__20260612.s5d.yaml + status: applied + product: s5d + version: 1.0.0 diff --git a/.s5d/ledger.yaml b/.s5d/ledger.yaml index a7f70c8..03f9c13 100644 --- a/.s5d/ledger.yaml +++ b/.s5d/ledger.yaml @@ -147,3 +147,17 @@ entries: status: success timestamp: 2026-06-11T13:18:34.283184+00:00 record_ref: records/feat.s5d.self-update__20260611.record.yaml +- spec_sha256: sha256:25f8d74f16b8fcc0548f7a8a821f7a5b479a470abd599eb7684b63fe19f06de0 + state_fingerprint: sha256:f3661f6500d4713210d6e64133caec154bd03f0a9e623e3b633c0b3dd358b251 + package_id: decision.s5d.ownership-derivation + action: import + status: success + timestamp: 2026-06-12T07:00:58.037320+00:00 + record_ref: records/decision.s5d.ownership-derivation__20260612.record.yaml +- spec_sha256: sha256:164fbe42e314dab04baa9f2d61dd8395ab644ab62198967f358ba3620bbacb9c + state_fingerprint: sha256:748cc41bf3906aae6b9a9ac23e1f7c25e4d2b31a3ae81c7dd8c8d9832f2378fa + package_id: feat.s5d.ownership-derivation + action: import + status: success + timestamp: 2026-06-12T07:11:04.200544+00:00 + record_ref: records/feat.s5d.ownership-derivation__20260612.record.yaml diff --git a/.s5d/packages/decision.s5d.ownership-derivation__20260612.s5d.yaml b/.s5d/packages/decision.s5d.ownership-derivation__20260612.s5d.yaml new file mode 100644 index 0000000..d481e91 --- /dev/null +++ b/.s5d/packages/decision.s5d.ownership-derivation__20260612.s5d.yaml @@ -0,0 +1,130 @@ +s5d: '1.0' +id: decision.s5d.ownership-derivation +version: 1.0.0 +product: s5d +tier: decision +allow_update: true +meta: + title: ownership-derivation + authors: [] + date: 2026-06-12 + tickets: [] + adrs: [] + renames: [] +context: null +workflow: null +artifacts: null +links: null +contracts: [] +gates: +- kind: review +roc: null +problem: + signal: How should S5D protect rollback ownership (owning_package) integrity without invalidating existing fingerprint baselines? (RAN-491 — owning_package sits outside compute_state_fingerprint, so corrupting it is invisible to drift-check while rollback tombstoning depends on it; pinned by test owning_package_corruption_is_invisible_to_drift_check) + constraints: + - Changing the fingerprint formula invalidates every imported baseline in every repo — mass false drift after upgrade is unacceptable as a silent default + - Rollback must never become destructive on derivation errors — when in doubt, keep data and report loudly + - Legacy repos (aliases created before ledger discipline, manual aliases.yaml edits) must keep working — degrade to warning, not hard failure + targets: [] + indicators: [] + goodhart_guard: null + acceptance: (1) Tampering owning_package cannot silently change rollback outcomes when ledger and packages are available; (2) zero false drift on existing baselines after upgrade — fingerprint formula untouched; (3) non-destructive degradation — underivable ownership falls back to stored field with at most a warning; (4) no migration day and no new persistent artifact required of users + blast_radius: .s5d state machine core — rollback path (CLI + MCP), alias table semantics; no user-facing schema change + reversibility: High — derivation is additive cross-check logic; reverting restores prior tombstoning behavior without data migration + status: backlog +hypotheses: +- id: versioned-fingerprint + title: Versioned fingerprint + content: Introduce fingerprint v2 that includes owning_package. New imports/reconciles stamp v2; existing entries stay valid under v1 until the next lifecycle event re-stamps them. No migration day; convergence by natural lifecycle. + scope: compute_state_fingerprint, drift-check, reconcile + kind: system + layer: L0 + r_eff: null + evidence: + - id: 20260612-internal-versioned-fingerprint + type: internal + content: 'Analysis: avoids baseline invalidation (old entries valid under v1) but leaves a blind window — ownership corruption stays invisible between upgrade and the next lifecycle re-stamp, and dual-version fingerprint code persists indefinitely.' + verdict: refine + valid_until: 2026-09-10T06:55:25.115942+00:00 + carrier_ref: null + formality: 2 + claim_scope: + - compatibility + - detection-latency + congruence_level: null + reliability: 0.7 + refine_kind: specialise + depends_on: [] + rationale: null + spec_ref: null + prompt: null + next_move: null +- id: separate-ownership-checksum + title: Separate ownership checksum + content: Keep the import fingerprint untouched; add a second checksum covering only ownership data, stored alongside. Drift-check compares each hash against its own baseline; missing ownership baseline is informational until first stamped. + scope: ledger entries, drift-check + kind: system + layer: L0 + r_eff: null + evidence: + - id: 20260612-internal-separate-ownership-checksum + type: internal + content: 'Analysis: detects corruption without touching the import fingerprint, but adds a second permanent artifact to compute, store, and migrate; detects the symptom rather than removing the load-bearing role of the mutable field. Lower ideality than derivation.' + verdict: refine + valid_until: 2026-09-10T06:55:25.121203+00:00 + carrier_ref: null + formality: 2 + claim_scope: + - complexity + - detection + congruence_level: null + reliability: 0.7 + refine_kind: specialise + depends_on: [] + rationale: null + spec_ref: null + prompt: null + next_move: null +- id: derive-ownership-from-ledger + title: Derive ownership from ledger + content: 'Rollback derives the owner of each global alias from the append-only ledger plus stored packages at the point of use, instead of trusting the mutable owning_package field. The field degrades to a verified cache: mismatch with the derivation is a loud error (or self-heal). Fingerprint formula unchanged; the corruption class stops being load-bearing.' + scope: rollback, alias table, ledger + kind: system + layer: L2 + r_eff: null + evidence: + - id: 20260612-internal-derive-ownership-from-ledger + type: internal + content: 'Falsification probe on the s5d repo itself (2026-06-12): all 17 global aliases have owning_package present in the ledger as a successful import (17/17). Prerequisite for ledger derivation holds on real data. Caveat: probe checked owner-presence, not full derived-owner==stored-owner equality; implementation must add the full derivation test.' + verdict: pass + valid_until: 2026-09-10T06:55:25.110345+00:00 + carrier_ref: null + formality: 3 + claim_scope: + - derivability + - real-repo + congruence_level: null + reliability: 0.85 + refine_kind: null + - id: 20260612-gate-review-derive-ownership-from-ledger + type: gate:review + content: 'Adversarial review, 5 probes: (1) ownership-transfer counter-argument REFUTED by identity.rs — resolve() is first-creator-wins, apply_renames preserves owning_package, no transfer API, so first-import derivation is correct by construction; (2) tail failure (edited+re-imported packages diverge current file from historical import) bounded by conservative semantics — derivation only vetoes/confirms tombstoning, never expands it, disagreement = skip + loud warning; (3) probe weakness (presence-only, not full equality) acknowledged, full derivation tests required in implementation; (4) weakest link — derivation sources are sha256-bound (packages) and append-only (ledger), strictly stronger than the unanchored alias field; (5) alternatives D1/D2 recorded as hypotheses with refine verdicts, merkle-izing aliases recreates the baseline-invalidation contradiction. No fatal flaw.' + verdict: pass + valid_until: 2026-09-10T07:00:05.862753+00:00 + carrier_ref: null + formality: 3 + claim_scope: + - adversarial-review + - decision-tier + congruence_level: null + reliability: 0.85 + refine_kind: null + depends_on: [] + rationale: null + spec_ref: feat.s5d.ownership-derivation__20260612.s5d.yaml + prompt: null + next_move: null +decision: null +note_rationale: null +expires_at: null +auto_noted: false diff --git a/.s5d/packages/feat.s5d.ownership-derivation__20260612.s5d.yaml b/.s5d/packages/feat.s5d.ownership-derivation__20260612.s5d.yaml new file mode 100644 index 0000000..2537669 --- /dev/null +++ b/.s5d/packages/feat.s5d.ownership-derivation__20260612.s5d.yaml @@ -0,0 +1,206 @@ +s5d: '1.0' +id: feat.s5d.ownership-derivation +version: 1.0.0 +product: s5d +tier: standard +allow_update: true +meta: + title: ownership-derivation + authors: [] + date: 2026-06-12 + tickets: [] + adrs: [] + renames: [] +context: | + ## Problem + RAN-491: owning_package on global aliases decides what rollback tombstones, + but it sits outside compute_state_fingerprint — corrupting it is invisible to + drift-check (pinned by test owning_package_corruption_is_invisible_to_drift_check). + Rollback logic is also duplicated between CLI (bin/s5d.rs run_rollback) and + MCP (mcp.rs tool_s5d_rollback), so any fix would have to land twice. + + ## Goal + Implement decision.s5d.ownership-derivation winner (derive-ownership-from-ledger): + rollback derives the owner of each global alias from the append-only ledger plus + stored packages at the point of use. Stored owning_package degrades to a verified + cache. Conservative semantics — derivation confirms or vetoes tombstoning, never + expands it; disagreement is a loud warning; underivable falls back to stored field. + Extract one shared lib rollback function used by both CLI and MCP. + + ## Acceptance (GWT) + 1. Given a global alias whose owning_package was tampered to claim spec A while + the ledger derivation attributes it to spec B, when A is rolled back, then the + alias is NOT tombstoned and a warning names both owners. + 2. Given untampered aliases where derivation agrees with the stored field, when + the owning spec is rolled back, then tombstoning proceeds exactly as before + (regression-safe). + 3. Given a legacy global alias with no ledger trace (underivable), when its stored + owner is rolled back, then tombstoning proceeds via stored-field fallback with + a warning, not a hard failure. + 4. Given a global whose derivation attributes it to spec A but stored field names + another spec, when A is rolled back, then rollback reports the suspected tamper + loudly and takes no destructive action on that alias. + + ## DO / DON'T + DO: extract shared rollback into the library; keep referenced_globals guard; keep + ledger/record/index updates byte-compatible; add corruption + fallback tests. + DON'T: change compute_state_fingerprint; add new persistent artifacts; make + rollback fail hard on legacy repos; let derivation tombstone anything the stored + field does not claim. + + ## Privacy + Classification: Internal +workflow: + mode: plan + target_architecture: + summary: Single shared rollback function in the library (import module) with + ledger-derived ownership cross-check; CLI and MCP are thin callers. The + mutable owning_package field is a verified cache, never the sole authority + for destructive actions. + invariants: + - Derivation never expands tombstoning beyond what the stored field claims + - Fingerprint formula unchanged — no baseline invalidation + - Underivable ownership degrades to stored-field trust with a warning + delivery_strategy: + summary: Extract run_rollback/tool_s5d_rollback bodies into one lib fn, add + derive_global_owners over ledger+packages, wire the cross-check, then cover + with corruption/fallback/regression tests in standalone_repos.rs. + rationale: Extraction first — implementing the cross-check twice would recreate + the duplication that made this class of fix expensive. + resources: null + role_map: + implementer: coder + owner: human + verifier: reviewer + reviewer: reviewer + review_policy: + cross_model_required: false + required_on: [] + structure_outline: null + execution_mode: + engine: manual + max_iterations: null + stop_conditions: + - Phase acceptance reached + - Approved scope exhausted + - New decision required + phases: + - id: prototype + title: Prototype + scope: Reduce unknowns before committed implementation + roles: + - owner + - implementer + acceptance: + - Critical feasibility questions answered + - Telemetry approach identified + rollback: + - Discard spike output if assumptions fail + - id: build + title: Build + scope: Implement approved scope and pass local gates + roles: + - implementer + - reviewer + acceptance: + - Tampered owning_package no longer changes rollback outcome (skip + warning, + test-pinned) + - Untampered rollback behavior byte-compatible with before (regression tests + green) + - Underivable ownership falls back to stored field with warning (test-pinned) + - CLI and MCP rollback call one shared lib function + - cargo test green, fmt/clippy clean + rollback: + - Revert or reconcile if gates or review fail + - id: measure + title: Measure + scope: Collect telemetry and close the loop with an explicit verdict + roles: + - owner + - verifier + acceptance: + - Telemetry collected against success criteria + - Outcome verdict recorded + rollback: + - Iterate or kill if evidence stays weak +artifacts: + products: + - id: s5d + name: s5d + organization: null + domains: + - id: dom.s5d.core + product: s5d + name: s5d core domain + classification: supporting + description: Scaffold default — rename or split into real bounded contexts + team: null + maturity_level: null + capabilities: + - id: cap.ownership-derivation + domain: dom.s5d.core + name: Rollback ownership integrity + description: Ledger-derived ownership cross-check protecting rollback tombstoning + from owning_package corruption + since: null + entities: [] + features: + - id: feat.s5d.ownership-derivation + product: s5d + name: ownership-derivation + description: null + use_cases: [] + systems: + - id: sys.s5d + product: s5d + name: s5d + containers: + - id: ctr.s5d.app + system: sys.s5d + name: s5d app + technology: null + components: + - id: comp.ownership-derivation + feature: feat.s5d.ownership-derivation + domain: dom.s5d.core + container: ctr.s5d.app + name: ownership derivation component + paths: + - rust/src/import.rs + - rust/src/mcp.rs + - rust/src/bin/s5d.rs + - rust/tests/standalone_repos.rs + roles: [] + concerns: [] + metrics: [] + supersystems: [] + transports: [] +links: + feature_to_domain: + - feature: feat.s5d.ownership-derivation + domain: dom.s5d.core + use_case_to_capability: [] + use_case_to_entity: [] + component_to_capability: + - component: comp.ownership-derivation + capability: cap.ownership-derivation + component_to_entity: [] + container_to_capability: [] + concern_to_metric: [] + component_to_container: [] + edges: [] + depends_on: [] + entity_relations: [] + capability_to_entity: [] + capability_to_concern: [] +contracts: [] +gates: +- kind: schema +- kind: graph +roc: null +problem: null +hypotheses: [] +decision: null +note_rationale: null +expires_at: null +auto_noted: false diff --git a/.s5d/records/decision.s5d.ownership-derivation__20260612.record.yaml b/.s5d/records/decision.s5d.ownership-derivation__20260612.record.yaml new file mode 100644 index 0000000..bf9260a --- /dev/null +++ b/.s5d/records/decision.s5d.ownership-derivation__20260612.record.yaml @@ -0,0 +1,74 @@ +spec_ref: decision.s5d.ownership-derivation__20260612.s5d.yaml +spec_sha256: sha256:7ea4541fa6237b7ad4631297e64b0835179e1eee516d59a442235f6636fe70cd +status: applied +sync_status: synced +status_history: +- status: proposed + timestamp: 2026-06-12T06:54:55.529419+00:00 +- status: previewed + timestamp: 2026-06-12T07:00:51.440630+00:00 +- status: approved + timestamp: 2026-06-12T07:00:51.459373+00:00 +- status: applied + timestamp: 2026-06-12T07:00:58.046004+00:00 +active_phase: null +phase_history: [] +phase_runs: [] +approvals: +- reviewer: Roman + date: 2026-06-12T07:00:51.459369+00:00 + spec_sha256: sha256:25f8d74f16b8fcc0548f7a8a821f7a5b479a470abd599eb7684b63fe19f06de0 + diff_sha256: sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +preview: + diff_sha256: sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + previewed_spec_sha256: sha256:25f8d74f16b8fcc0548f7a8a821f7a5b479a470abd599eb7684b63fe19f06de0 + actions: + create: 0 + link: 0 + update: 0 + delete: 0 + log: null +reflection: null +gate_results: +- kind: review + status: passed + attempt: 1 + timestamp: 2026-06-12T07:00:57.987319+00:00 + log: |- + review gate passed (built-in): 1 review-evidence with verdict=pass + - hypothesis=derive-ownership-from-ledger evidence_id=20260612-gate-review-derive-ownership-from-ledger formality=3 + exit_code: null + evidence_path: evidence/decision.s5d.ownership-derivation/review_1.log + command: null + duration_ms: 0 + waiver_expires_at: null + kind_class: null +decision: + title: Derive global-alias ownership from ledger at rollback time + winner_id: derive-ownership-from-ledger + rejected_ids: + - versioned-fingerprint + - separate-ownership-checksum + context: 'RAN-491: owning_package sits outside compute_state_fingerprint, so corruption is invisible to drift-check while rollback tombstoning depends on it. Fixing via fingerprint change would invalidate every existing baseline (mass false drift). TRIZ physical contradiction: formula must change and must not change.' + decision: 'Rollback derives the owner of each global alias from the append-only ledger plus stored packages at the point of use. The stored owning_package field degrades to a verified cache: derivation confirms or vetoes tombstoning, never expands it; disagreement is a loud warning; underivable ownership falls back to the stored field (legacy repos keep working). Fingerprint formula untouched. Shared lib extraction removes the CLI/MCP rollback duplication so the cross-check exists once.' + rationale: 'Only option that removes the contradiction instead of balancing it: the mutable field stops being load-bearing, so corrupting it stops being a threat — nothing to detect, no baselines invalidated, no migration day, no second persistent artifact. Probe on the s5d repo itself confirmed derivability (17/17 owners present in ledger).' + consequences: 'Rollback gains a ledger+packages scan (rare operation, cheap). Tamper-to-dodge case (field edited away from true owner) is detected and reported but not auto-tombstoned — operator decides. Pre-ledger aliases degrade to stored-field trust with a warning. CLI and MCP rollback collapse into one lib function. Follow-up: full derived==stored equality check could later run in s5d ci exec as a read-only integrity check.' + decided_at: 2026-06-12T07:00:43.400176+00:00 + confirmed_by: Roman + expires_at: 2026-09-10 + do_list: [] + dont_list: [] + challenge: + mode: standard + passed: true + summary: '5 probes run: ownership-transfer counter-argument refuted by identity.rs (first-creator-wins, renames preserve owner, no transfer API); edited-package tail failure bounded by veto-only semantics (derivation never expands tombstoning, disagreement = skip + loud warning); probe presence-only weakness acknowledged with full-derivation tests required; weakest-link check shows derivation sources sha256-bound/append-only vs unanchored alias field; alternatives D1/D2 carry refine verdicts, merkle-izing aliases recreates the contradiction. No fatal flaw.' + checks: [] + decision_subject: null + decision_subject_granularity: null + evaluative_surface: null + belief_state: null + outcome_model: null + pareto_set: [] + choice_rule: null +verified_by: Diana +drift_tolerance: null diff --git a/.s5d/records/feat.s5d.ownership-derivation__20260612.record.yaml b/.s5d/records/feat.s5d.ownership-derivation__20260612.record.yaml new file mode 100644 index 0000000..ee6635d --- /dev/null +++ b/.s5d/records/feat.s5d.ownership-derivation__20260612.record.yaml @@ -0,0 +1,69 @@ +spec_ref: feat.s5d.ownership-derivation__20260612.s5d.yaml +spec_sha256: sha256:1d0488fcc4da8b6608daa00574203666a222034fe86bd66535c95ed06d58fe8f +status: applied +sync_status: synced +status_history: +- status: proposed + timestamp: 2026-06-12T07:00:22.197224+00:00 +- status: previewed + timestamp: 2026-06-12T07:02:43.018280+00:00 +- status: approved + timestamp: 2026-06-12T07:02:43.036262+00:00 +- status: applied + timestamp: 2026-06-12T07:11:04.210326+00:00 +active_phase: null +phase_history: +- phase_id: build + status: active + timestamp: 2026-06-12T07:11:10.928062+00:00 + reviewer: null + engine: manual + notes: Started work state 'Build' +- phase_id: build + status: accepted + timestamp: 2026-06-12T07:11:10.941356+00:00 + reviewer: Roman + engine: null + notes: Human run evidence acceptance +phase_runs: [] +approvals: +- reviewer: Roman + date: 2026-06-12T07:02:43.036258+00:00 + spec_sha256: sha256:164fbe42e314dab04baa9f2d61dd8395ab644ab62198967f358ba3620bbacb9c + diff_sha256: sha256:734dab44b19e5b8693ae8687f8518785be9d231543ae610c88658993ca3ae2d2 +preview: + diff_sha256: sha256:734dab44b19e5b8693ae8687f8518785be9d231543ae610c88658993ca3ae2d2 + previewed_spec_sha256: sha256:164fbe42e314dab04baa9f2d61dd8395ab644ab62198967f358ba3620bbacb9c + actions: + create: 3 + link: 3 + update: 4 + delete: 0 + log: null +reflection: null +gate_results: +- kind: schema + status: passed + attempt: 1 + timestamp: 2026-06-12T07:11:04.155068+00:00 + log: schema validation passed (built-in) + exit_code: null + evidence_path: evidence/feat.s5d.ownership-derivation/schema_1.log + command: null + duration_ms: 0 + waiver_expires_at: null + kind_class: null +- kind: graph + status: passed + attempt: 1 + timestamp: 2026-06-12T07:11:04.155142+00:00 + log: graph check passed (built-in) + exit_code: null + evidence_path: evidence/feat.s5d.ownership-derivation/graph_1.log + command: null + duration_ms: 0 + waiver_expires_at: null + kind_class: null +decision: null +verified_by: Diana +drift_tolerance: null diff --git a/rust/src/bin/s5d.rs b/rust/src/bin/s5d.rs index 09ded44..4a04cd6 100644 --- a/rust/src/bin/s5d.rs +++ b/rust/src/bin/s5d.rs @@ -3201,77 +3201,36 @@ fn run_rollback(spec_arg: &str) -> anyhow::Result<()> { let (project, _spec_path, spec, spec_filename) = load_spec_context(spec_arg)?; let _lock = project.acquire_lock(&format!("rollback.{}", spec.id))?; - let s5d_dir = project.s5d_dir(); - - // Load ledger and find last successful import for this package - let mut ledger = project.load_ledger()?; - let last_import_idx = ledger - .entries - .iter() - .rposition(|e| e.package_id == spec.id && e.action == "import" && e.status == "success"); + let report = s5d::import::rollback_spec(&project, &spec, &spec_filename)?; - if last_import_idx.is_none() { - anyhow::bail!("no successful import found for {} to roll back", spec.id); + for warning in &report.ownership_mismatches { + eprintln!( + "{} ownership mismatch (possible owning_package corruption), NOT tombstoned: {}", + "warn".yellow(), + warning + ); } - - // Collect global artifact IDs still referenced by other specs - let other_specs = project.discover_specs()?; - let mut referenced_globals = std::collections::HashSet::new(); - for (_, other) in &other_specs { - if other.id != spec.id { - referenced_globals.extend(s5d::import::collect_global_artifact_ids(other)); - } + for warning in &report.suspected_tampers { + eprintln!( + "{} suspected ownership tamper, no action taken: {}", + "warn".yellow(), + warning + ); } - - // Tombstone alias entries for this package - let mut aliases = s5d::AliasTable::load(&s5d_dir)?; - for entry in &mut aliases.packages { - if entry.package_id.as_deref() == Some(&spec.id) && !entry.deprecated { - entry.deprecated = true; - } + for label in &report.ownership_unverifiable { + eprintln!( + "{} ownership unverifiable (package files edited or deleted since import), NOT tombstoned: {}", + "warn".yellow(), + label + ); } - for entry in &mut aliases.global { - if entry.owning_package.as_deref() == Some(&spec.id) && !entry.deprecated { - // Skip if another spec still references this global artifact - let key = (entry.artifact_type.clone(), entry.artifact_id.clone()); - if !referenced_globals.contains(&key) { - entry.deprecated = true; - } - } + for label in &report.underivable_fallbacks { + eprintln!( + "{} no ledger trace for {} — tombstoned via stored owning_package (legacy fallback)", + "warn".yellow(), + label + ); } - aliases.save(&s5d_dir)?; - - // Append rollback ledger entry - ledger.entries.push(s5d::LedgerEntry { - spec_sha256: "rollback".into(), - state_fingerprint: "rollback".into(), - package_id: spec.id.clone(), - action: "rollback".into(), - status: "success".into(), - timestamp: chrono::Utc::now().to_rfc3339(), - record_ref: Some(format!( - "records/{}", - spec_filename.replace(".s5d.yaml", ".record.yaml") - )), - }); - project.save_ledger(&ledger)?; - - // Update record status - let mut record = project - .load_record(&spec_filename)? - .ok_or_else(|| anyhow::anyhow!("no record found for {}", spec_filename))?; - record.status = s5d::SpecStatus::Deprecated; - record.sync_status = s5d::SyncStatus::Unknown; - record.status_history.push(s5d::StatusEntry { - status: s5d::SpecStatus::Deprecated, - timestamp: chrono::Utc::now().to_rfc3339(), - }); - project.save_record(&spec_filename, &record)?; - - // Remove from index - let mut index = project.load_index()?; - index.features.retain(|e| e.id != spec.id); - project.save_index(&index)?; println!("{} Rolled back: {}", "ok".green(), spec.id); Ok(()) diff --git a/rust/src/import.rs b/rust/src/import.rs index 183cca7..118a0e0 100644 --- a/rust/src/import.rs +++ b/rust/src/import.rs @@ -425,3 +425,312 @@ pub fn execute_import( Ok((actions, fingerprint)) } + +/// Per-key outcome of the ledger ownership derivation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DerivedOwnership { + /// The owner is certain: a sha-verified claim exists and every unverifiable + /// ledger event that could have claimed the key first belongs to the same + /// package — whichever import was actually first, the owner is identical. + Owner(String), + /// No verified claim, and exactly one package's unverifiable imports could + /// have created the key. Not proof — the key might equally have no ledger + /// origin — but the only candidate the ledger offers. + UnverifiedCandidate(String), + /// Multiple packages could own the key (a tainted entry from a DIFFERENT + /// package precedes or shadows the verified claim) — nothing destructive + /// may rely on it. + Unverifiable, +} + +/// Derive the owner of every global artifact from the append-only ledger plus the +/// stored packages, replaying the alias-table epoch semantics: +/// +/// - AliasTable::resolve is first-creator-wins **within the current active epoch**: +/// a rollback tombstones the entry, and the next import creates a fresh one with +/// a new owner. The replay therefore releases a package's ownerships when its +/// ledger `rollback` entry is seen. +/// - An import/reconcile entry only contributes claims when the current package +/// file's sha256 matches `LedgerEntry.spec_sha256`. A mismatching or missing file +/// means we cannot know what that import declared — every key unowned at that +/// point becomes poisoned (Unverifiable) until the tainting package is rolled +/// back, because the unknown import may have been the true first creator. +/// +/// Keys absent from the result have no ledger trace at all (legacy aliases). +/// Renames are approximated through the current artifact ids; at worst they +/// degrade to a non-destructive mismatch or Unverifiable, never a wrong tombstone. +pub fn derive_global_owners( + ledger: &Ledger, + specs: &[(std::path::PathBuf, Spec)], + universe: &std::collections::HashSet<(String, String)>, +) -> std::collections::HashMap<(String, String), DerivedOwnership> { + let mut by_id: std::collections::HashMap<&str, (&Spec, String)> = + std::collections::HashMap::new(); + for (path, spec) in specs { + let sha = S5dProject::file_sha256(path).unwrap_or_default(); + by_id.insert(spec.id.as_str(), (spec, sha)); + } + + let mut owners: std::collections::HashMap<(String, String), String> = + std::collections::HashMap::new(); + // key → set of packages whose unverifiable imports might own it + let mut poison: std::collections::HashMap<(String, String), std::collections::HashSet> = + std::collections::HashMap::new(); + + for entry in &ledger.entries { + if entry.status != "success" { + continue; + } + match entry.action.as_str() { + "import" | "reconcile" => { + let verified = by_id + .get(entry.package_id.as_str()) + .filter(|(_, sha)| *sha == entry.spec_sha256) + .map(|(spec, _)| *spec); + match verified { + Some(spec) => { + for key in collect_global_artifact_ids(spec) { + if universe.contains(&key) { + owners + .entry(key) + .or_insert_with(|| entry.package_id.clone()); + } + } + } + None => { + // Unknown claims: poison every key unowned at this point. + for key in universe { + if !owners.contains_key(key) { + poison + .entry(key.clone()) + .or_default() + .insert(entry.package_id.clone()); + } + } + } + } + } + "rollback" => { + owners.retain(|_, owner| owner != &entry.package_id); + for set in poison.values_mut() { + set.remove(&entry.package_id); + } + poison.retain(|_, set| !set.is_empty()); + } + _ => {} + } + } + + let mut result = std::collections::HashMap::new(); + for key in universe { + let poison_set = poison.get(key); + match owners.get(key) { + Some(owner) => { + // Self-poison is harmless: a package's own stale-sha entries + // (normal edit + re-import lifecycle) cannot change WHO owns + // the key — whichever of its imports was first, the owner is + // the same package. Only foreign unknowns break certainty. + let foreign = poison_set.is_some_and(|set| set.iter().any(|p| p != owner)); + if foreign { + result.insert(key.clone(), DerivedOwnership::Unverifiable); + } else { + result.insert(key.clone(), DerivedOwnership::Owner(owner.clone())); + } + } + None => match poison_set { + Some(set) if set.len() == 1 => { + let candidate = set.iter().next().expect("len checked").clone(); + result.insert( + key.clone(), + DerivedOwnership::UnverifiedCandidate(candidate), + ); + } + Some(_) => { + result.insert(key.clone(), DerivedOwnership::Unverifiable); + } + None => {} // no trace — legacy + }, + } + } + result +} + +#[derive(Debug, Default)] +pub struct RollbackReport { + pub spec_id: String, + /// Globals tombstoned ("Type:id"), derivation agreed or artifact was underivable. + pub tombstoned_globals: Vec, + /// Globals kept because another spec still references them. + pub kept_referenced: Vec, + /// Stored field claims this spec but the ledger derivation names another owner — + /// possible owning_package corruption. Skipped, nothing tombstoned. + pub ownership_mismatches: Vec, + /// Ledger derivation attributes the global to this spec but the stored field + /// names someone else — possible tamper to dodge rollback. Reported, no action. + pub suspected_tampers: Vec, + /// Tombstoned via stored-field trust because the ledger has no trace (legacy). + pub underivable_fallbacks: Vec, + /// Skipped because ownership could not be verified — a package file was edited + /// or deleted after import, so the ledger evidence is inconclusive. Tombstoning + /// on inconclusive evidence would let a two-file tamper destroy another spec's + /// global, so the entry is kept and reported. + pub ownership_unverifiable: Vec, +} + +/// Roll back the last successful import of a spec. Ownership of global aliases is +/// cross-checked against the ledger derivation (decision.s5d.ownership-derivation): +/// the derivation confirms or vetoes tombstoning, never expands it. Disagreements +/// surface in the report for the caller to print loudly. +pub fn rollback_spec( + project: &S5dProject, + spec: &Spec, + spec_filename: &str, +) -> anyhow::Result { + let s5d_dir = project.s5d_dir(); + let mut ledger = project.load_ledger()?; + + let has_import = ledger + .entries + .iter() + .any(|e| e.package_id == spec.id && e.action == "import" && e.status == "success"); + if !has_import { + anyhow::bail!("no successful import found for {} to roll back", spec.id); + } + + // Globals still referenced by other LIVE specs are never tombstoned. + // A rolled-back spec (record status Deprecated) whose package file is still + // on disk must not pin globals alive — its claim ended with its rollback, + // matching the epoch semantics of the ownership derivation below. + let all_specs = project.discover_specs()?; + let mut referenced_globals = std::collections::HashSet::new(); + for (path, other) in &all_specs { + if other.id == spec.id { + continue; + } + let other_filename = path + .file_name() + .map(|f| f.to_string_lossy().into_owned()) + .unwrap_or_default(); + let rolled_back = project + .load_record(&other_filename) + .ok() + .flatten() + .is_some_and(|r| r.status == SpecStatus::Deprecated); + if !rolled_back { + referenced_globals.extend(collect_global_artifact_ids(other)); + } + } + + let mut aliases = AliasTable::load(&s5d_dir)?; + + // Derivation is scoped to the keys rollback can actually touch. + let universe: std::collections::HashSet<(String, String)> = aliases + .global + .iter() + .filter(|e| !e.deprecated) + .map(|e| (e.artifact_type.clone(), e.artifact_id.clone())) + .collect(); + let derived_owners = derive_global_owners(&ledger, &all_specs, &universe); + + let mut report = RollbackReport { + spec_id: spec.id.clone(), + ..Default::default() + }; + + for entry in &mut aliases.packages { + if entry.package_id.as_deref() == Some(&spec.id) && !entry.deprecated { + entry.deprecated = true; + } + } + for entry in &mut aliases.global { + if entry.deprecated { + continue; + } + let key = (entry.artifact_type.clone(), entry.artifact_id.clone()); + let label = format!("{}:{}", entry.artifact_type, entry.artifact_id); + let stored_claims_spec = entry.owning_package.as_deref() == Some(&spec.id); + let derived = derived_owners.get(&key); + + if stored_claims_spec { + // Definite-corruption check first — a stored owner the ledger contradicts + // is reported even when the referenced-guard would keep the entry anyway. + if let Some(DerivedOwnership::Owner(other_owner)) = + derived.filter(|d| !matches!(d, DerivedOwnership::Owner(o) if o == &spec.id)) + { + report.ownership_mismatches.push(format!( + "{} — stored owner is {} but ledger derivation says {}", + label, spec.id, other_owner + )); + continue; + } + if referenced_globals.contains(&key) { + report.kept_referenced.push(label); + continue; + } + match derived { + Some(DerivedOwnership::Owner(_)) => { + entry.deprecated = true; + report.tombstoned_globals.push(label); + } + // The only ledger candidate is this spec itself — unverified + // but consistent with the stored field; nothing contradicts. + Some(DerivedOwnership::UnverifiedCandidate(c)) if c == &spec.id => { + entry.deprecated = true; + report.tombstoned_globals.push(label.clone()); + report.underivable_fallbacks.push(label); + } + Some(DerivedOwnership::UnverifiedCandidate(_)) + | Some(DerivedOwnership::Unverifiable) => { + // Inconclusive evidence must not destroy data: a two-file + // tamper (alias field + any package file) would otherwise + // route a foreign global into the fallback tombstone. + report.ownership_unverifiable.push(label); + } + None => { + entry.deprecated = true; + report.tombstoned_globals.push(label.clone()); + report.underivable_fallbacks.push(label); + } + } + } else if matches!(derived, Some(DerivedOwnership::Owner(o)) if o == &spec.id) { + report.suspected_tampers.push(format!( + "{} — ledger derivation says {} owns it but stored owner is {}", + label, + spec.id, + entry.owning_package.as_deref().unwrap_or("") + )); + } + } + aliases.save(&s5d_dir)?; + + ledger.entries.push(LedgerEntry { + spec_sha256: "rollback".into(), + state_fingerprint: "rollback".into(), + package_id: spec.id.clone(), + action: "rollback".into(), + status: "success".into(), + timestamp: Utc::now().to_rfc3339(), + record_ref: Some(format!( + "records/{}", + spec_filename.replace(".s5d.yaml", ".record.yaml") + )), + }); + project.save_ledger(&ledger)?; + + let mut record = project + .load_record(spec_filename)? + .ok_or_else(|| anyhow::anyhow!("no record found for {}", spec_filename))?; + record.status = SpecStatus::Deprecated; + record.sync_status = SyncStatus::Unknown; + record.status_history.push(StatusEntry { + status: SpecStatus::Deprecated, + timestamp: Utc::now().to_rfc3339(), + }); + project.save_record(spec_filename, &record)?; + + let mut index = project.load_index()?; + index.features.retain(|e| e.id != spec.id); + project.save_index(&index)?; + + Ok(report) +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 102b3c4..98ca6cd 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -39,7 +39,10 @@ pub use drift::{check_drift, reconcile, DriftResult}; pub use gates::{effective_gates_for_spec, run_gates}; pub use graph::{check_domain_layering, graph_check, tarjan_scc}; pub use identity::{AliasEntry, AliasTable}; -pub use import::{compute_diff, compute_state_fingerprint, execute_import, DiffActions}; +pub use import::{ + compute_diff, compute_state_fingerprint, derive_global_owners, execute_import, rollback_spec, + DiffActions, RollbackReport, +}; pub use models::*; pub use phase_gates::{ check_add_hypothesis, check_approve, check_challenge, check_decide, check_import, diff --git a/rust/src/mcp.rs b/rust/src/mcp.rs index 8d4dafe..a7813d4 100644 --- a/rust/src/mcp.rs +++ b/rust/src/mcp.rs @@ -2143,73 +2143,34 @@ fn tool_s5d_rollback(args: &Value) -> anyhow::Result { let (project, _spec_path, spec, spec_filename) = load_spec_context_mcp(spec_arg)?; let _lock = project.acquire_lock(&format!("rollback.{}", spec.id))?; - let s5d_dir = project.s5d_dir(); - let mut ledger = project.load_ledger()?; - - let has_import = ledger - .entries - .iter() - .any(|e| e.package_id == spec.id && e.action == "import" && e.status == "success"); + let report = crate::import::rollback_spec(&project, &spec, &spec_filename)?; - if !has_import { - anyhow::bail!("no successful import found for {} to roll back", spec.id); + let mut out = format!("Rolled back: {}", spec.id); + for warning in &report.ownership_mismatches { + out.push_str(&format!( + "\nwarn: ownership mismatch (possible owning_package corruption), NOT tombstoned: {}", + warning + )); } - - // Collect global artifact IDs still referenced by other specs - let other_specs = project.discover_specs()?; - let mut referenced_globals = std::collections::HashSet::new(); - for (_, other) in &other_specs { - if other.id != spec.id { - referenced_globals.extend(crate::import::collect_global_artifact_ids(other)); - } + for warning in &report.suspected_tampers { + out.push_str(&format!( + "\nwarn: suspected ownership tamper, no action taken: {}", + warning + )); } - - let mut aliases = crate::AliasTable::load(&s5d_dir)?; - for entry in &mut aliases.packages { - if entry.package_id.as_deref() == Some(&spec.id) && !entry.deprecated { - entry.deprecated = true; - } + for label in &report.ownership_unverifiable { + out.push_str(&format!( + "\nwarn: ownership unverifiable (package files edited or deleted since import), NOT tombstoned: {}", + label + )); } - for entry in &mut aliases.global { - if entry.owning_package.as_deref() == Some(&spec.id) && !entry.deprecated { - let key = (entry.artifact_type.clone(), entry.artifact_id.clone()); - if !referenced_globals.contains(&key) { - entry.deprecated = true; - } - } + for label in &report.underivable_fallbacks { + out.push_str(&format!( + "\nwarn: no ledger trace for {} — tombstoned via stored owning_package (legacy fallback)", + label + )); } - aliases.save(&s5d_dir)?; - - ledger.entries.push(crate::LedgerEntry { - spec_sha256: "rollback".into(), - state_fingerprint: "rollback".into(), - package_id: spec.id.clone(), - action: "rollback".into(), - status: "success".into(), - timestamp: chrono::Utc::now().to_rfc3339(), - record_ref: Some(format!( - "records/{}", - spec_filename.replace(".s5d.yaml", ".record.yaml") - )), - }); - project.save_ledger(&ledger)?; - - let mut record = project - .load_record(&spec_filename)? - .ok_or_else(|| anyhow::anyhow!("no record found for {}", spec_filename))?; - record.status = crate::SpecStatus::Deprecated; - record.sync_status = crate::SyncStatus::Unknown; - record.status_history.push(crate::StatusEntry { - status: crate::SpecStatus::Deprecated, - timestamp: chrono::Utc::now().to_rfc3339(), - }); - project.save_record(&spec_filename, &record)?; - - let mut index = project.load_index()?; - index.features.retain(|e| e.id != spec.id); - project.save_index(&index)?; - - Ok(format!("Rolled back: {}", spec.id)) + Ok(out) } // ── s5d_drift_check ─────────────────────────────────────────────────────────── diff --git a/rust/tests/standalone_repos.rs b/rust/tests/standalone_repos.rs index e5c7e4b..2bad807 100644 --- a/rust/tests/standalone_repos.rs +++ b/rust/tests/standalone_repos.rs @@ -4727,13 +4727,14 @@ fn bulk_reconcile_reports_failure_in_exit_code() { #[test] fn owning_package_corruption_is_invisible_to_drift_check() { - // KNOWN LIMITATION — tracked as RAN-491 (scenario #13 family): + // BY DESIGN — resolved by decision.s5d.ownership-derivation (RAN-491): // compute_state_fingerprint hashes global aliases as type:id:uuid — - // owning_package is not part of the fingerprint, so its corruption is - // undetectable by drift-check even though rollback semantics depend on - // it. Fixing requires a fingerprint migration (all baselines drift). - // This test documents the current blind spot; when RAN-491 lands it - // must be flipped to assert drift instead. + // owning_package is deliberately outside the fingerprint, because + // including it would invalidate every existing baseline. Instead the + // field is a verified cache: rollback derives ownership from the + // ledger + packages and cross-checks the stored value (see the + // rollback_* ownership tests below). Drift-check staying "synced" + // here is the intended behavior, not a blind spot. let repo = StandaloneRepo::new(); run_ok(repo.path(), ["init"]); let spec_str = setup_standard_spec(&repo, "feat.rec5"); @@ -4762,11 +4763,434 @@ fn owning_package_corruption_is_invisible_to_drift_check() { let outcome = run_ok(repo.path(), ["drift-check", &spec_str]); assert!( outcome.stdout.contains("synced"), - "documents the blind spot — ownership is outside the fingerprint:\n{}", + "ownership is outside the fingerprint by design — rollback owns the check:\n{}", outcome.summary() ); } +// ── Rollback ownership integrity (decision.s5d.ownership-derivation) ───────── + +#[test] +fn rollback_vetoes_tombstoning_when_ledger_contradicts_stored_owner() { + // GWT: a global's owning_package is tampered to claim spec2 while the + // ledger derivation attributes it to spec1 (first importer). Rolling + // back spec2 must NOT tombstone the global and must warn loudly. + let repo = StandaloneRepo::new(); + run_ok(repo.path(), ["init"]); + + let spec1_str = setup_standard_spec(&repo, "feat.owner1"); + run_ok(repo.path(), ["preview", &spec1_str]); + run_ok(repo.path(), ["approve", &spec1_str, "--reviewer", "R"]); + run_ok(repo.path(), ["run-gates", &spec1_str]); + run_ok(repo.path(), ["import", &spec1_str, "--verified-by", "V"]); + + // Second spec in the same repo, sharing the product global. + run_ok( + repo.path(), + [ + "new", + "feat.owner2", + "--tier", + "lightweight", + "--product", + "shop", + ], + ); + let specs_dir = repo.path().join(".s5d").join("packages"); + let spec2_path = fs::read_dir(&specs_dir) + .unwrap() + .map(|e| e.unwrap().path()) + .find(|p| p.to_string_lossy().contains("feat.owner2")) + .expect("spec2 should exist"); + let spec2_str = spec2_path.to_str().unwrap().to_string(); + { + let mut spec: s5d::Spec = load_yaml(&spec2_path); + materialize_scaffold_paths(&mut spec); + fs::write(&spec2_path, serde_yaml::to_string(&spec).unwrap()).unwrap(); + } + run_ok(repo.path(), ["preview", &spec2_str]); + run_ok(repo.path(), ["approve", &spec2_str, "--reviewer", "R"]); + run_ok(repo.path(), ["run-gates", &spec2_str]); + run_ok(repo.path(), ["import", &spec2_str, "--verified-by", "V"]); + + // Tamper: the shared Product global is owned by spec1 (first creator); + // rewrite the stored owner to claim spec2. + let s5d_dir = repo.path().join(".s5d"); + let mut aliases = s5d::AliasTable::load(&s5d_dir).unwrap(); + let entry = aliases + .global + .iter_mut() + .find(|e| { + e.artifact_type == "Product" && e.owning_package.as_deref() == Some("feat.owner1") + }) + .expect("spec1 owns the Product global"); + let tampered_key = (entry.artifact_type.clone(), entry.artifact_id.clone()); + entry.owning_package = Some("feat.owner2".into()); + aliases.save(&s5d_dir).unwrap(); + + let outcome = run_ok(repo.path(), ["rollback", &spec2_str]); + assert!( + outcome.stderr.contains("ownership mismatch"), + "tampered owner must surface as a mismatch warning:\n{}", + outcome.summary() + ); + + let after = s5d::AliasTable::load(&s5d_dir).unwrap(); + let alive = after + .global + .iter() + .any(|e| (e.artifact_type.clone(), e.artifact_id.clone()) == tampered_key && !e.deprecated); + assert!( + alive, + "global with contradicted ownership must NOT be tombstoned:\n{:?}", + after.global + ); +} + +#[test] +fn rollback_reports_suspected_tamper_without_destroying_global() { + // GWT: the ledger derivation attributes a global to the spec being + // rolled back, but the stored field was edited to name someone else + // (dodge attempt). Rollback must report it loudly and take no + // destructive action on that alias. + let repo = StandaloneRepo::new(); + run_ok(repo.path(), ["init"]); + let spec_str = setup_standard_spec(&repo, "feat.dodge"); + + run_ok(repo.path(), ["preview", &spec_str]); + run_ok(repo.path(), ["approve", &spec_str, "--reviewer", "R"]); + run_ok(repo.path(), ["run-gates", &spec_str]); + run_ok(repo.path(), ["import", &spec_str, "--verified-by", "V"]); + + let s5d_dir = repo.path().join(".s5d"); + let mut aliases = s5d::AliasTable::load(&s5d_dir).unwrap(); + let entry = aliases + .global + .iter_mut() + .find(|e| e.owning_package.as_deref() == Some("feat.dodge")) + .expect("imported spec owns at least one global alias"); + let dodged_key = (entry.artifact_type.clone(), entry.artifact_id.clone()); + entry.owning_package = Some("feat.someone.else".into()); + aliases.save(&s5d_dir).unwrap(); + + let outcome = run_ok(repo.path(), ["rollback", &spec_str]); + assert!( + outcome.stderr.contains("suspected ownership tamper"), + "dodge attempt must be reported:\n{}", + outcome.summary() + ); + + let after = s5d::AliasTable::load(&s5d_dir).unwrap(); + let alive = after + .global + .iter() + .any(|e| (e.artifact_type.clone(), e.artifact_id.clone()) == dodged_key && !e.deprecated); + assert!( + alive, + "derivation must never tombstone an alias the stored field does not claim:\n{:?}", + after.global + ); +} + +#[test] +fn rollback_falls_back_to_stored_owner_when_ledger_has_no_trace() { + // GWT: legacy alias with no ledger trace (created before ledger + // discipline or by hand). Rollback of its stored owner proceeds via + // stored-field trust with a warning — never a hard failure. + let repo = StandaloneRepo::new(); + run_ok(repo.path(), ["init"]); + let spec_str = setup_standard_spec(&repo, "feat.legacy"); + + run_ok(repo.path(), ["preview", &spec_str]); + run_ok(repo.path(), ["approve", &spec_str, "--reviewer", "R"]); + run_ok(repo.path(), ["run-gates", &spec_str]); + run_ok(repo.path(), ["import", &spec_str, "--verified-by", "V"]); + + // Hand-append a global alias no spec declares and no ledger entry covers. + let s5d_dir = repo.path().join(".s5d"); + let mut aliases = s5d::AliasTable::load(&s5d_dir).unwrap(); + aliases.global.push(s5d::AliasEntry { + uuid: "11111111-1111-1111-1111-111111111111".into(), + artifact_id: "role.ghost".into(), + artifact_type: "Role".into(), + package_id: None, + owning_package: Some("feat.legacy".into()), + deprecated: false, + }); + aliases.save(&s5d_dir).unwrap(); + + let outcome = run_ok(repo.path(), ["rollback", &spec_str]); + assert!( + outcome.stderr.contains("no ledger trace"), + "legacy fallback must be announced:\n{}", + outcome.summary() + ); + + let after = s5d::AliasTable::load(&s5d_dir).unwrap(); + let ghost = after + .global + .iter() + .find(|e| e.artifact_id == "role.ghost") + .expect("ghost alias still present"); + assert!( + ghost.deprecated, + "underivable alias claimed by the stored field must tombstone via fallback" + ); +} + +#[test] +fn rollback_after_reimport_does_not_false_flag_new_owner() { + // Epoch semantics (tribunal counterexample): A imports global X, A is + // rolled back (X tombstoned), B imports X and legitimately owns the new + // active alias entry. Rolling back B must tombstone X cleanly — the + // derivation must NOT resurrect A as "first ever importer" and veto. + let repo = StandaloneRepo::new(); + run_ok(repo.path(), ["init"]); + + let spec1_str = setup_standard_spec(&repo, "feat.epoch1"); + run_ok(repo.path(), ["preview", &spec1_str]); + run_ok(repo.path(), ["approve", &spec1_str, "--reviewer", "R"]); + run_ok(repo.path(), ["run-gates", &spec1_str]); + run_ok(repo.path(), ["import", &spec1_str, "--verified-by", "V"]); + run_ok(repo.path(), ["rollback", &spec1_str]); + + // Clean up the rolled-back spec entirely (normal user hygiene). Its ledger + // import entry now has no matching file — the replay must neutralize that + // taint via the rollback entry instead of poisoning the key forever. + fs::remove_file(&spec1_str).unwrap(); + + run_ok( + repo.path(), + [ + "new", + "feat.epoch2", + "--tier", + "lightweight", + "--product", + "shop", + ], + ); + let spec2_path = only_spec_path(&repo); + let spec2_str = spec2_path.to_str().unwrap().to_string(); + { + let mut spec: s5d::Spec = load_yaml(&spec2_path); + materialize_scaffold_paths(&mut spec); + fs::write(&spec2_path, serde_yaml::to_string(&spec).unwrap()).unwrap(); + } + run_ok(repo.path(), ["preview", &spec2_str]); + run_ok(repo.path(), ["approve", &spec2_str, "--reviewer", "R"]); + run_ok(repo.path(), ["run-gates", &spec2_str]); + run_ok(repo.path(), ["import", &spec2_str, "--verified-by", "V"]); + + let outcome = run_ok(repo.path(), ["rollback", &spec2_str]); + assert!( + !outcome.stderr.contains("ownership mismatch") + && !outcome.stderr.contains("ownership unverifiable"), + "untampered epoch transition must not be flagged:\n{}", + outcome.summary() + ); + + let s5d_dir = repo.path().join(".s5d"); + let aliases = s5d::AliasTable::load(&s5d_dir).unwrap(); + let shop_active = aliases + .global + .iter() + .any(|e| e.artifact_type == "Product" && e.artifact_id == "shop" && !e.deprecated); + assert!( + !shop_active, + "epoch-2 owner rollback must tombstone the global it legitimately owns:\n{:?}", + aliases.global + ); +} + +#[test] +fn rollback_after_edit_and_reimport_tombstones_own_globals() { + // Normal evolution lifecycle (tribunal round-2 blocker 1): a spec is + // imported, edited, re-approved, re-imported. The FIRST ledger entry's + // sha no longer matches the current file — that self-stale entry must + // not poison the spec's own ownership: whichever of its imports was + // first, the owner is the same package. Rollback must tombstone cleanly. + let repo = StandaloneRepo::new(); + run_ok(repo.path(), ["init"]); + let spec_str = setup_standard_spec(&repo, "feat.evolve"); + + run_ok(repo.path(), ["preview", &spec_str]); + run_ok(repo.path(), ["approve", &spec_str, "--reviewer", "R"]); + run_ok(repo.path(), ["run-gates", &spec_str]); + run_ok(repo.path(), ["import", &spec_str, "--verified-by", "V"]); + + // Edit (version bump) and run the pipeline again — second import entry. + { + let spec_path = PathBuf::from(&spec_str); + let mut spec: s5d::Spec = load_yaml(&spec_path); + spec.version = "1.0.1".into(); + fs::write(&spec_path, serde_yaml::to_string(&spec).unwrap()).unwrap(); + } + run_ok(repo.path(), ["preview", &spec_str]); + run_ok(repo.path(), ["approve", &spec_str, "--reviewer", "R"]); + run_ok(repo.path(), ["run-gates", &spec_str]); + run_ok(repo.path(), ["import", &spec_str, "--verified-by", "V"]); + + let outcome = run_ok(repo.path(), ["rollback", &spec_str]); + assert!( + !outcome.stderr.contains("ownership unverifiable") + && !outcome.stderr.contains("ownership mismatch"), + "self-stale ledger entries must not block the owner's own rollback:\n{}", + outcome.summary() + ); + + let s5d_dir = repo.path().join(".s5d"); + let aliases = s5d::AliasTable::load(&s5d_dir).unwrap(); + let shop_active = aliases + .global + .iter() + .any(|e| e.artifact_type == "Product" && e.artifact_id == "shop" && !e.deprecated); + assert!( + !shop_active, + "evolved spec's rollback must still tombstone its own globals:\n{:?}", + aliases.global + ); +} + +#[test] +fn rollback_epoch_transition_works_with_old_spec_file_left_on_disk() { + // Tribunal round-2 blocker 2: same epoch transition as the test above, + // but the rolled-back spec's package file is NOT deleted. Its record + // says Deprecated, so it must neither pin the global via the + // referenced-globals guard nor distort the derivation. + let repo = StandaloneRepo::new(); + run_ok(repo.path(), ["init"]); + + let spec1_str = setup_standard_spec(&repo, "feat.keep1"); + run_ok(repo.path(), ["preview", &spec1_str]); + run_ok(repo.path(), ["approve", &spec1_str, "--reviewer", "R"]); + run_ok(repo.path(), ["run-gates", &spec1_str]); + run_ok(repo.path(), ["import", &spec1_str, "--verified-by", "V"]); + run_ok(repo.path(), ["rollback", &spec1_str]); + // spec1's file stays on disk — only its record marks it Deprecated. + + run_ok( + repo.path(), + [ + "new", + "feat.keep2", + "--tier", + "lightweight", + "--product", + "shop", + ], + ); + let specs_dir = repo.path().join(".s5d").join("packages"); + let spec2_path = fs::read_dir(&specs_dir) + .unwrap() + .map(|e| e.unwrap().path()) + .find(|p| p.to_string_lossy().contains("feat.keep2")) + .expect("spec2 should exist"); + let spec2_str = spec2_path.to_str().unwrap().to_string(); + { + let mut spec: s5d::Spec = load_yaml(&spec2_path); + materialize_scaffold_paths(&mut spec); + fs::write(&spec2_path, serde_yaml::to_string(&spec).unwrap()).unwrap(); + } + run_ok(repo.path(), ["preview", &spec2_str]); + run_ok(repo.path(), ["approve", &spec2_str, "--reviewer", "R"]); + run_ok(repo.path(), ["run-gates", &spec2_str]); + run_ok(repo.path(), ["import", &spec2_str, "--verified-by", "V"]); + + let outcome = run_ok(repo.path(), ["rollback", &spec2_str]); + assert!( + !outcome.stderr.contains("ownership mismatch") + && !outcome.stderr.contains("ownership unverifiable"), + "epoch transition with the old file kept must not be flagged:\n{}", + outcome.summary() + ); + + let s5d_dir = repo.path().join(".s5d"); + let aliases = s5d::AliasTable::load(&s5d_dir).unwrap(); + let shop_active = aliases + .global + .iter() + .any(|e| e.artifact_type == "Product" && e.artifact_id == "shop" && !e.deprecated); + assert!( + !shop_active, + "rolled-back spec1 must not pin the global alive via referenced-globals:\n{:?}", + aliases.global + ); +} + +#[test] +fn rollback_skips_tombstoning_when_ownership_unverifiable() { + // Sha anchoring (tribunal counterexample): a package file edited after + // import makes its historical claims unknowable. Keys that were unowned + // at that point are poisoned — rollback must neither veto-with-a-named- + // owner nor fall back to a destructive tombstone; it keeps the alias and + // reports the evidence gap. + let repo = StandaloneRepo::new(); + run_ok(repo.path(), ["init"]); + + let spec1_str = setup_standard_spec(&repo, "feat.taint1"); + run_ok(repo.path(), ["preview", &spec1_str]); + run_ok(repo.path(), ["approve", &spec1_str, "--reviewer", "R"]); + run_ok(repo.path(), ["run-gates", &spec1_str]); + run_ok(repo.path(), ["import", &spec1_str, "--verified-by", "V"]); + + // Backdate-edit: change spec1's file AFTER its import (sha now mismatches + // the ledger entry, so its import claims become unknowable). + { + let mut content = fs::read_to_string(&spec1_str).unwrap(); + content.push_str("\n# edited after import\n"); + fs::write(&spec1_str, content).unwrap(); + } + + // Second spec with its own product global, imported after the tainted one. + run_ok( + repo.path(), + [ + "new", + "feat.taint2", + "--tier", + "lightweight", + "--product", + "mart", + ], + ); + let specs_dir = repo.path().join(".s5d").join("packages"); + let spec2_path = fs::read_dir(&specs_dir) + .unwrap() + .map(|e| e.unwrap().path()) + .find(|p| p.to_string_lossy().contains("feat.taint2")) + .expect("spec2 should exist"); + let spec2_str = spec2_path.to_str().unwrap().to_string(); + { + let mut spec: s5d::Spec = load_yaml(&spec2_path); + materialize_scaffold_paths(&mut spec); + fs::write(&spec2_path, serde_yaml::to_string(&spec).unwrap()).unwrap(); + } + run_ok(repo.path(), ["preview", &spec2_str]); + run_ok(repo.path(), ["approve", &spec2_str, "--reviewer", "R"]); + run_ok(repo.path(), ["run-gates", &spec2_str]); + run_ok(repo.path(), ["import", &spec2_str, "--verified-by", "V"]); + + let outcome = run_ok(repo.path(), ["rollback", &spec2_str]); + assert!( + outcome.stderr.contains("ownership unverifiable"), + "evidence gap must be reported:\n{}", + outcome.summary() + ); + + let s5d_dir = repo.path().join(".s5d"); + let aliases = s5d::AliasTable::load(&s5d_dir).unwrap(); + let mart_alive = aliases + .global + .iter() + .any(|e| e.artifact_type == "Product" && e.artifact_id == "mart" && !e.deprecated); + assert!( + mart_alive, + "inconclusive ownership evidence must never tombstone:\n{:?}", + aliases.global + ); +} + #[test] fn reconcile_fails_closed_on_corrupted_uuid() { let repo = StandaloneRepo::new();