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 <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:
parent
d97652a3bb
commit
a45cf475bc
4 changed files with 191 additions and 7 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue