From 5c45d3c1570e6ada64366bf53b5046a3f248ade1 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 1 Nov 2026 00:39:34 +0100 Subject: [PATCH] APPROVAL-WP-0007-T02 done: actor_consumes; T05 principal_approves split out Founder decision 2026-11-01, relayed by the net-kingdom coordinator: "Agree to split APPROVAL-WP-0007-T02: build actor_consumes now (opt-in at issue; the bounded executor will set it); principal_approves keeps waiting for the KeyCape subject mapping." - Optional actor_consumes (boolean) at issue, schema v8. Consume refuses with 403 unless the consuming subject equals binding.actor, also on idempotent retries; internal calls without a consumer fail closed. Inherited on supersession (existing-successor mismatch 409), in audit details, served in GET/claim only when declared so undeclared claims keep exact bytes. Legacy rows migrate without the flag. - GH-DEC-2026-026 Ruling 6: under human_control, add_entry refuses (403) the binding actor as approver. human_control already covered a service actor; this covers a human actor. - Workplan: T02 done (actor_consumes); new T05 principal_approves (wait). - Not released or deployed: needs a v8 image and founder-run rollout. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 5502@bnt-lap001 Assistant-Session: 68b3b02f-5d0f-4e71-8421-796f62e50196 --- approval_engine/api.py | 1 + approval_engine/store.py | 55 ++++- deploy/README.md | 7 +- docs/approval-claim.md | 31 +++ docs/bounded-execution-admission-carrier.md | 32 ++- schemas/approval_claim.schema.json | 4 + tests/test_actor_consumes.py | 190 ++++++++++++++++++ tests/test_human_control.py | 2 +- tests/test_required_assurance.py | 2 +- tests/test_tenant_provenance.py | 2 +- ...ROVAL-WP-0007-bounded-execution-carrier.md | 54 +++-- 11 files changed, 344 insertions(+), 36 deletions(-) create mode 100644 tests/test_actor_consumes.py diff --git a/approval_engine/api.py b/approval_engine/api.py index 3a0f1f1..f025c67 100644 --- a/approval_engine/api.py +++ b/approval_engine/api.py @@ -134,6 +134,7 @@ class App: pdp_path=bool(data.get("pdp_path", False)), human_control=data.get("human_control", False), required_assurance=data.get("required_assurance"), + actor_consumes=data.get("actor_consumes", False), approval_id=data.get("id"), ) return 201, obj.as_dict() diff --git a/approval_engine/store.py b/approval_engine/store.py index 9a8225b..4b5f5ac 100644 --- a/approval_engine/store.py +++ b/approval_engine/store.py @@ -43,7 +43,7 @@ EVENT_CLASSES = ("issuance", "use", "supersession", "revocation", "heartbeat") # class and says nothing about the quiet one — here that is `revocation`, which # is the class whose silence actually matters. HEARTBEAT_CLASSES = ("issuance", "use", "supersession", "revocation") -LATEST_SCHEMA_VERSION = 7 +LATEST_SCHEMA_VERSION = 8 # APPROVAL-WP-0007-T01: authenticator assurance levels, weakest first. A level # outside this list is unknown, and an unknown level never meets a floor. ASSURANCE_LEVELS = ("aal1", "aal2", "aal3") @@ -107,6 +107,7 @@ CREATE TABLE IF NOT EXISTS approvals ( pdp_path INTEGER NOT NULL DEFAULT 0, human_control INTEGER NOT NULL DEFAULT 0 CHECK (human_control IN (0, 1)), required_assurance TEXT CHECK (required_assurance IS NULL OR required_assurance IN ('aal1', 'aal2', 'aal3')), + actor_consumes INTEGER NOT NULL DEFAULT 0 CHECK (actor_consumes IN (0, 1)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -165,6 +166,7 @@ class Approval: pdp_path: bool human_control: bool required_assurance: str | None + actor_consumes: bool actor: str principal: str action: str @@ -203,6 +205,9 @@ class Approval: # APPROVAL-WP-0007-T01. Stated only when declared, so undeclared # objects keep their exact pre-v7 shape for strict readers. result["binding"]["required_assurance"] = {"level": self.required_assurance} + if self.actor_consumes: + # APPROVAL-WP-0007-T02, same rule: stated only when declared. + result["binding"]["actor_consumes"] = True if self.consumed_digest: result["consumption"] = { "request_digest": self.consumed_digest, @@ -305,6 +310,13 @@ class Engine: "ALTER TABLE approvals ADD COLUMN required_assurance TEXT CHECK " "(required_assurance IS NULL OR required_assurance IN ('aal1', 'aal2', 'aal3'))" ) + if "actor_consumes" not in approval_columns: + # v8, APPROVAL-WP-0007-T02. Legacy rows default to 0: nobody + # declared at their issue that only the actor may spend them. + conn.execute( + "ALTER TABLE approvals ADD COLUMN actor_consumes " + "INTEGER NOT NULL DEFAULT 0 CHECK (actor_consumes IN (0, 1))" + ) entry_columns = { row["name"] for row in conn.execute("PRAGMA table_info(entries)").fetchall() } @@ -427,6 +439,7 @@ class Engine: pdp_path: bool = False, human_control: bool = False, required_assurance: dict[str, Any] | None = None, + actor_consumes: bool = False, approval_id: str | None = None, ) -> Approval: if required_count < 1: @@ -435,6 +448,8 @@ class Engine: raise Unprocessable("pdp_path must be a boolean") if not isinstance(human_control, bool): raise Unprocessable("human_control must be a boolean") + if not isinstance(actor_consumes, bool): + raise Unprocessable("actor_consumes must be a boolean") floor = require_assurance_floor(required_assurance) canon = canonical_binding(binding) digest = binding_digest(canon) @@ -464,8 +479,9 @@ class Engine: id, status, binding_json, binding_digest, pdp_digest, actor, principal, action, purpose, target_json, not_before, expires_at, required_count, superseded_by, - pdp_path, human_control, required_assurance, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + pdp_path, human_control, required_assurance, actor_consumes, + created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( aid, "requested", @@ -484,6 +500,7 @@ class Engine: 1 if pdp_path else 0, 1 if human_control else 0, floor, + 1 if actor_consumes else 0, now, now, ), @@ -543,6 +560,7 @@ class Engine: pdp_path=bool(row["pdp_path"]), human_control=bool(row["human_control"]), required_assurance=row["required_assurance"], + actor_consumes=bool(row["actor_consumes"]), actor=row["actor"], principal=row["principal"], action=row["action"], @@ -608,6 +626,12 @@ class Engine: # This is the bind boundary. A service may draft a requested # approval, but cannot contribute evidence for human judgment. raise Forbidden("human_control requires a verified human approver") + if row["human_control"] and subject_id == row["actor"]: + # GH-DEC-2026-026 Ruling 6: the actor is never an approver of + # its own approval. The principal_type check above covers a + # service actor; this covers a human actor, who may create a + # human_control request but must not also approve it. + raise Forbidden("human_control forbids the binding actor approving its own approval") if not entry_meets_floor(assurance, row["required_assurance"]): # The level comes only from the verified token (api.py); a # weaker or missing level cannot count toward this object. @@ -718,13 +742,16 @@ class Engine: conn.rollback() raise Conflict("supersession lost the compare-and-swap") existing = conn.execute( - "SELECT id, human_control, required_assurance FROM approvals WHERE id=?", + "SELECT id, human_control, required_assurance, actor_consumes " + "FROM approvals WHERE id=?", (successor_id,), ).fetchone() if existing is not None and existing["human_control"] != row["human_control"]: raise Conflict("successor must inherit the human_control declaration") if existing is not None and existing["required_assurance"] != row["required_assurance"]: raise Conflict("successor must inherit the required_assurance declaration") + if existing is not None and existing["actor_consumes"] != row["actor_consumes"]: + raise Conflict("successor must inherit the actor_consumes declaration") created_successor = False if existing is None: conn.execute( @@ -732,8 +759,9 @@ class Engine: id, status, binding_json, binding_digest, pdp_digest, actor, principal, action, purpose, target_json, not_before, expires_at, required_count, superseded_by, - pdp_path, human_control, required_assurance, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + pdp_path, human_control, required_assurance, actor_consumes, + created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( successor_id, "requested", @@ -752,6 +780,7 @@ class Engine: row["pdp_path"], row["human_control"], row["required_assurance"], + row["actor_consumes"], now, now, ), @@ -843,6 +872,12 @@ class Engine: if row is None: conn.rollback() raise NotFound(approval_id) + if row["actor_consumes"] and (consumer is None or consumer.subject != row["actor"]): + # APPROVAL-WP-0007-T02. Checked before the idempotent branch so + # another service cannot obtain a consumption receipt either. + # No authenticated consumer (internal call) fails closed. + conn.rollback() + raise Forbidden("actor_consumes requires the consuming subject to equal binding.actor") if row["status"] == "consumed": if row["consumed_digest"] != digest: conn.rollback() @@ -1004,13 +1039,15 @@ class Engine: if approval_id: details["approval_id"] = approval_id row = conn.execute( - "SELECT human_control, required_assurance FROM approvals WHERE id=?", + "SELECT human_control, required_assurance, actor_consumes FROM approvals WHERE id=?", (approval_id,), ).fetchone() if row is not None: details["human_control"] = bool(row["human_control"]) if row["required_assurance"]: details["required_assurance"] = {"level": row["required_assurance"]} + if row["actor_consumes"]: + details["actor_consumes"] = True payload = { "schema_version": AUDIT_SCHEMA, "event_id": event_id, @@ -1061,6 +1098,10 @@ class Engine: # their exact pre-v7 bytes; absence means no floor was declared. if obj.required_assurance: binding["required_assurance"] = {"level": obj.required_assurance} + # APPROVAL-WP-0007-T02: same rule. Absence means any tenant consumer + # holding approval:consume may spend the object. + if obj.actor_consumes: + binding["actor_consumes"] = True return { "schema_version": CLAIM_SCHEMA, "kind": "approval-claim", diff --git a/deploy/README.md b/deploy/README.md index 9a06983..85994f0 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -27,9 +27,10 @@ provenance. The prior v5 image was `sha256:251941a5cb2724b57cc32cff6b693b1ab0be695bee4f56f02d51961189c0fa49`. It refuses a v6 store: rollback requires the matching verified pre-v6 backup and the stopped-single-writer restore procedure in `docs/storage-operations.md`. -Source has since moved to schema v7 (`approvals.required_assurance`, -APPROVAL-WP-0007-T01); the pinned image predates schema v7. Deploying v7 needs a -new release, a verified pre-v7 backup and a founder-run rollout like the v6 one. +Source has since moved to schema v8 (v7 `approvals.required_assurance`, +APPROVAL-WP-0007-T01; v8 `approvals.actor_consumes`, APPROVAL-WP-0007-T02); the +pinned image predates schema v8 (and v7). Deploying needs a new release, a +verified pre-v7 backup and a founder-run rollout like the v6 one. The earlier September v5 release evidence remains historical at [the image evidence](../docs/evidence/2026-09-10-human-control-image.json). diff --git a/docs/approval-claim.md b/docs/approval-claim.md index 3dfa79f..d0afc3e 100644 --- a/docs/approval-claim.md +++ b/docs/approval-claim.md @@ -425,3 +425,34 @@ entry, never a request body: Migration leaves existing rows NULL (no floor). The declaration is separate from the five-field act digest. Whether KeyCape issues `aal2` for a step-up login is KeyCape's question. + +### Declared consumer = actor — `binding.actor_consumes` + +APPROVAL-WP-0007-T02 (actor half), schema v8 store. `POST /v1/approvals` may +declare `"actor_consumes": true` (a JSON boolean; anything else is 422; `false` +is the same as absent). The flag is stored on the object, served in `GET` and +the claim as `binding.actor_consumes: true`, carried on audit events in +`details.actor_consumes`, and inherited on supersession (linking an existing +successor with a different declaration is a 409). + +The member is **present only when declared**, so undeclared objects keep their +exact pre-v8 bytes. Absence means any tenant service or agent holding +`approval:consume` may consume, as before. + +When declared, `POST /consume` refuses with **403** unless the authenticated +consuming subject equals `binding.actor`. The check runs before the idempotent +branch, so another service gets no receipt from a retry either. An internal +call with no authenticated consumer fails closed. The check does not change +`valid_now`: the claim describes the object, not who is reading it. + +Migration leaves existing rows without the flag. The declaration is separate +from the five-field act digest. + +### Actor is never an approver under `human_control` + +GH-DEC-2026-026 Ruling 6. Under `human_control: true`, `POST /entries` refuses +(403) an entry whose verified subject equals `binding.actor`. For a service +actor the existing human-only rule already made this impossible; the check +covers a human actor, who may create the request but not approve it. Objects +without `human_control` are unchanged. Entries persisted before this check +are not re-evaluated by the claim or consume. diff --git a/docs/bounded-execution-admission-carrier.md b/docs/bounded-execution-admission-carrier.md index e0ac17f..f39c006 100644 --- a/docs/bounded-execution-admission-carrier.md +++ b/docs/bounded-execution-admission-carrier.md @@ -40,6 +40,8 @@ unchanged: "validity": {"not_before": "", "expires_at": ""}, "required_count": 1, "human_control": true, + "required_assurance": {"level": "aal2"}, + "actor_consumes": true, "pdp_path": false } ``` @@ -64,7 +66,16 @@ Things the requester must know: - **Declare `human_control: true`.** Then `add_entry` refuses any non-human approver and consume refuses without the required human entries (`Engine.add_entry`, `Engine.consume`). This keeps the service actor from - approving its own request. + approving its own request. GH-DEC-2026-026 Ruling 6 (the actor is never an + approver of its own approval) is therefore already met for this service + actor by `human_control` alone. For a human actor it is not, so `add_entry` + also refuses (403), under `human_control`, an entry whose subject equals + `binding.actor` (`docs/approval-claim.md`). +- **Declare `actor_consumes: true`** (APPROVAL-WP-0007-T02, schema v8, in + source, not yet deployed). Consume then refuses with 403 unless the + consuming subject equals `binding.actor` (see §3). The `required_assurance` + and `actor_consumes` members are accepted only by a v7/v8 image; until one is + live, omit them and keep the executor's own checks. - **`pdp_path: false` for the pilot.** `pdp_path: true` requires a PDP request digest at issue (GH-DEC-2026-008). There is no access-engine decision for executor operations yet, so the pilot is off the PDP path. When access-engine @@ -72,8 +83,10 @@ Things the requester must know: - **`principal` is a bound label, not an approver constraint.** The engine does not check that the approving human equals `binding.principal`. Any human holding `approval:approve` for the tenant can approve. Proposal - APPROVAL-WP-0007-T02 lets an issue declare that correspondence; until then - the executor checks the entry subject itself (see §3). + APPROVAL-WP-0007-T05 (`principal_approves`, split from T02 by founder + decision 2026-11-01) would let an issue declare that correspondence; it + waits for the KeyCape subject mapping. Until then the executor checks the + entry subject itself (see §3). ## 2. Assurance (BEA-C4) @@ -143,11 +156,14 @@ either one. `GET /v1/approvals/{id}` (scope `approval:read`) is the existing authoritative read surface for confirming consumption (`docs/task-execution-consumption-contract.md`, "Existing route and trust -boundary"). The engine does not check whether a digest is equivalent and does -not check that the consuming subject equals `binding.actor`. Any service in -the tenant that holds `approval:consume` could spend the object. That cannot -make anything run, but it can deny the run. Proposal APPROVAL-WP-0007-T02 adds -an opt-in check that the consumer is the actor. The reading we intend for the +boundary"). The engine does not check whether a digest is equivalent. Without a +declaration it does not check that the consuming subject equals +`binding.actor`: any service in the tenant that holds `approval:consume` could +spend the object. That cannot make anything run, but it can deny the run. +**Update (APPROVAL-WP-0007-T02, in source, not yet deployed):** issue with +`"actor_consumes": true` and consume refuses (403) every other subject, +including on idempotent retries; GET and the claim serve +`binding.actor_consumes: true`. The reading we intend for the executor: 1. Before consuming, GET the object and require: `status == "approved"`; the diff --git a/schemas/approval_claim.schema.json b/schemas/approval_claim.schema.json index 1776dfc..8b17359 100644 --- a/schemas/approval_claim.schema.json +++ b/schemas/approval_claim.schema.json @@ -175,6 +175,10 @@ } }, "description": "APPROVAL-WP-0007-T01: minimum authenticator assurance declared at issue (aal1 < aal2 < aal3). Present only when declared; absence means no floor was declared and is never proof of any level. With valid_now true, every entry's verified token assurance.level met it. Inherited on supersession. Separate from the five-field act digest. A consumer that needs a floor must require this member with the expected level; it must not infer one from approver entries." + }, + "actor_consumes": { + "const": true, + "description": "APPROVAL-WP-0007-T02: declared at issue; only a consuming subject equal to binding.actor may consume (others get 403, including idempotent retries). Present only when declared; absence means any tenant service or agent holding approval:consume may consume. Inherited on supersession. Separate from the five-field act digest." } } }, diff --git a/tests/test_actor_consumes.py b/tests/test_actor_consumes.py new file mode 100644 index 0000000..d20d225 --- /dev/null +++ b/tests/test_actor_consumes.py @@ -0,0 +1,190 @@ +"""APPROVAL-WP-0007-T02: opt-in consumer = actor, declared at issue. + +Also GH-DEC-2026-026 Ruling 6: under human_control the actor never approves +its own approval. +""" +import dataclasses +import json +from pathlib import Path + +import jsonschema +import pytest + +from approval_engine.api import App, call +from approval_engine.auth import Identity, StaticTokenAuthenticator +from approval_engine.errors import Conflict, Forbidden, Unprocessable +from approval_engine.store import Engine, LATEST_SCHEMA_VERSION +from tests.conftest import FROZEN, binding, validity + +SCHEMA = json.loads( + (Path(__file__).parents[1] / "schemas" / "approval_claim.schema.json").read_text() +) +ACTOR = "agt-secrets-engine" # binding() default actor + + +def _identity(subject, principal_type="service"): + return Identity( + subject=subject, + issuer="https://keycape.example", + audiences=("approval-engine",), + principal_type=principal_type, + tenant="tenant:platform", + roles=frozenset(), + scopes=frozenset({"approval:create", "approval:read", "approval:approve", + "approval:consume", "approval:supersede"}), + assurance={"level": "aal2", "amr": ["pwd", "otp"], "source": "key-cape"}, + evidence_ref=f"test-identity:{subject}", + ) + + +@pytest.fixture +def multi_app(engine): + return App(engine, StaticTokenAuthenticator({ + "actor": _identity(ACTOR), + "other": _identity("svc-other"), + "human": _identity("user:admin", "human"), + })) + + +def _approved(engine, **kwargs): + obj = engine.create(binding(), validity(), **kwargs) + return engine.add_entry(obj.id, "user:admin", principal_type="human") + + +def test_undeclared_keeps_any_consumer_and_exact_shape(engine): + obj = _approved(engine) + assert obj.actor_consumes is False + assert "actor_consumes" not in obj.as_dict()["binding"] + claim = engine.claim(obj.id) + assert "actor_consumes" not in claim["binding"] + jsonschema.validate(claim, SCHEMA) + result = engine.consume(obj.id, obj.binding_digest, consumer=_identity("svc-other")) + assert result["status"] == "consumed" + + +def test_declared_false_is_the_same_as_undeclared(engine): + obj = _approved(engine, actor_consumes=False) + assert "actor_consumes" not in engine.claim(obj.id)["binding"] + engine.consume(obj.id, obj.binding_digest, consumer=_identity("svc-other")) + + +@pytest.mark.parametrize("value", ["true", 1, None, [], {}]) +def test_declaration_must_be_a_boolean(multi_app, engine, value): + status, _ = call(multi_app, "POST", "/v1/approvals", { + "binding": binding(), "validity": validity(), "actor_consumes": value, + }, authorization="Bearer actor") + assert status == 422 + with pytest.raises(Unprocessable, match="actor_consumes"): + engine.create(binding(), validity(), actor_consumes=value) + + +def test_wrong_consumer_is_refused_and_actor_consumes_over_http(multi_app, engine): + status, created = call(multi_app, "POST", "/v1/approvals", { + "binding": binding(), "validity": validity(), + "human_control": True, "actor_consumes": True, + }, authorization="Bearer actor") + assert status == 201, created + assert created["binding"]["actor_consumes"] is True + status, _ = call(multi_app, "POST", f"/v1/approvals/{created['id']}/entries", {}, + authorization="Bearer human") + assert status == 200 + claim = engine.claim(created["id"]) + assert claim["binding"]["actor_consumes"] is True and claim["valid_now"] + jsonschema.validate(claim, SCHEMA) + + digest = created["binding"]["digest"] + status, refused = call(multi_app, "POST", f"/v1/approvals/{created['id']}/consume", + {"request_digest": digest}, authorization="Bearer other") + assert status == 403, refused + assert engine.get(created["id"]).status == "approved" + + status, spent = call(multi_app, "POST", f"/v1/approvals/{created['id']}/consume", + {"request_digest": digest}, authorization="Bearer actor") + assert status == 200 and spent["status"] == "consumed" and spent["idempotent"] is False + + # Another service gets no receipt from an idempotent retry either. + status, _ = call(multi_app, "POST", f"/v1/approvals/{created['id']}/consume", + {"request_digest": digest}, authorization="Bearer other") + assert status == 403 + status, again = call(multi_app, "POST", f"/v1/approvals/{created['id']}/consume", + {"request_digest": digest}, authorization="Bearer actor") + assert status == 200 and again["idempotent"] is True + + +def test_internal_call_without_consumer_fails_closed(engine): + obj = _approved(engine, actor_consumes=True) + with pytest.raises(Forbidden, match="actor_consumes"): + engine.consume(obj.id, obj.binding_digest) + assert engine.get(obj.id).status == "approved" + assert engine.consume(obj.id, obj.binding_digest, consumer=_identity(ACTOR))["status"] == "consumed" + + +def test_supersession_inherits_and_refuses_existing_mismatch(engine): + parent = engine.create(binding(), validity(), actor_consumes=True) + plain = engine.create(binding(), validity()) + with pytest.raises(Conflict, match="actor_consumes"): + engine.supersede(parent.id, plain.id) + assert engine.get(parent.id).status == "requested" + child = engine.get(engine.supersede(parent.id)["successor_id"]) + assert child.actor_consumes is True + assert engine.claim(child.id)["binding"]["actor_consumes"] is True + engine.add_entry(child.id, "user:admin", principal_type="human") + with pytest.raises(Forbidden): + engine.consume(child.id, child.binding_digest, consumer=_identity("svc-other")) + + +def test_declaration_is_on_audit_events_and_outside_the_act_digest(engine): + plain = engine.create(binding(), validity()) + declared = engine.create(binding(), validity(), actor_consumes=True) + assert plain.binding_digest == declared.binding_digest + engine.add_entry(declared.id, "user:admin", principal_type="human") + event = json.loads(engine._conn().execute( + "SELECT payload_json FROM outbox WHERE approval_id=?", (declared.id,)).fetchone()[0]) + assert event["details"]["actor_consumes"] is True + plain_event_rows = engine._conn().execute( + "SELECT payload_json FROM outbox WHERE approval_id=?", (plain.id,)).fetchall() + assert all("actor_consumes" not in json.loads(r[0])["details"] for r in plain_event_rows) + + +def test_legacy_v7_rows_migrate_without_the_flag(tmp_path): + path = tmp_path / "v7.sqlite" + eng = Engine(path, clock=lambda: FROZEN) + obj = eng.create(binding(), validity()) + eng.add_entry(obj.id, "user:admin", principal_type="human") + eng._conn().execute("ALTER TABLE approvals DROP COLUMN actor_consumes") + eng._conn().execute("PRAGMA user_version=7") + eng._conn().commit() + eng.close() + upgraded = Engine(path, clock=lambda: FROZEN) + survivor = upgraded.get(obj.id) + assert survivor.actor_consumes is False + assert "actor_consumes" not in upgraded.claim(obj.id)["binding"] + assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 8 + upgraded.consume(obj.id, obj.binding_digest, consumer=_identity("svc-other")) + upgraded.close() + + +def test_human_control_refuses_the_actor_as_approver(engine): + """GH-DEC-2026-026 Ruling 6. A human actor may create a human_control + request but may not approve it; another human may.""" + obj = engine.create(binding(actor="user:operator"), validity(), human_control=True) + with pytest.raises(Forbidden, match="actor"): + engine.add_entry(obj.id, "user:operator", principal_type="human") + assert engine.get(obj.id).entries == [] + assert engine.add_entry(obj.id, "user:admin", principal_type="human").status == "approved" + + +def test_actor_may_still_approve_without_human_control(engine): + obj = engine.create(binding(actor="user:operator"), validity()) + assert engine.add_entry(obj.id, "user:operator", principal_type="human").status == "approved" + + +def test_human_actor_self_approval_refused_over_http(engine): + app = App(engine, StaticTokenAuthenticator({"op": _identity("user:operator", "human")})) + status, created = call(app, "POST", "/v1/approvals", { + "binding": binding(actor="user:operator"), "validity": validity(), "human_control": True, + }, authorization="Bearer op") + assert status == 201 + status, refused = call(app, "POST", f"/v1/approvals/{created['id']}/entries", {}, + authorization="Bearer op") + assert status == 403, refused diff --git a/tests/test_human_control.py b/tests/test_human_control.py index 31a8b6c..f6faa87 100644 --- a/tests/test_human_control.py +++ b/tests/test_human_control.py @@ -144,7 +144,7 @@ def test_legacy_v4_rows_are_not_reclassified_from_human_entries(tmp_path): assert survivor.entries[0]["principal_type"] == "human" assert survivor.human_control is False assert upgraded.claim(obj.id)["binding"]["human_control"] is False - assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 7 + assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 8 upgraded.close() diff --git a/tests/test_required_assurance.py b/tests/test_required_assurance.py index 7c67a88..df8319d 100644 --- a/tests/test_required_assurance.py +++ b/tests/test_required_assurance.py @@ -159,5 +159,5 @@ def test_legacy_v6_rows_migrate_to_null_floor(tmp_path): survivor = upgraded.get(obj.id) assert survivor.status == "approved" and survivor.required_assurance is None assert upgraded.claim(obj.id)["valid_now"] is True - assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 7 + assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 8 upgraded.close() diff --git a/tests/test_tenant_provenance.py b/tests/test_tenant_provenance.py index 0355fed..96fd9b9 100644 --- a/tests/test_tenant_provenance.py +++ b/tests/test_tenant_provenance.py @@ -79,7 +79,7 @@ def test_v5_migration_preserves_unknown_provenance_and_existing_evidence(tmp_pat with pytest.raises(StoreUnavailable, match="migrate"): Engine(path, auto_migrate=False) engine = Engine(path, clock=lambda: FROZEN) - assert engine.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 7 + assert engine.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 8 assert engine.get(obj.id).entries[0]["tenant_source"] is None assert engine.get(obj.id).binding_digest == digest assert engine.get(obj.id).human_control is True diff --git a/workplans/APPROVAL-WP-0007-bounded-execution-carrier.md b/workplans/APPROVAL-WP-0007-bounded-execution-carrier.md index 228c288..6cf436d 100644 --- a/workplans/APPROVAL-WP-0007-bounded-execution-carrier.md +++ b/workplans/APPROVAL-WP-0007-bounded-execution-carrier.md @@ -9,7 +9,7 @@ flavor: implementation owner: approval-engine topic_slug: netkingdom created: "2026-10-31" -updated: "2026-10-31" +updated: "2026-11-01" depends_on: [] state_hub_workstream_id: "af365506-f451-50b5-a0a2-f0ba9a0a4d9c" --- @@ -46,30 +46,54 @@ contract in `docs/approval-claim.md`). The member is present only when declared, so undeclared claims keep their exact bytes for strict readers. Not released or deployed: a v7 image and a founder-run rollout are still needed. -## Opt-in approver and consumer correspondence +## Opt-in consumer = actor (`actor_consumes`) ```task id: APPROVAL-WP-0007-T02 -status: wait +status: done priority: medium state_hub_task_id: "9657820d-23f9-54d9-920c-8f2a72110ad0" ``` -Proposal for owner and Gate House agreement before implementation, because -it gives `principal` and `actor` an enforced meaning. Two optional issue -flags: +Optional issue flag `actor_consumes: true` on `POST /v1/approvals`: the +consuming subject must equal `binding.actor`. Closes the denial-of-run gap +(today any tenant service with `approval:consume` can spend the object). Adds +no identity mapping, because the actor is already pinned to the creator's +token subject. Serves GH-DEC-2026-026 build-mode relaxation B2. -- `principal_approves`: every entry subject must equal `binding.principal`. -- `actor_consumes`: the consuming subject must equal `binding.actor`. +Split from the original T02 by founder decision 2026-11-01, relayed by the +net-kingdom coordinator: "Agree to split APPROVAL-WP-0007-T02: build +actor_consumes now (opt-in at issue; the bounded executor will set it); +principal_approves keeps waiting for the KeyCape subject mapping." +`principal_approves` moved to T05. -Owner position 2026-10-31 (`docs/decision-services-alignment-answer.md`): -split it. `actor_consumes` is ready to implement once agreed. `principal_approves` -keeps waiting for the subject mapping and the S5 doctrine. +Done 2026-11-01 in source (schema v8, `tests/test_actor_consumes.py`, +contract in `docs/approval-claim.md`). Stored on the object, served in GET and +the claim only when declared (undeclared claims keep their exact bytes), +carried in audit `details`, inherited on supersession (existing-successor +mismatch is 409). Consume refuses with 403 when the consuming subject differs, +also on idempotent retries; an internal call without a consumer fails closed. +Legacy rows migrate without the flag. Same commit: under `human_control`, +`add_entry` refuses (403) the binding actor as approver (GH-DEC-2026-026 +Ruling 6); human_control alone already covered a service actor, this covers a +human actor. Not released or deployed: a v8 image and a founder-run rollout +are still needed. -Today any human with `approval:approve` can approve, and any tenant service -with `approval:consume` can spend the object (a denial-of-run risk). Wait -reason: semantic decision. NetKingdom's actor and principal strings must also -match KeyCape subjects exactly. +## Opt-in approver = principal (`principal_approves`) + +```task +id: APPROVAL-WP-0007-T05 +status: wait +priority: medium +``` + +Optional issue flag `principal_approves: true`: every entry subject must equal +`binding.principal`. It gives `principal` an enforced meaning, so it waits. +Wait reasons: the KeyCape subject mapping (NetKingdom's principal strings must +equal KeyCape subjects exactly) and the S5 doctrine. Split from T02 by the +founder decision of 2026-11-01 quoted under T02. Until it lands, any human +holding `approval:approve` for the tenant can approve, and the executor checks +the entry subject itself (`docs/bounded-execution-admission-carrier.md` §3). ## Revoker identity in revocation evidence