WARDEN-WP-0044-T03: original-intent lookup/context for owner reconciliation
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

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 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 903097@bnt-lap001
Assistant-Session: aa320c8f-c3fa-46e7-8541-0b58e1e45d23
This commit is contained in:
tegwick 2026-10-17 04:32:50 +02:00
parent d97652a3bb
commit a45cf475bc
4 changed files with 191 additions and 7 deletions

View file

@ -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,