From a45cf475bc4dbc929e0eecbc1f806a9229d934e2 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 17 Oct 2026 04:32:50 +0200 Subject: [PATCH] WARDEN-WP-0044-T03: original-intent lookup/context for owner reconciliation Durably record the original run Invocation, pins, adapter source and tenant/realm provenance before dispatch; expose Journal.original_intent and Adapter.reconcile_original(ReconcileContext) for NK-WP-0048-T15. Missing or mismatched intent refuses and leaves the step uncertain. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 903097@bnt-lap001 Assistant-Session: aa320c8f-c3fa-46e7-8541-0b58e1e45d23 --- .custodian-brief.md | 7 +- src/warden/case_run.py | 107 +++++++++++++++++- tests/test_case_run.py | 69 +++++++++++ ...EN-WP-0044-security-case-follow-through.md | 15 +++ 4 files changed, 191 insertions(+), 7 deletions(-) diff --git a/.custodian-brief.md b/.custodian-brief.md index 09161f4..34573d2 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,7 +2,7 @@ # Custodian Brief — ops-warden **Domain:** infotech -**Last synced:** 2026-10-11 18:37 UTC +**Last synced:** 2026-10-16 00:05 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams @@ -14,7 +14,7 @@ Progress: 1/4 done | workplan_id: `c8ee441e-1be1-5219-910c-e79ff23cc9ec` - ! Tasks `611f0901` *(wait: Warden's own declaration cannot classify the repair-path target workloads; the owner must declare the continuity actor/target and its z2-continuity resolution (see 2026-09-28 review).)* - ! Tasks `54ad4864` - *(wait: Owners of agt-codex-interhub-bootstrap, agt-state-hub-bridge and atm-backup-daily must publish authoritative workload identity declarations.)* + *(wait: Owners of agt-codex-interhub-bootstrap (ops-hub) and agt-state-hub-bridge (ops-bridge) must publish authoritative workload identity declarations; atm-backup-daily declared by activity-core and compiled 2026-10-09 (z2-protected, admission unsatisfied: M1 below M2 floor).)* - ! Tasks `93eaf1f2` *(wait: Gated on T01 and on layer model v0.8 (or successor) reaching accepted; never convert against a proposed standard.)* @@ -30,6 +30,7 @@ Progress: 2/4 done | workplan_id: `f7e81013-6868-5d99-8528-ba75a442eca9` **Open tasks:** - ! T03 — Run and verify a case `4a0fa4a7` + *(wait: Catalog integration follows a proven run.)* - ► T02 — Plan a case run `bcbf6c91` ### Tamper-resistant credential governance + mass rotation/lockdown (Strand B) @@ -43,7 +44,7 @@ Progress: 2/3 done | workplan_id: `42a097db-1c24-558e-a724-030bb2b4443e` **Open tasks:** - ! Prove routing and publication `a8b1b855` - *(wait: Founder HOLD 2026-09-21: no rotation until SECRETS-WP-0006-T06 moves the publish lane off the legacy whynot-design npm path; then rotate and prove a fresh publish.)* + *(wait: Hold kept by D3 until SECRETS-WP-0006-T06 moves publishing off the legacy whynot-design npm path; then rotate and prove a fresh publish.)* ## Inbox Hygiene diff --git a/src/warden/case_run.py b/src/warden/case_run.py index f16ab2a..331ce11 100644 --- a/src/warden/case_run.py +++ b/src/warden/case_run.py @@ -168,6 +168,52 @@ class Invocation: pins: Pins attempt_id: str = "" operation: str = "admission" + tenant: str = "" + realm: str = "" + + +INTENT_SCHEMA = "warden.case.original-intent.v1" + + +@dataclass(frozen=True, slots=True) +class OriginalIntent: + """Durable record of the original mutating dispatch, written before dispatch. + + Binds the original Invocation (attempt, step, owner, target, actor, principal, + lane), all pins, the admitted adapter source and the tenant/realm provenance + the caller was admitted with. It is evidence of what Warden dispatched, not + owner proof of creation: owners still authenticate created-only provenance. + """ + + binding: str + invocation: Invocation + source: str + + def payload(self) -> str: + return json.dumps( + {"schema": INTENT_SCHEMA, "binding": self.binding, "source": self.source} + | asdict(self.invocation), + separators=(",", ":"), + sort_keys=True, + ) + + @classmethod + def decode(cls, raw: str) -> OriginalIntent: + obj = json.loads(raw) + fields = {f for f in Invocation.__dataclass_fields__} + if set(obj) != fields | {"schema", "binding", "source"} or obj["schema"] != INTENT_SCHEMA: + raise Refused("unknown original intent schema") + inv = {k: obj[k] for k in fields} + inv["pins"] = Pins(**inv["pins"]) + return cls(obj["binding"], Invocation(**inv), obj["source"]) + + +@dataclass(frozen=True, slots=True) +class ReconcileContext: + """Original intent and the new reconciliation attempt, kept separate.""" + + original: OriginalIntent + attempt: Invocation @dataclass(frozen=True, slots=True) @@ -178,6 +224,7 @@ class Adapter: verify: Callable[[Invocation, OwnerResult], bool] rollback: Callable[[Invocation, tuple[str, ...]], OwnerResult] | None = None reconcile: Callable[[Invocation], OwnerResult] | None = None + reconcile_original: Callable[[ReconcileContext], OwnerResult] | None = None class Journal: @@ -205,6 +252,33 @@ class Journal: self.db.execute( "CREATE TABLE IF NOT EXISTS receipts (seq INTEGER PRIMARY KEY, binding TEXT NOT NULL, payload TEXT NOT NULL)" ) + self.db.execute( + "CREATE TABLE IF NOT EXISTS intents (seq INTEGER PRIMARY KEY, attempt_id TEXT NOT NULL UNIQUE, binding TEXT NOT NULL, payload TEXT NOT NULL)" + ) + + def original_intent(self, binding: str, current: Invocation, source: str) -> OriginalIntent: + """Latest original run intent for binding, refused unless it matches exactly. + + Missing or mismatched records refuse; the step stays uncertain. + """ + row = self.db.execute( + "SELECT payload FROM intents WHERE binding=? ORDER BY seq DESC LIMIT 1", + (binding,), + ).fetchone() + if row is None: + raise Refused("original intent record missing; step stays uncertain") + intent = OriginalIntent.decode(row[0]) + o = intent.invocation + if intent.binding != binding or o.operation != "run": + raise Refused("original intent binding mismatch") + if o.pins != current.pins: + raise Refused("original intent pins mismatch") + if intent.source != source: + raise Refused("original intent source mismatch") + for name in ("step", "owner", "target", "principal", "lane", "tenant", "realm"): + if getattr(o, name) != getattr(current, name): + raise Refused(f"original intent {name} mismatch") + return intent @contextmanager def locked(self): @@ -279,9 +353,15 @@ class Journal: state: StepState, evidence: OwnerResult | None = None, invocation: Invocation | None = None, + intent: OriginalIntent | None = None, ): self.db.execute("BEGIN IMMEDIATE") try: + if intent is not None: + self.db.execute( + "INSERT INTO intents(attempt_id,binding,payload) VALUES (?,?,?)", + (intent.invocation.attempt_id, binding, intent.payload()), + ) self.db.execute( "INSERT INTO receipts(binding,payload) VALUES (?,?)", ( @@ -594,7 +674,12 @@ class Executor: raise Refused("bounded journal history requires owner archival") running = begin(state, invocation.pins, retry=retry) dispatched = self._attempt(invocation, running, "run") - self.journal.save(binding, running, invocation=dispatched) + self.journal.save( + binding, + running, + invocation=dispatched, + intent=OriginalIntent(binding, dispatched, adapter.source), + ) evidence = None try: with contained_output(): @@ -626,7 +711,12 @@ class Executor: raise Refused("bounded journal history requires owner archival") running = begin_rollback(state, invocation.pins) dispatched = self._attempt(invocation, running, "rollback") - self.journal.save(binding, running, invocation=dispatched) + self.journal.save( + binding, + running, + invocation=dispatched, + intent=OriginalIntent(binding, dispatched, adapter.source), + ) evidence = None try: with contained_output(): @@ -648,7 +738,7 @@ class Executor: ) -> StepState: """Explicit owner-native state inspection; never redispatches the mutation.""" adapter = self._admission(descriptor, invocation, initial) - if adapter.reconcile is None: + if adapter.reconcile is None and adapter.reconcile_original is None: raise Refused("owner reconciliation adapter absent") with self.journal.locked(): binding = f"{invocation.step}:{invocation.target}" @@ -667,11 +757,20 @@ class Executor: Receipt(len(state.receipts) + 1, Outcome.UNCERTAIN), ), ) + original = ( + self.journal.original_intent(binding, invocation, adapter.source) + if adapter.reconcile_original is not None + else None + ) dispatched = self._attempt(invocation, state, "reconcile") self.journal.save(binding, state, invocation=dispatched) with contained_output(): result = self._result( - adapter, dispatched, adapter.reconcile(dispatched) + adapter, + dispatched, + adapter.reconcile_original(ReconcileContext(original, dispatched)) + if original is not None + else adapter.reconcile(dispatched), ) if result.outcome not in ( Outcome.NO_CHANGE, diff --git a/tests/test_case_run.py b/tests/test_case_run.py index 8125e2c..0032e5e 100644 --- a/tests/test_case_run.py +++ b/tests/test_case_run.py @@ -629,3 +629,72 @@ def test_secret_sensitivity_parameter_cannot_reach_admission_or_journal(rig): ) with pytest.raises(Refused, match="secret-sensitivity"): executor.admit(sources[0], source, sources[2], sources[3], **kwargs) + + +def _lose_result(executor, args, calls): + old = executor.adapters[args[0].adapter] + + def lost(inv): + calls.append(inv) + raise TimeoutError("lost") + + seen = [] + + def by_original(ctx): + seen.append(ctx) + return old.reconcile(ctx.attempt) + + executor.adapters[old.reference] = replace( + old, invoke=lost, reconcile=None, reconcile_original=by_original + ) + assert executor.run(*args).last.outcome is Outcome.UNCERTAIN + return seen + + +def test_original_intent_survives_restart_separate_from_reconcile_attempt(rig): + executor, args, calls, _, _ = rig + seen = _lose_result(executor, args, calls) + executor.journal = Journal(executor.journal.directory) # restart: fresh handle + assert executor.reconcile(*args).last.outcome is Outcome.NO_CHANGE + (ctx,) = seen + original = ctx.original.invocation + assert original.attempt_id == calls[0].attempt_id + assert original.operation == "run" and ctx.attempt.operation == "reconcile" + assert ctx.attempt.attempt_id != original.attempt_id + assert original.pins == args[1].pins + assert (original.principal, original.tenant, original.realm) == ( + args[1].principal, + args[1].tenant, + args[1].realm, + ) + assert ctx.original.source == executor.adapters[args[0].adapter].source + + +def test_missing_original_intent_refuses_and_stays_uncertain(rig): + executor, args, calls, _, _ = rig + seen = _lose_result(executor, args, calls) + executor.journal.db.execute("DELETE FROM intents") + with pytest.raises(Refused, match="original intent record missing"): + executor.reconcile(*args) + assert seen == [] + binding = f"{args[1].step}:{args[1].target}" + assert executor.journal.load(binding, args[2]).last.outcome is Outcome.UNCERTAIN + + +@pytest.mark.parametrize( + "change", [{"tenant": "other-tenant"}, {"realm": "other-realm"}, {"lane": "x"}] +) +def test_original_intent_mismatch_refuses(rig, change): + executor, args, calls, _, _ = rig + seen = _lose_result(executor, args, calls) + with pytest.raises(Refused): + executor.reconcile(args[0], replace(args[1], **change), args[2]) + assert seen == [] + + +def test_original_intent_source_mismatch_refuses(rig): + executor, args, calls, _, _ = rig + _lose_result(executor, args, calls) + binding = f"{args[1].step}:{args[1].target}" + with pytest.raises(Refused, match="source mismatch"): + executor.journal.original_intent(binding, calls[0], "other-source") diff --git a/workplans/WARDEN-WP-0044-security-case-follow-through.md b/workplans/WARDEN-WP-0044-security-case-follow-through.md index 00cfb99..40f1b5b 100644 --- a/workplans/WARDEN-WP-0044-security-case-follow-through.md +++ b/workplans/WARDEN-WP-0044-security-case-follow-through.md @@ -161,6 +161,21 @@ reverses exactly this case's steps. Acceptance: the Forgejo reference case (NK-WP-0048-T06) reaches `live` through this path, with no case-specific script. +2026-10-17: original-intent lookup/context interface for NK-WP-0048-T15, source +and synthetic tests only. `src/warden/case_run.py`: `Executor.run` writes an +`OriginalIntent` (schema `warden.case.original-intent.v1`: the dispatched run +Invocation — attempt, step, owner, target, actor, principal, lane, all six pins, +tenant, realm — plus admitted adapter source) in the same SQLite transaction as +the pre-dispatch journal row. `Journal.original_intent(binding, current, source)` +returns it after restart and refuses on a missing record or any +pins/source/step/owner/target/principal/lane/tenant/realm mismatch, before any +journal write, so the step stays uncertain. An adapter's +`reconcile_original(ReconcileContext(original, attempt))` receives the original +intent separately from the new reconcile attempt. This is Warden's record of what +it dispatched, not owner created-only provenance; tenant/realm are as supplied at +admission. Owner descriptors/adapters, contained session integration, Forgejo +materialization and the native run remain open. + Agreed with railiance-platform (RPF-WP-0054-T01, 2026-10-09): - `mint-secret` runs `credential.py generate --grant keycape/client-secret-generate --client-id --case-ref --case-file ` as the caller's