From 7e756773de89fa56d4ec98ddb956c2e6f681f4a8 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 6 Sep 2026 14:51:23 +0200 Subject: [PATCH] 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 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 --- approval_engine/api.py | 1 + approval_engine/store.py | 42 ++++++++++-- docs/approval-claim.md | 18 +++++ docs/storage-operations.md | 13 ++++ examples/claim.revoked.json | 3 +- examples/claim.valid.json | 3 +- schemas/approval_claim.schema.json | 7 +- tests/test_cas.py | 66 ++++++++++++++++++- tests/test_examples.py | 13 ++++ ...duction-readiness-and-consumer-adoption.md | 11 ++++ 10 files changed, 166 insertions(+), 11 deletions(-) diff --git a/approval_engine/api.py b/approval_engine/api.py index ee2ab28..43825a7 100644 --- a/approval_engine/api.py +++ b/approval_engine/api.py @@ -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() diff --git a/approval_engine/store.py b/approval_engine/store.py index 5df4dcf..e77da4f 100644 --- a/approval_engine/store.py +++ b/approval_engine/store.py @@ -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", diff --git a/docs/approval-claim.md b/docs/approval-claim.md index 114a113..cec57ae 100644 --- a/docs/approval-claim.md +++ b/docs/approval-claim.md @@ -122,6 +122,24 @@ was not issued against a PDP decision — a stated fact rather than a missing key, so a consumer cannot read absence as an oversight. It is not required on every approval, because approvals legitimately exist that no decision preceded. +### Declared path intent — `binding.pdp_path` + +`GH-DEC-2026-008` requires `pdp_digest` on the `GH-DEC-2026-003` path. This +engine enforces that **at issue, not at consume**: an approval declared with +`pdp_path: true` and no `pdp_digest` is refused at create. Discovering an +unusable approval at the moment of the protected side effect is the worst place +to find out. + +So `binding.pdp_path` is a guarantee, not a hint: **`pdp_path: true` implies +`pdp_digest` is non-null.** A consumer on that path MUST require `pdp_path` +true, and MUST NOT infer path intent from a `pdp_digest` that merely happens to +be present — a digest recorded for another reason is not a declaration that +anybody made. + +Intent is declared by the requester and never back-filled. Approvals issued +before schema v3 carry `pdp_path: false` regardless of any digest they hold, +and a successor created by supersession inherits its predecessor's declaration. + **A PEP on a privileged lane MUST refuse a claim whose `pdp_digest` is `null`.** Such a claim proves an approval exists; it does not prove the approval was issued against the request now being decided, and no vocabulary diff --git a/docs/storage-operations.md b/docs/storage-operations.md index e8d42e5..775ed41 100644 --- a/docs/storage-operations.md +++ b/docs/storage-operations.md @@ -29,3 +29,16 @@ Restore is a stopped-single-writer operation: Approval mutation and outbox insertion share `BEGIN IMMEDIATE` and one commit; a failed outbox insert rolls the mutation back. Delivery occurs afterward and does not roll back a committed mutation. + +## Schema v3 — declared PDP-path intent + +`GH-DEC-2026-008` added `approvals.pdp_path` (INTEGER NOT NULL DEFAULT 0). +Migration is the usual additive `ALTER TABLE`; run `approval-engine migrate` +before a production start, which refuses an unmigrated store. + +Legacy rows default to `0`. Intent is **not** back-filled from a recorded +`pdp_digest`: an approval issued before the ruling was never declared for the +PDP path, and inferring the declaration from an incidental digest would +manufacture a statement nobody made. Such approvals stay usable by consumers in +this engine's own vocabulary and are simply not usable on the PDP path — which +is the ruling's intended cost, not a migration defect. diff --git a/examples/claim.revoked.json b/examples/claim.revoked.json index 229dc38..b5d7e54 100644 --- a/examples/claim.revoked.json +++ b/examples/claim.revoked.json @@ -17,7 +17,8 @@ "principal": "bernd", "purpose": "rotate-exposed-key", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "pdp_digest": null + "pdp_digest": null, + "pdp_path": false }, "freshness": { "observed_at": "2026-08-29T12:05:00+00:00", diff --git a/examples/claim.valid.json b/examples/claim.valid.json index 89bd363..3852d16 100644 --- a/examples/claim.valid.json +++ b/examples/claim.valid.json @@ -17,7 +17,8 @@ "principal": "bernd", "purpose": "rotate-exposed-key", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "pdp_digest": "sha256:3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f" + "pdp_digest": "sha256:3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f", + "pdp_path": true }, "freshness": { "observed_at": "2026-08-29T12:00:00+00:00", diff --git a/schemas/approval_claim.schema.json b/schemas/approval_claim.schema.json index 990e3ed..d913205 100644 --- a/schemas/approval_claim.schema.json +++ b/schemas/approval_claim.schema.json @@ -113,7 +113,8 @@ "principal", "purpose", "digest", - "pdp_digest" + "pdp_digest", + "pdp_path" ], "properties": { "action": { @@ -147,6 +148,10 @@ ], "pattern": "^sha256:[0-9a-f]{64}$", "description": "The flex-auth NewDecisionBinding request_digest recorded at issue time, or null when the approval was not issued against a PDP decision. Always present so its absence is a stated fact rather than a missing key. When non-null, access-engine MUST compare this to the digest it already computes and MUST NOT re-derive the native digest as a substitute. A PEP on a privileged lane MUST refuse a claim whose pdp_digest is null." + }, + "pdp_path": { + "type": "boolean", + "description": "Whether this approval was declared at issue for the GH-DEC-2026-003 PDP consumption path. GH-DEC-2026-008 requires pdp_digest on that path, so this engine refuses to create a pdp_path approval without one; a true value therefore guarantees pdp_digest is non-null. A consumer on that path MUST require pdp_path true and MUST NOT infer path intent from a pdp_digest that merely happens to be present." } } }, diff --git a/tests/test_cas.py b/tests/test_cas.py index e5c8416..3dce2be 100644 --- a/tests/test_cas.py +++ b/tests/test_cas.py @@ -3,9 +3,11 @@ import threading import sqlite3 from pathlib import Path -from approval_engine.errors import Conflict +import pytest + +from approval_engine.errors import Conflict, Unprocessable from approval_engine.store import Engine -from tests.conftest import FROZEN, approve +from tests.conftest import FROZEN, approve, binding, validity def test_second_supersession_loses(engine): @@ -136,3 +138,63 @@ def test_existing_database_migrates_consumption_columns(): } assert {"consumed_digest", "consumed_decision_id", "consumed_at"} <= columns eng.close() + + +def test_pdp_path_requires_a_digest_at_issue(engine): + """GH-DEC-2026-008: refuse at issue, not at the protected side effect.""" + with pytest.raises(Unprocessable): + engine.create(binding(), validity(), pdp_path=True) + + +def test_pdp_path_approval_states_itself_on_the_claim(engine): + obj = engine.create( + binding(), validity(), pdp_digest="sha256:" + "ab" * 32, pdp_path=True + ) + engine.add_entry(obj.id, "user:alice") + claim = engine.claim(obj.id) + assert claim["binding"]["pdp_path"] is True + assert claim["binding"]["pdp_digest"] == "sha256:" + "ab" * 32 + + +def test_a_recorded_digest_alone_does_not_declare_the_path(engine): + """Intent is declared, never inferred from an incidental digest.""" + obj = engine.create(binding(), validity(), pdp_digest="sha256:" + "cd" * 32) + claim = engine.claim(obj.id) + assert claim["binding"]["pdp_digest"] is not None + assert claim["binding"]["pdp_path"] is False + + +def test_successor_inherits_the_pdp_path_declaration(engine): + obj = engine.create( + binding(), validity(), pdp_digest="sha256:" + "ef" * 32, pdp_path=True + ) + engine.add_entry(obj.id, "user:alice") + result = engine.supersede(obj.id, None) + successor = engine.get(result["successor_id"]) + assert successor.pdp_path is True + + +def test_v2_database_migrates_to_v3_preserving_approvals(): + """Schema v3 (GH-DEC-2026-008) must not disturb approvals issued under v2.""" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "v2.sqlite" + eng = Engine(path, clock=lambda: FROZEN) + obj = eng.create(binding(), validity(), pdp_digest="sha256:" + "12" * 32) + eng.add_entry(obj.id, "user:alice") + # simulate a store written before v3 existed + eng._conn().execute("ALTER TABLE approvals DROP COLUMN pdp_path") + eng._conn().execute("PRAGMA user_version=2") + eng._conn().commit() + eng.close() + + upgraded = Engine(path, clock=lambda: FROZEN) + version = int(upgraded._conn().execute("PRAGMA user_version").fetchone()[0]) + assert version == 3 + survivor = upgraded.get(obj.id) + assert survivor.status == "approved" + assert survivor.pdp_digest == "sha256:" + "12" * 32 + # a legacy row never declared the path; intent is not back-filled from + # a digest that happens to be present + assert survivor.pdp_path is False + assert upgraded.claim(obj.id)["binding"]["pdp_path"] is False + upgraded.close() diff --git a/tests/test_examples.py b/tests/test_examples.py index d61ad6d..59bf33d 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -39,3 +39,16 @@ def test_examples_cover_both_pdp_binding_states(): json.loads(p.read_text())["binding"]["pdp_digest"] is None for p in EXAMPLES } assert states == {True, False} + + +def test_examples_cover_both_pdp_path_declarations(): + states = {json.loads(p.read_text())["binding"]["pdp_path"] for p in EXAMPLES} + assert states == {True, False} + + +@pytest.mark.parametrize("path", EXAMPLES, ids=lambda p: p.name) +def test_pdp_path_examples_always_carry_a_digest(path): + """GH-DEC-2026-008: pdp_path true guarantees pdp_digest non-null.""" + binding = json.loads(path.read_text())["binding"] + if binding["pdp_path"]: + assert binding["pdp_digest"] is not None diff --git a/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md b/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md index d6f609d..6e22875 100644 --- a/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md +++ b/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md @@ -237,3 +237,14 @@ was not issued against a PDP decision, so absence is a stated fact rather than a missing key, and the schema requires it as nullable. A PEP on a privileged lane must refuse a null. Both published examples were contradicting the schema; fixed, and `tests/test_examples.py` now validates every example against it. + +2026-09-06 follow-on (T01/T05): implemented `GH-DEC-2026-008`, which requires +`binding.pdp_digest` on the `GH-DEC-2026-003` path and directed this engine to +record the PDP digest at issue and have the claim state which approvals those +are. Schema v3 adds `approvals.pdp_path`; `create` refuses `pdp_path: true` +without a `pdp_digest`, so the failure lands at issue rather than at the +protected side effect. The claim exposes `binding.pdp_path`, making +`pdp_path: true` a guarantee that `pdp_digest` is non-null. Intent is declared, +never inferred from an incidental digest, and never back-filled: legacy rows +migrate to `false` and a successor inherits its predecessor's declaration. +Schema, both examples, and a v2→v3 migration test cover it (102 tests).