427 lines
16 KiB
Diff
427 lines
16 KiB
Diff
--- a/src/warden/case_run.py
|
|
+++ b/src/warden/case_run.py
|
|
@@ -189,7 +189,66 @@
|
|
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 +258,44 @@
|
|
|
|
@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 +346,76 @@
|
|
"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 +493,23 @@
|
|
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:
|
|
--- /dev/null
|
|
+++ b/tests/test_case_original_context_consistency.py
|
|
@@ -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 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",
|
|
+ }
|