Implement GH-DEC-2026-008: declared PDP-path intent, enforced at issue

Gate House ruled binding.pdp_digest is the binding correspondence on the
GH-DEC-2026-003 path and is required there, having rejected a vocabulary
mapping for the reasons we gave. It asked this engine to record the PDP
digest at issue for approvals intended for that path, and to have the
claim state which approvals those are rather than leaving it to the
requester's memory.

Schema v3 adds approvals.pdp_path. create() refuses pdp_path true without
a pdp_digest, so an approval that would be unusable on the path fails at
issue rather than at the protected side effect. The claim exposes
binding.pdp_path, which makes it a guarantee rather than a hint: pdp_path
true implies pdp_digest is non-null.

Intent is declared and never inferred. A pdp_digest that happens to be
present is not a declaration anybody made, so a recorded digest alone
leaves pdp_path false, legacy rows migrate to false rather than being
back-filled from their digests, and a successor inherits its
predecessor's declaration. Approvals issued before the ruling stay usable
by consumers in this engine's own vocabulary and are simply not usable on
the PDP path -- the ruling's intended cost, stated as such.

Schema, both published examples, a v2-to-v3 migration test asserting
survivors keep their digest while declaring no path intent, and tests for
refusal at issue, claim exposure, non-inference, and successor
inheritance. 102 tests pass (8 new).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvyJPAaVCGsVheVhcCwNND

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 411227@bnt-lap001
Assistant-Session: d566f6d3-bcaf-43c3-bc5e-3ddd0f64b535
This commit is contained in:
tegwick 2026-09-06 14:51:23 +02:00
parent 6d0dfc8010
commit 7e756773de
10 changed files with 166 additions and 11 deletions

View file

@ -123,6 +123,7 @@ class App:
data.get("validity") or {},
int(data.get("required_count") or 1),
pdp_digest=data.get("pdp_digest"),
pdp_path=bool(data.get("pdp_path", False)),
approval_id=data.get("id"),
)
return 201, obj.as_dict()

View file

@ -35,7 +35,7 @@ AUDIT_SCHEMA = "audit-core.event.v1alpha1"
SOURCE = "approval-engine"
SCOPE = "netkingdom-approvals"
EVENT_CLASSES = ("issuance", "use", "supersession", "revocation", "heartbeat")
LATEST_SCHEMA_VERSION = 2
LATEST_SCHEMA_VERSION = 3
SCHEMA = """
CREATE TABLE IF NOT EXISTS approvals (
@ -56,6 +56,7 @@ CREATE TABLE IF NOT EXISTS approvals (
consumed_digest TEXT,
consumed_decision_id TEXT,
consumed_at TEXT,
pdp_path INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@ -109,6 +110,7 @@ class Approval:
binding: dict[str, Any]
binding_digest: str
pdp_digest: str | None
pdp_path: bool
actor: str
principal: str
action: str
@ -132,7 +134,8 @@ class Approval:
"binding": {
**self.binding,
"digest": self.binding_digest,
**({"pdp_digest": self.pdp_digest} if self.pdp_digest else {}),
"pdp_digest": self.pdp_digest,
"pdp_path": self.pdp_path,
},
"validity": {"not_before": self.not_before, "expires_at": self.expires_at},
"required_count": self.required_count,
@ -219,6 +222,14 @@ class Engine:
for name in ("consumed_digest", "consumed_decision_id", "consumed_at"):
if name not in approval_columns:
conn.execute(f"ALTER TABLE approvals ADD COLUMN {name} TEXT")
if "pdp_path" not in approval_columns:
# v3, GH-DEC-2026-008. Legacy rows default to 0: an approval
# issued before the ruling was never declared for the PDP path,
# and inferring intent from a recorded digest would manufacture
# a declaration nobody made.
conn.execute(
"ALTER TABLE approvals ADD COLUMN pdp_path INTEGER NOT NULL DEFAULT 0"
)
outbox_columns = {
row["name"] for row in conn.execute("PRAGMA table_info(outbox)").fetchall()
}
@ -326,13 +337,25 @@ class Engine:
required_count: int = 1,
*,
pdp_digest: str | None = None,
pdp_path: bool = False,
approval_id: str | None = None,
) -> Approval:
if required_count < 1:
raise Unprocessable("required_count must be >= 1")
if not isinstance(pdp_path, bool):
raise Unprocessable("pdp_path must be a boolean")
canon = canonical_binding(binding)
digest = binding_digest(canon)
pdp = require_digest(pdp_digest)
if pdp_path and pdp is None:
# GH-DEC-2026-008: refuse at issue rather than at consume. An
# approval declared for the PDP path without a bound request
# digest is unusable there, and discovering that at the moment of
# the protected side effect is the worst place to find out.
raise Unprocessable(
"pdp_path requires pdp_digest: an approval for the "
"GH-DEC-2026-003 path must bind the PDP request digest at issue"
)
not_before = validity.get("not_before") or iso(self.now())
expires_at = validity.get("expires_at")
if not expires_at:
@ -349,8 +372,8 @@ class Engine:
id, status, binding_json, binding_digest, pdp_digest,
actor, principal, action, purpose, target_json,
not_before, expires_at, required_count, superseded_by,
created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
pdp_path, created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
aid,
"requested",
@ -366,6 +389,7 @@ class Engine:
expires_at,
required_count,
None,
1 if pdp_path else 0,
now,
now,
),
@ -420,6 +444,7 @@ class Engine:
binding=binding,
binding_digest=row["binding_digest"],
pdp_digest=row["pdp_digest"],
pdp_path=bool(row["pdp_path"]),
actor=row["actor"],
principal=row["principal"],
action=row["action"],
@ -552,8 +577,8 @@ class Engine:
id, status, binding_json, binding_digest, pdp_digest,
actor, principal, action, purpose, target_json,
not_before, expires_at, required_count, superseded_by,
created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
pdp_path, created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
successor_id,
"requested",
@ -569,6 +594,7 @@ class Engine:
row["expires_at"],
row["required_count"],
None,
row["pdp_path"],
now,
now,
),
@ -817,6 +843,10 @@ class Engine:
# decision. A missing key reads as an oversight; an explicit null is a
# fact the consumer must act on. See docs/approval-claim.md.
binding["pdp_digest"] = obj.pdp_digest or None
# GH-DEC-2026-008: the claim states whether this approval was declared
# for the PDP path, so a consumer does not infer it from a digest that
# happens to be present.
binding["pdp_path"] = obj.pdp_path
return {
"schema_version": CLAIM_SCHEMA,
"kind": "approval-claim",