fix(case): validate original intent and bind its running receipt
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1468b-63fa-7710-85dc-8f2e16b3ebfa
This commit is contained in:
parent
a45cf475bc
commit
117fec9241
4 changed files with 421 additions and 17 deletions
|
|
@ -125,3 +125,30 @@ is now the tested producer revision. Its evidence module is imported lazily only
|
|||
by `check-evidence`, outside this admission path; Warden nevertheless pins and
|
||||
copies it with the complete reviewed producer set. Missing or modified bytes in
|
||||
any of the four modules refuse admission. The test covers every dependency.
|
||||
|
||||
|
||||
## Original-intent consistency boundary — 2026-10-17
|
||||
|
||||
`OriginalIntent` and the optional additive
|
||||
`reconcile_original(ReconcileContext(original, attempt))` callback retain the
|
||||
original run separately from a fresh reconciliation attempt. Existing one-argument
|
||||
reconcile adapters remain source-compatible. Intent and pre-dispatch receipt are
|
||||
committed in one SQLite transaction; lookup now requires their exact attempt,
|
||||
actor, step/owner/target, source and pin correspondence. Missing legacy context,
|
||||
an orphan receipt, changed context or an invalid bounded field contract refuses;
|
||||
there is no backfill or new runtime holder.
|
||||
|
||||
Original intent serialization accepts only the fixed reviewed Invocation/Pins
|
||||
field contract and bounded operational identifiers. Duplicate, oversized and
|
||||
malformed original records refuse. Current Executor.admit leaves tenant and realm
|
||||
empty: those reserved fields are not admitted tenant/realm provenance, and future
|
||||
nonempty coordinates need an authoritative owner contract. Raw owner payloads,
|
||||
credentials, parameters and caller output are not part of this context contract.
|
||||
|
||||
Local dispatch correspondence is not independent owner proof. A real owner must
|
||||
still authenticate the exact original created-only result and original revocation,
|
||||
current native revision and the fresh attempt's session/grant evidence before
|
||||
recovery can count. No actual original holder/verifier or native adapter is added.
|
||||
NetKingdom's current registration adapter still has no reconciliation callback;
|
||||
source tests do not authorize its post-restart undo, replay or a changed caller/
|
||||
session/run-sheet migration. WARDEN-WP-0044-T03 remains a native acceptance gate.
|
||||
|
|
|
|||
|
|
@ -180,16 +180,76 @@ 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.
|
||||
lane), all pins and the admitted adapter source. Current admit leaves tenant
|
||||
and realm empty; their reserved fields are not admitted provenance. This is
|
||||
local dispatch correspondence, not owner proof of creation. Owners still
|
||||
authenticate original created-only provenance independently.
|
||||
"""
|
||||
|
||||
binding: str
|
||||
invocation: Invocation
|
||||
source: str
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Fixed bounded metadata only; tenant/realm are not authority claims.
|
||||
|
||||
Current admit leaves tenant/realm empty. Nonempty future coordinates need
|
||||
separate owner provenance; serializability alone must never supply it.
|
||||
"""
|
||||
if (
|
||||
type(self.invocation) is not Invocation
|
||||
or type(self.invocation.pins) is not Pins
|
||||
):
|
||||
raise Refused("exact original invocation required")
|
||||
i = self.invocation
|
||||
fields = (i.step, i.owner, i.target, i.principal, i.lane, i.attempt_id)
|
||||
if (
|
||||
not all(
|
||||
type(value) is str and IDENTIFIER.fullmatch(value) for value in fields
|
||||
)
|
||||
or type(i.actor) is not str
|
||||
or i.actor not in ("human", "agent")
|
||||
or type(i.operation) is not str
|
||||
or i.operation not in ("run", "rollback")
|
||||
or not all(
|
||||
type(value) is str and (not value or IDENTIFIER.fullmatch(value))
|
||||
for value in (i.tenant, i.realm)
|
||||
)
|
||||
or type(self.source) is not str
|
||||
or not DIGEST.fullmatch(self.source)
|
||||
or type(self.binding) is not str
|
||||
or self.binding != f"{i.step}:{i.target}"
|
||||
):
|
||||
raise Refused("invalid original intent metadata")
|
||||
if set(Invocation.__dataclass_fields__) != {
|
||||
"step",
|
||||
"owner",
|
||||
"target",
|
||||
"actor",
|
||||
"principal",
|
||||
"lane",
|
||||
"pins",
|
||||
"attempt_id",
|
||||
"operation",
|
||||
"tenant",
|
||||
"realm",
|
||||
}:
|
||||
raise Refused("original invocation field contract changed")
|
||||
if not all(
|
||||
type(v) is str and DIGEST.fullmatch(v)
|
||||
for v in (
|
||||
i.pins.case,
|
||||
i.pins.case_type,
|
||||
i.pins.adapter,
|
||||
i.pins.catalog,
|
||||
i.pins.acceptance,
|
||||
i.pins.run_sheet,
|
||||
)
|
||||
):
|
||||
raise Refused("invalid original intent pins")
|
||||
|
||||
def payload(self) -> str:
|
||||
self.validate()
|
||||
return json.dumps(
|
||||
{"schema": INTENT_SCHEMA, "binding": self.binding, "source": self.source}
|
||||
| asdict(self.invocation),
|
||||
|
|
@ -199,13 +259,44 @@ class OriginalIntent:
|
|||
|
||||
@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"])
|
||||
def unique(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise Refused("duplicate original intent field")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
try:
|
||||
if type(raw) is not str or len(raw.encode()) > MAX_SOURCE_BYTES:
|
||||
raise Refused("original intent input exceeds bound")
|
||||
obj = json.loads(raw, object_pairs_hook=unique)
|
||||
fields = {
|
||||
"step",
|
||||
"owner",
|
||||
"target",
|
||||
"actor",
|
||||
"principal",
|
||||
"lane",
|
||||
"pins",
|
||||
"attempt_id",
|
||||
"operation",
|
||||
"tenant",
|
||||
"realm",
|
||||
}
|
||||
if (
|
||||
type(obj) is not dict
|
||||
or 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"])
|
||||
result = cls(obj["binding"], Invocation(**inv), obj["source"])
|
||||
result.validate()
|
||||
return result
|
||||
except (TypeError, ValueError, KeyError):
|
||||
raise Refused("invalid original intent record") from None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -256,28 +347,76 @@ class Journal:
|
|||
"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:
|
||||
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",
|
||||
"SELECT attempt_id,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])
|
||||
intent = OriginalIntent.decode(row[1])
|
||||
o = intent.invocation
|
||||
if row[0] != o.attempt_id:
|
||||
raise Refused("original intent attempt identity differs")
|
||||
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"):
|
||||
for name in (
|
||||
"step",
|
||||
"owner",
|
||||
"target",
|
||||
"actor",
|
||||
"principal",
|
||||
"lane",
|
||||
"tenant",
|
||||
"realm",
|
||||
):
|
||||
if getattr(o, name) != getattr(current, name):
|
||||
raise Refused(f"original intent {name} mismatch")
|
||||
# Match the persisted original running receipt. An orphan context or a
|
||||
# payload from another attempt cannot become the current lookup key.
|
||||
receipts = self.db.execute(
|
||||
"SELECT payload FROM receipts WHERE binding=? ORDER BY seq DESC LIMIT ?",
|
||||
(binding, MAX_HISTORY + 1),
|
||||
).fetchall()
|
||||
if len(receipts) > MAX_HISTORY:
|
||||
raise Refused("original intent receipt history exceeds bound")
|
||||
matched = False
|
||||
try:
|
||||
for (raw,) in receipts:
|
||||
if type(raw) is not str or len(raw.encode()) > MAX_SOURCE_BYTES:
|
||||
raise Refused("original receipt input exceeds bound")
|
||||
payload = json.loads(raw)
|
||||
if (
|
||||
payload.get("attempt_id") != o.attempt_id
|
||||
or payload.get("operation") != "run"
|
||||
):
|
||||
continue
|
||||
last = payload.get("receipts", [])[-1]
|
||||
if last.get("outcome") != "running":
|
||||
continue
|
||||
if (
|
||||
payload.get("journal_schema") != "warden.case.step-journal.v1"
|
||||
or payload.get("step_id") != o.step
|
||||
or payload.get("owner") != o.owner
|
||||
or payload.get("pins") != asdict(o.pins)
|
||||
):
|
||||
raise Refused("original running receipt binding differs")
|
||||
matched = True
|
||||
break
|
||||
except (TypeError, ValueError, KeyError, IndexError, AttributeError):
|
||||
raise Refused("invalid original running receipt") from None
|
||||
if not matched:
|
||||
raise Refused("original running receipt missing; no backfill")
|
||||
return intent
|
||||
|
||||
@contextmanager
|
||||
|
|
@ -355,6 +494,23 @@ class Journal:
|
|||
invocation: Invocation | None = None,
|
||||
intent: OriginalIntent | None = None,
|
||||
):
|
||||
if intent is not None:
|
||||
if type(intent) is not OriginalIntent:
|
||||
raise Refused("exact original intent required")
|
||||
intent.validate()
|
||||
expected = (
|
||||
Outcome.RUNNING
|
||||
if intent.invocation.operation == "run"
|
||||
else Outcome.ROLLBACK_RUNNING
|
||||
)
|
||||
if (
|
||||
intent.binding != binding
|
||||
or intent.invocation != invocation
|
||||
or intent.invocation.pins != state.pins
|
||||
or not state.last
|
||||
or state.last.outcome is not expected
|
||||
):
|
||||
raise Refused("original intent and running receipt differ")
|
||||
self.db.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
if intent is not None:
|
||||
|
|
|
|||
198
tests/test_case_original_context_consistency.py
Normal file
198
tests/test_case_original_context_consistency.py
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
"""Original-intent consistency checks with generated metadata only.
|
||||
|
||||
Reuse the existing synthetic owner rig. These checks do not provide a native
|
||||
holder or independently authenticate original creation from local SQL/JSON.
|
||||
"""
|
||||
|
||||
from dataclasses import asdict, replace
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import pytest
|
||||
from tests.test_case_run import rig as rig
|
||||
from warden.case_receipt_state import Outcome, Refused
|
||||
from warden.case_run import OriginalIntent
|
||||
|
||||
|
||||
def interrupted(rig):
|
||||
executor, args, calls, sources, kwargs = rig
|
||||
old = executor.adapters[args[0].adapter]
|
||||
seen = []
|
||||
|
||||
def lost(inv):
|
||||
calls.append(inv)
|
||||
raise TimeoutError("generated lost response")
|
||||
|
||||
def original(ctx):
|
||||
seen.append(ctx)
|
||||
return old.reconcile(ctx.attempt)
|
||||
|
||||
executor.adapters[old.reference] = replace(
|
||||
old, invoke=lost, reconcile=None, reconcile_original=original
|
||||
)
|
||||
assert executor.run(*args).last.outcome is Outcome.UNCERTAIN
|
||||
return executor, args, calls, old, seen
|
||||
|
||||
|
||||
def alter_payload(executor, **changes):
|
||||
row = executor.journal.db.execute(
|
||||
"SELECT seq,payload FROM intents ORDER BY seq DESC LIMIT 1"
|
||||
).fetchone()
|
||||
obj = json.loads(row[1])
|
||||
obj.update(changes)
|
||||
executor.journal.db.execute(
|
||||
"UPDATE intents SET payload=? WHERE seq=?", (json.dumps(obj), row[0])
|
||||
)
|
||||
|
||||
|
||||
def test_original_actor_mismatch_is_refused(rig):
|
||||
executor, args, calls, old, _ = interrupted(rig)
|
||||
alter_payload(executor, actor="agent")
|
||||
with pytest.raises(Refused):
|
||||
executor.journal.original_intent(
|
||||
"mint-secret:fixture-client", args[1], old.source
|
||||
)
|
||||
|
||||
|
||||
def test_payload_attempt_id_must_match_sql_column_and_original_receipt(rig):
|
||||
executor, args, calls, old, _ = interrupted(rig)
|
||||
actual = calls[0].attempt_id
|
||||
alter_payload(executor, attempt_id="attempt-unrelated")
|
||||
with pytest.raises(Refused):
|
||||
executor.journal.original_intent(
|
||||
"mint-secret:fixture-client", args[1], old.source
|
||||
)
|
||||
assert (
|
||||
executor.journal.db.execute("SELECT attempt_id FROM intents").fetchone()[0]
|
||||
== actual
|
||||
)
|
||||
|
||||
|
||||
def test_orphan_intent_without_original_running_receipt_is_refused(rig):
|
||||
executor, args, calls, old, _ = interrupted(rig)
|
||||
executor.journal.db.execute(
|
||||
"DELETE FROM receipts WHERE seq=(SELECT MIN(seq) FROM receipts)"
|
||||
)
|
||||
assert (
|
||||
executor.journal.load("mint-secret:fixture-client", args[2]).last.outcome
|
||||
is Outcome.UNCERTAIN
|
||||
)
|
||||
with pytest.raises(Refused):
|
||||
executor.journal.original_intent(
|
||||
"mint-secret:fixture-client", args[1], old.source
|
||||
)
|
||||
|
||||
|
||||
def test_serializer_refuses_arbitrary_values_outside_normal_admit(rig):
|
||||
_, args, _, _, _ = rig
|
||||
inv = replace(
|
||||
args[1],
|
||||
target={"unexpected": "synthetic-owner-payload"},
|
||||
operation="run",
|
||||
attempt_id="attempt-fixture",
|
||||
)
|
||||
record = OriginalIntent("mint-secret:fixture-client", inv, "sha256:" + "1" * 64)
|
||||
with pytest.raises(Refused):
|
||||
OriginalIntent.decode(record.payload())
|
||||
# This is a direct serialization API gap, not an observed normal-admit leak.
|
||||
|
||||
# Owner trust is the existing synthetic fixture; this proves call shape only.
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("principal", "another-person"),
|
||||
("tenant", "another-tenant"),
|
||||
("realm", "another-realm"),
|
||||
],
|
||||
)
|
||||
def test_existing_lookup_does_refuse_stored_principal_tenant_realm_mismatch(
|
||||
rig, field, value
|
||||
):
|
||||
executor, args, _, old, _ = interrupted(rig)
|
||||
alter_payload(executor, **{field: value})
|
||||
with pytest.raises(Refused):
|
||||
executor.journal.original_intent(
|
||||
"mint-secret:fixture-client", args[1], old.source
|
||||
)
|
||||
|
||||
|
||||
def test_existing_atomic_insert_failure_prevents_dispatch_and_both_records(rig):
|
||||
executor, args, calls, _, _ = rig
|
||||
executor.journal.db.execute(
|
||||
"CREATE TRIGGER fail_receipt BEFORE INSERT ON receipts BEGIN SELECT RAISE(ABORT,'fixture'); END"
|
||||
)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
executor.run(*args)
|
||||
assert not calls
|
||||
assert executor.journal.db.execute("SELECT COUNT(*) FROM intents").fetchone() == (
|
||||
0,
|
||||
)
|
||||
assert executor.journal.db.execute("SELECT COUNT(*) FROM receipts").fetchone() == (
|
||||
0,
|
||||
)
|
||||
|
||||
|
||||
def test_existing_missing_original_refuses_before_callback(rig):
|
||||
executor, args, _, _, seen = interrupted(rig)
|
||||
executor.journal.db.execute("DELETE FROM intents")
|
||||
with pytest.raises(Refused):
|
||||
executor.reconcile(*args)
|
||||
assert not seen
|
||||
assert (
|
||||
executor.journal.load("mint-secret:fixture-client", args[2]).last.outcome
|
||||
is Outcome.UNCERTAIN
|
||||
)
|
||||
|
||||
|
||||
def test_existing_current_parent_revocation_remains_mandatory(rig):
|
||||
executor, args, _, _, _ = interrupted(rig)
|
||||
old = executor.evidence_verifier
|
||||
|
||||
def refuse_session(purpose, source, evidence):
|
||||
return (
|
||||
False
|
||||
if purpose.startswith("session-revocation:")
|
||||
else old(purpose, source, evidence)
|
||||
)
|
||||
|
||||
executor.evidence_verifier = refuse_session
|
||||
with pytest.raises(Refused):
|
||||
executor.reconcile(*args)
|
||||
assert (
|
||||
executor.journal.load("mint-secret:fixture-client", args[2]).last.outcome
|
||||
is Outcome.UNCERTAIN
|
||||
)
|
||||
|
||||
|
||||
def test_existing_intent_survives_actual_distinct_interpreter(rig):
|
||||
executor, args, calls, old, _ = interrupted(rig)
|
||||
code = """import json,sys
|
||||
from pathlib import Path
|
||||
from warden.case_run import Journal,Invocation
|
||||
from warden.case_receipt_state import Pins
|
||||
obj=json.load(sys.stdin);obj['pins']=Pins(**obj['pins']);j=Journal(Path(sys.argv[1]))
|
||||
with j.locked():
|
||||
i=j.original_intent('mint-secret:fixture-client',Invocation(**obj),sys.argv[2])
|
||||
print(json.dumps({'attempt':i.invocation.attempt_id,'operation':i.invocation.operation}))
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-B", "-c", code, str(executor.journal.directory), old.source],
|
||||
input=json.dumps(asdict(args[1])),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=15,
|
||||
env={
|
||||
"PATH": "/usr/bin:/bin",
|
||||
"PYTHONPATH": str(Path(__file__).parents[1] / "src"),
|
||||
"PYTHONDONTWRITEBYTECODE": "1",
|
||||
},
|
||||
check=True,
|
||||
)
|
||||
assert json.loads(result.stdout) == {
|
||||
"attempt": calls[0].attempt_id,
|
||||
"operation": "run",
|
||||
}
|
||||
|
|
@ -168,14 +168,37 @@ Invocation — attempt, step, owner, target, actor, principal, lane, all six pin
|
|||
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
|
||||
pins/source/step/owner/target/actor/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
|
||||
it dispatched, not owner created-only provenance. Current Executor.admit leaves
|
||||
tenant/realm empty; the reserved coordinates establish no admitted provenance.
|
||||
Owner descriptors/adapters, contained session integration, Forgejo
|
||||
materialization and the native run remain open.
|
||||
|
||||
2026-10-17 source consistency follow-through: retained the additive intents table
|
||||
and reconcile_original callback and fixed four reproduced local defects: original
|
||||
actor mismatch, payload/SQL attempt-ID mismatch, orphan intent without its
|
||||
matching original running receipt, and arbitrary nonmetadata values accepted by
|
||||
the original-intent serializer. Exact bounded field validation, duplicate-free
|
||||
bounded decode and save-time intent/invocation/running-state equality now preserve
|
||||
the original receipt linkage before owner lookup. No new context store, native
|
||||
verifier, trust root, adapter activation or authority migration was introduced.
|
||||
|
||||
The actual canonical focused kernel and new consistency suite passes 54 checks;
|
||||
owner-installed Ruff check/format passes. The existing generated-data/synthetic
|
||||
owner fixture and a distinct interpreter prove source correspondence/refusal,
|
||||
not independent native custody or creation. Earlier comparison probes and failed
|
||||
preparation are retained. Current caller and fresh session/grant revocation checks
|
||||
remain required; original owner payload/revocation, exact native readback and
|
||||
created-only provenance still need independent authenticated owner admission.
|
||||
No JSON/SQL context, unknown tenant/realm, changed run-sheet or legacy missing
|
||||
intent supplies recovery, replay or undo authority. T03 remains progress and all
|
||||
native acceptance gates/statuses/dependencies/UUIDs remain unchanged. Evidence:
|
||||
Custodian original-context-compare-r2 and original-context-owner-followthrough-r3
|
||||
under docs/changes/netkingdom-target-2026-10-17/; publication/projection is separate.
|
||||
|
||||
Agreed with railiance-platform (RPF-WP-0054-T01, 2026-10-09):
|
||||
- `mint-secret` runs `credential.py generate --grant keycape/client-secret-generate
|
||||
--client-id <id> --case-ref <case-ref> --case-file <case.yaml>` as the caller's
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue