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).