diff --git a/approval_engine/api.py b/approval_engine/api.py index 98cc724..f3f94f4 100644 --- a/approval_engine/api.py +++ b/approval_engine/api.py @@ -124,6 +124,7 @@ class App: int(data.get("required_count") or 1), pdp_digest=data.get("pdp_digest"), pdp_path=bool(data.get("pdp_path", False)), + human_control=data.get("human_control", False), approval_id=data.get("id"), ) return 201, obj.as_dict() diff --git a/approval_engine/store.py b/approval_engine/store.py index 4aac8f5..5172bb3 100644 --- a/approval_engine/store.py +++ b/approval_engine/store.py @@ -22,6 +22,7 @@ from .binding import binding_digest, canonical_binding, require_digest from .errors import ( Conflict, DuplicateApprover, + Forbidden, NotFound, StoreUnavailable, Unprocessable, @@ -35,7 +36,7 @@ AUDIT_SCHEMA = "audit-core.event.v1alpha1" SOURCE = "approval-engine" SCOPE = "netkingdom-approvals" EVENT_CLASSES = ("issuance", "use", "supersession", "revocation", "heartbeat") -LATEST_SCHEMA_VERSION = 4 +LATEST_SCHEMA_VERSION = 5 SCHEMA = """ CREATE TABLE IF NOT EXISTS approvals ( @@ -57,6 +58,7 @@ CREATE TABLE IF NOT EXISTS approvals ( consumed_decision_id TEXT, consumed_at TEXT, pdp_path INTEGER NOT NULL DEFAULT 0, + human_control INTEGER NOT NULL DEFAULT 0 CHECK (human_control IN (0, 1)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -112,6 +114,7 @@ class Approval: binding_digest: str pdp_digest: str | None pdp_path: bool + human_control: bool actor: str principal: str action: str @@ -137,6 +140,7 @@ class Approval: "digest": self.binding_digest, "pdp_digest": self.pdp_digest, "pdp_path": self.pdp_path, + "human_control": self.human_control, }, "validity": {"not_before": self.not_before, "expires_at": self.expires_at}, "required_count": self.required_count, @@ -231,6 +235,14 @@ class Engine: conn.execute( "ALTER TABLE approvals ADD COLUMN pdp_path INTEGER NOT NULL DEFAULT 0" ) + if "human_control" not in approval_columns: + # v5, GH-DEC-2026-016. Existing human entries are evidence of + # their authors, not a declaration that the object discharged + # a human control. Never infer that declaration on migration. + conn.execute( + "ALTER TABLE approvals ADD COLUMN human_control " + "INTEGER NOT NULL DEFAULT 0 CHECK (human_control IN (0, 1))" + ) entry_columns = { row["name"] for row in conn.execute("PRAGMA table_info(entries)").fetchall() } @@ -348,12 +360,15 @@ class Engine: *, pdp_digest: str | None = None, pdp_path: bool = False, + human_control: 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") + if not isinstance(human_control, bool): + raise Unprocessable("human_control must be a boolean") canon = canonical_binding(binding) digest = binding_digest(canon) pdp = require_digest(pdp_digest) @@ -382,8 +397,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, - pdp_path, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + pdp_path, human_control, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( aid, "requested", @@ -400,6 +415,7 @@ class Engine: required_count, None, 1 if pdp_path else 0, + 1 if human_control else 0, now, now, ), @@ -456,6 +472,7 @@ class Engine: binding_digest=row["binding_digest"], pdp_digest=row["pdp_digest"], pdp_path=bool(row["pdp_path"]), + human_control=bool(row["human_control"]), actor=row["actor"], principal=row["principal"], action=row["action"], @@ -511,6 +528,10 @@ class Engine: if row["status"] not in MUTABLE: conn.rollback() raise Conflict(f"cannot add entries in status {row['status']}") + if row["human_control"] and principal_type != "human": + # 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") try: conn.execute( "INSERT INTO entries " @@ -582,7 +603,11 @@ class Engine: if cur.rowcount != 1: conn.rollback() raise Conflict("supersession lost the compare-and-swap") - existing = conn.execute("SELECT id FROM approvals WHERE id=?", (successor_id,)).fetchone() + existing = conn.execute( + "SELECT id, human_control 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") created_successor = False if existing is None: conn.execute( @@ -590,8 +615,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, - pdp_path, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + pdp_path, human_control, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( successor_id, "requested", @@ -608,6 +633,7 @@ class Engine: row["required_count"], None, row["pdp_path"], + row["human_control"], now, now, ), @@ -720,6 +746,14 @@ class Engine: if self.now() < parse_iso(row["not_before"]) or self.now() >= parse_iso(row["expires_at"]): conn.rollback() raise Conflict("cannot consume outside validity window") + if row["human_control"]: + counts = conn.execute( + "SELECT COUNT(*) AS total, " + "SUM(CASE WHEN principal_type='human' THEN 1 ELSE 0 END) AS humans " + "FROM entries WHERE approval_id=?", (approval_id,) + ).fetchone() + if counts["total"] < row["required_count"] or counts["humans"] != counts["total"]: + raise Conflict("cannot consume without the required human evidence") cur = conn.execute( "UPDATE approvals SET status='consumed', consumed_digest=?, " "consumed_decision_id=?, consumed_at=?, updated_at=? " @@ -823,6 +857,11 @@ class Engine: details = {"class": event_class, **(extra or {})} if approval_id: details["approval_id"] = approval_id + row = conn.execute( + "SELECT human_control FROM approvals WHERE id=?", (approval_id,) + ).fetchone() + if row is not None: + details["human_control"] = bool(row["human_control"]) payload = { "schema_version": AUDIT_SCHEMA, "event_id": event_id, @@ -861,6 +900,7 @@ class Engine: # 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 + binding["human_control"] = obj.human_control return { "schema_version": CLAIM_SCHEMA, "kind": "approval-claim", @@ -895,6 +935,8 @@ class Engine: if obj.status == "approved": if now < parse_iso(obj.not_before): return "approved", False, False, "not_yet_valid" + if obj.human_control and any(e["principal_type"] != "human" for e in obj.entries): + return "approved", False, False, "human_control_unsatisfied" if len({e["subject_id"] for e in obj.entries}) < obj.required_count: return "approved", False, False, "insufficient_approvers" return "valid", True, False, "ok" diff --git a/deploy/README.md b/deploy/README.md index 12a3d17..a512cbd 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -13,12 +13,12 @@ pinned at `b51d174`. There is no placeholder left to replace. Both references MUST stay identical and MUST stay digests — a tag here would let the init container and the server run different code against one database. -**The pinned image predates schema v4.** The published artifact recorded in +**The pinned image predates schema v5.** The published artifact recorded in [`docs/image-scan-2026-09-06.md`](../docs/image-scan-2026-09-06.md) carries -`LATEST_SCHEMA_VERSION = 3`; this repository is now at 4 -(`entries.principal_type`, see [storage-operations.md](../docs/storage-operations.md)). +`LATEST_SCHEMA_VERSION = 3`; this repository is now at 5 +(`entries.principal_type` plus `approvals.human_control`, see [storage-operations.md](../docs/storage-operations.md)). The pinned pair is self-consistent — that image migrates to 3 and serves 3 — so -nothing is broken by leaving it pinned, but a rollout that must carry approver +nothing is broken by leaving it pinned, but a rollout that must enforce declared human controls or carry approver principal-type evidence requires cutting a new image at step 3 below. The `migrate` init container then performs the additive upgrade on the existing volume; `tests/test_deploy_manifest.py` holds this acknowledgement so the drift diff --git a/docs/approval-claim.md b/docs/approval-claim.md index 38787fa..f28cc74 100644 --- a/docs/approval-claim.md +++ b/docs/approval-claim.md @@ -347,3 +347,26 @@ false, and the example set is what would have taught them — the failure `tests/test_examples.py` asserts the decorrelation, not merely that both values appear somewhere. + + +### Declared human judgment — `binding.human_control` + +New producers always state this boolean. `true` records that the requester +explicitly declared a human-in-the-loop or dual-control requirement at issue; +`valid_now: true` then also requires the declared count of distinct verified +human approvers. Non-human binds are refused before insertion. Undeclared objects +remain useful for service approvals. Historical absence is undeclared, never +proof of human judgment; a consumer needing the property must require exactly +true, rather than infer it from an approver's name or from a valid generic claim. + +The declaration stays separate from the five act fields and does not change the +native binding digest. It is inherited on supersession, emitted on audit events, +and cannot be downgraded by linking an existing successor. The additive claim +property stays within schema 0.1; old consumers that do not require this property +retain their existing behavior. Consumer adoption of the new requirement and +native issuer/deployment proof remain necessary before the factory human path. + +`claim.valid-human-control.json` shows a valid declared human control; +`claim.valid.json` shows a valid ordinary approval. For an inconsistent persisted +human-control object, `valid_now` is false with `human_control_unsatisfied` and +consume refuses. Neither the declaration nor a claim is an authorization verdict. diff --git a/docs/approver-surface-requirements.md b/docs/approver-surface-requirements.md index c41bd73..78f3736 100644 --- a/docs/approver-surface-requirements.md +++ b/docs/approver-surface-requirements.md @@ -53,22 +53,21 @@ the deployed issuer's `/jwks` (RS256) and carry: The **access** token is what we validate. `id_token` appears nowhere in this codebase; the ID token belongs to the login client and is never evidence here. -**Correction to our 2026-09-09 message.** We said an agent token cannot hold -`approval:approve` under the requested registrations. That is wrong twice: the -`approval-engine-operator` service client holds `approval:approve`, and this -engine does not restrict `/entries` by principal type at all — only `/consume` -is restricted, to `service`/`agent`. So a non-human principal can supply -approver evidence today. +**GH-DEC-2026-016 is now implemented in the source candidate.** An approval +explicitly declared with `human_control: true` refuses service/agent binds at +`/entries`, irrespective of what the caller submits in the body. The verified +human identity supplies the entry. The UI retains its own humans-bind-agents-draft +rule and must require the declared human-control object for that workflow; an +undeclared historical approval must not be relabelled from its human entries. +Request a new declared object when needed. The requester may be a service; it +creates an unapproved request and cannot supply human judgment. -Whether it *should* be able to is approval doctrine and belongs to -`gate-house`, not to us and not to you. What we have done instead is make it -visible: schema v4 records the verified `principal_type` on every entry -(`tests/test_auth.py::test_entry_records_the_verified_principal_type`, -`::test_non_human_approver_is_recorded_as_such`). Your design principle 10 -("humans bind, agents draft") is therefore enforceable in the evidence chain -rather than assumed — read `entries[].principal_type` and do not infer the -answer from the shape of `subject_id`. Entries written before v4 are `null` and -must not be read as `human`. +The engine still admits service-to-service approvals when no human control was +declared. The flag neither grants a scope nor identifies which acts require a +human; those remain owner doctrine. See [caller-authentication.md](caller-authentication.md) +for the issuer's human-type provenance and remaining native acceptance. The +source candidate requires schema v5 and a new image; the current production +manifest is not evidence that this enforcement is deployed. ## 3. Open question A — the human client cannot read the approval it renders diff --git a/docs/caller-authentication.md b/docs/caller-authentication.md index f7a5d03..9dd8d3d 100644 --- a/docs/caller-authentication.md +++ b/docs/caller-authentication.md @@ -47,15 +47,37 @@ Create additionally requires `binding.actor == sub`. Approval-entry subject, assurance, evidence reference, and **principal type** are derived from the verified JWT, never the request body. -`principal_type` is recorded on the entry (schema v4) because `subject_id` -alone cannot answer what kind of principal bound the approval — `user:alice` is -a naming convention, not a verified claim. This engine does not restrict -`/entries` to human principals: whether a service or agent may supply approver -evidence is approval doctrine and belongs to `gate-house`, and the -`approval-engine-operator` registration holds `approval:approve` today. What -this engine owes is that the evidence chain says which it was. Entries written -before v4 stay `null` rather than being back-filled into a claim nobody made. KeyCape owns client registration and scope grants; approval-engine -only verifies and enforces them. Requested registrations are: +`principal_type` is recorded from verified identity (since schema v4). Schema v5 +implements GH-DEC-2026-016: when an approval declares `human_control: true`, +`/entries` refuses a service, agent or unknown principal with 403 before inserting +an entry or emitting issuance. The request body cannot supply the approver type +or downgrade the declaration. Undeclared approvals retain service-to-service use; +a human entry does not retroactively declare a human control. + +**Requesting and binding are separate operations.** `POST /v1/approvals` creates +an unapproved request and records the strict boolean declaration. A service or +agent with `approval:create` may draft that request. The principal contributing +judgment is known only at `POST /entries`; this is the bind/issue boundary where +GH-DEC-2026-016's non-human refusal applies. A draft is never a valid approval. +No statement about the approver is inferred from `binding.principal` or the +requesting actor's type. Dual control also needs its declared `required_count`; +human control alone does not imply two approvers or decide which acts need it. + +**Identity provenance:** reviewed KeyCape source +`f9812ab3b2bfe8f0817185f44071e612264ec3ee:src/internal/server/oidc/token.go` +sets `principal_type=human` only after consuming a client/redirect-bound PKCE +session and looking up the current user. Its separate client-credentials path +sets `service`; no registration field supplies a human principal type. This is +different from registration-supplied tenant routing. Production acceptance must +pin and prove that issuer behavior with a real human flow; signed fixtures here +prove engine enforcement, not native identity admission. If an issuer introduces +a registration-supplied route to `human`, GH-DEC-2026-016 §5 requires independent +provenance and refusal of that route before admitting it. A consumer must not +relax the identity contract simply because the JWT verifies. + +Entries written before v4 stay null; pre-v5 objects have `human_control: false`. +KeyCape owns client registration and scope grants; approval-engine verifies and +enforces them. Requested registrations are: - audience/resource server `approval-engine` with the scopes above; - the secrets-engine PEP service client with `approval:read` and diff --git a/docs/evidence/2026-09-10-human-control.json b/docs/evidence/2026-09-10-human-control.json new file mode 100644 index 0000000..b488639 --- /dev/null +++ b/docs/evidence/2026-09-10-human-control.json @@ -0,0 +1,47 @@ +{ + "status": "source-tests-passed", + "ruling": "GH-DEC-2026-016", + "schema_version": 5, + "new_cases_failed_before": 22, + "new_cases_passed_after": 22, + "full_suite_passed": 152, + "tests": "tests/test_human_control.py", + "semantics": { + "create": "Records declared intent on an unapproved request; service/agent requesters remain permitted under approval:create", + "bind": "Verified human required before insertion/issuance for declared human controls", + "legacy": "False, no inference from existing human entries", + "supersession": "Declaration inherited; mismatched existing successor conflicts atomically", + "identity": "RS256/JWKS verifies principal_type; entry body cannot spoof it", + "issuer_contract_source": "f9812ab3b2bfe8f0817185f44071e612264ec3ee:src/internal/server/oidc/token.go", + "persistence_proof": "Temporary on-disk v4 database migrated to v5; preserved records/digests", + "outbox": "Human bind and issuance roll back together", + "act_digest": "Unchanged five-field digest" + }, + "consumer_compatibility": { + "status": "passed", + "cases": [ + { + "human_control": false, + "existing_consumer_accepts_additive_field": true + }, + { + "human_control": true, + "existing_consumer_accepts_additive_field": true + } + ], + "native_calls": 0, + "human_control_required_by_existing_consumer": false, + "limit": "This proves wire compatibility only. The pilot requester/PEP must explicitly declare and require human_control; current consumer admission is not changed." + }, + "native_issuer_proof": false, + "production_migration": false, + "factory_attempts": 0, + "residual_records": [ + "APPROVAL-WP-0002-T01", + "APPROVAL-WP-0002-T03", + "APPROVAL-WP-0002-T05", + "SECRETS-WP-0009-T03", + "INFD-WP-0001-T08", + "HFACT-WP-0001-T01/T03/T04/T05" + ] +} diff --git a/docs/storage-operations.md b/docs/storage-operations.md index d3de405..c0c53df 100644 --- a/docs/storage-operations.md +++ b/docs/storage-operations.md @@ -60,3 +60,21 @@ Downgrade is not supported: an older server refuses the store on the version check rather than reading the column, which is the intended direction. Restore from a v4 backup onto a v3 release requires re-pinning forward, not editing `user_version`. + + +## Schema v5 — explicit human-control declaration + +GH-DEC-2026-016 adds `approvals.human_control`, constrained to 0 or 1, with default +0. Existing objects remain undeclared regardless of recorded approver types; +there is no retrospective declaration or change to their five-field binding +digest. A successor inherits the declaration. Linking an existing successor with +a different declaration conflicts and rolls back the parent transition. + +New human-control entries require verified type human at the engine boundary. +The declaration is retained on object/claim and audit events. Claim and consume +also refuse inconsistent persisted human evidence. Tests cover a persistent v4 +upgrade and preservation, signed API refusal, quorum and outbox rollback. Take +and verify the existing backup before migration. The old v3 image must not serve +a v5 database; rollback requires the matching pre-migration backup and the +existing single-writer recovery procedure. No live database is migrated by the +source test run. diff --git a/examples/claim.revoked.json b/examples/claim.revoked.json index b5d7e54..0f3ae14 100644 --- a/examples/claim.revoked.json +++ b/examples/claim.revoked.json @@ -18,7 +18,8 @@ "purpose": "rotate-exposed-key", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "pdp_digest": null, - "pdp_path": false + "pdp_path": false, + "human_control": false }, "freshness": { "observed_at": "2026-08-29T12:05:00+00:00", diff --git a/examples/claim.valid-human-control.json b/examples/claim.valid-human-control.json new file mode 100644 index 0000000..c11f210 --- /dev/null +++ b/examples/claim.valid-human-control.json @@ -0,0 +1,34 @@ +{ + "schema_version": "0.1", + "kind": "approval-claim", + "yields_to": "net-kingdom taxonomy request-claim schema (statute \u00a717; unassigned)", + "issuer": "approval-engine", + "approval_id": "f7e5aefe-3136-4cd7-9445-5118a3c70b1c", + "state": "valid", + "valid_now": true, + "consumed": false, + "binding": { + "action": "secrets.kv.destroy", + "target": { + "id": "lane-openbao-root", + "stage": "prod" + }, + "actor": "agt-secrets-engine", + "principal": "bernd", + "purpose": "rotate-exposed-key", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pdp_digest": "sha256:3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f", + "pdp_path": true, + "human_control": true + }, + "freshness": { + "observed_at": "2026-08-29T12:00:00+00:00", + "ttl_seconds": 30, + "not_after": "2026-08-29T12:00:30+00:00" + }, + "validity": { + "not_before": "2026-08-29T11:00:00+00:00", + "expires_at": "2026-08-29T15:00:00+00:00" + }, + "reason_code": "ok" +} diff --git a/examples/claim.valid.json b/examples/claim.valid.json index 3852d16..bcc2689 100644 --- a/examples/claim.valid.json +++ b/examples/claim.valid.json @@ -18,7 +18,8 @@ "purpose": "rotate-exposed-key", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "pdp_digest": "sha256:3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f", - "pdp_path": true + "pdp_path": true, + "human_control": false }, "freshness": { "observed_at": "2026-08-29T12:00:00+00:00", diff --git a/examples/claim.valid.no-pdp.json b/examples/claim.valid.no-pdp.json index bf21ee6..f6fdf50 100644 --- a/examples/claim.valid.no-pdp.json +++ b/examples/claim.valid.no-pdp.json @@ -1,7 +1,7 @@ { "schema_version": "0.1", "kind": "approval-claim", - "yields_to": "net-kingdom taxonomy request-claim schema (statute §17; unassigned)", + "yields_to": "net-kingdom taxonomy request-claim schema (statute \u00a717; unassigned)", "issuer": "approval-engine", "approval_id": "7c4e2b91-08da-4f63-b5c7-2a9e6d1f04b3", "state": "valid", @@ -18,7 +18,8 @@ "purpose": "cut-0-1-0-release", "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "pdp_digest": null, - "pdp_path": false + "pdp_path": false, + "human_control": false }, "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 bb005ae..2b3249f 100644 --- a/schemas/approval_claim.schema.json +++ b/schemas/approval_claim.schema.json @@ -74,7 +74,8 @@ "expired", "revoked", "superseded", - "consumed" + "consumed", + "human_control_unsatisfied" ] } }, @@ -152,6 +153,10 @@ "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." + }, + "human_control": { + "type": "boolean", + "description": "Declared at issue under GH-DEC-2026-016: this object discharges a human-in-the-loop or dual-control requirement. New producers always state it; absence in historical claims is undeclared, never proof of human control. True with valid_now means the required distinct approvers are verified human principals. It is separate from the five-field act digest. Consumers requiring human control must require exactly true; they must not infer it from approver identity or count." } } }, diff --git a/tests/test_cas.py b/tests/test_cas.py index fef2ffa..010c201 100644 --- a/tests/test_cas.py +++ b/tests/test_cas.py @@ -216,7 +216,8 @@ def test_v3_entries_migrate_to_v4_without_inventing_a_principal_type(): eng.close() upgraded = Engine(path, clock=lambda: FROZEN) - assert int(upgraded._conn().execute("PRAGMA user_version").fetchone()[0]) == 4 + from approval_engine.store import LATEST_SCHEMA_VERSION + assert int(upgraded._conn().execute("PRAGMA user_version").fetchone()[0]) == LATEST_SCHEMA_VERSION survivor = upgraded.get(obj.id) assert survivor.status == "approved" assert survivor.entries[0]["subject_id"] == "user:alice" diff --git a/tests/test_examples.py b/tests/test_examples.py index 61f5a8c..6a67452 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -96,3 +96,12 @@ def test_pdp_path_examples_always_carry_a_digest(path): binding = json.loads(path.read_text())["binding"] if binding["pdp_path"]: assert binding["pdp_digest"] is not None + + +def test_valid_examples_distinguish_declared_human_control_from_ordinary_approval(): + controls = { + c["binding"]["human_control"] + for c in (json.loads(p.read_text()) for p in EXAMPLES) + if c["valid_now"] + } + assert controls == {True, False} diff --git a/tests/test_human_control.py b/tests/test_human_control.py new file mode 100644 index 0000000..925ba43 --- /dev/null +++ b/tests/test_human_control.py @@ -0,0 +1,168 @@ +"""GH-DEC-2026-016: declared human judgment cannot be supplied by a service.""" +import json +from pathlib import Path + +import jsonschema +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa + +from approval_engine.api import App, call +from approval_engine.auth import JWTAuthenticator +from approval_engine.errors import Conflict, Forbidden, StoreUnavailable, Unprocessable +from approval_engine.store import Engine, LATEST_SCHEMA_VERSION +from tests.conftest import FROZEN, binding, validity +from tests.test_auth import _jwt, _JWKS + + +@pytest.fixture +def signed_app(engine): + private = rsa.generate_private_key(public_exponent=65537, key_size=2048) + app = App(engine, JWTAuthenticator( + issuer="https://keycape.example", audience="approval-engine", + jwks_url="https://keycape.example/jwks", jwks_client=_JWKS(private.public_key()), + )) + def bearer(principal_type, subject="service:secrets-engine"): + return "Bearer " + _jwt(private, principal_type=principal_type, sub=subject, + tenant="tenant:platform", scope="approval:create approval:approve approval:read") + return app, bearer + + +@pytest.mark.parametrize("principal_type", ["service", "agent"]) +def test_signed_nonhuman_cannot_bind_declared_human_control(engine, signed_app, principal_type): + app, bearer = signed_app + status, created = call(app, "POST", "/v1/approvals", { + "binding": binding(actor="service:secrets-engine"), "validity": validity(), + "human_control": True, + }, authorization=bearer("service")) + assert status == 201 # A machine may request a human's judgment, not supply it. + status, refused = call(app, "POST", f"/v1/approvals/{created['id']}/entries", { + "principal_type": "human", "subject_id": "forged-human", "human_control": False, + }, authorization=bearer(principal_type)) + assert status == 403, refused + obj = engine.get(created["id"]) + assert obj.status == "requested" and obj.entries == [] + assert engine._conn().execute("SELECT COUNT(*) FROM outbox").fetchone()[0] == 0 + + status, approved = call(app, "POST", f"/v1/approvals/{created['id']}/entries", {}, + authorization=bearer("human", "user:reviewer")) + assert status == 200 and approved["status"] == "approved" + assert approved["binding"]["human_control"] is True + assert approved["entries"][0]["subject_id"] == "user:reviewer" + assert approved["entries"][0]["principal_type"] == "human" + claim = engine.claim(created["id"]) + assert claim["valid_now"] and claim["binding"]["human_control"] is True + schema = json.loads((Path(__file__).parents[1]/"schemas/approval_claim.schema.json").read_text()) + jsonschema.validate(claim, schema) + event = json.loads(engine._conn().execute("SELECT payload_json FROM outbox").fetchone()[0]) + assert event["details"]["human_control"] is True + + +@pytest.mark.parametrize("value", ["false", "true", 0, 1, None, [], {}]) +def test_declaration_is_a_boolean_not_truthiness(app, engine, value): + status, _ = call(app, "POST", "/v1/approvals", { + "binding": binding(), "validity": validity(), "human_control": value, + }) + assert status == 422 + assert engine._conn().execute("SELECT COUNT(*) FROM approvals").fetchone()[0] == 0 + with pytest.raises(Unprocessable, match="human_control"): + engine.create(binding(), validity(), human_control=value) + + +@pytest.mark.parametrize("principal_type", [None, "service", "agent", "human ", "HUMAN"]) +def test_store_itself_refuses_nonhuman_binding(engine, principal_type): + obj = engine.create(binding(), validity(), human_control=True) + with pytest.raises(Forbidden, match="human"): + engine.add_entry(obj.id, "caller", principal_type=principal_type) + assert engine.get(obj.id).entries == [] + + +def test_undeclared_approvals_remain_usable_for_services_and_do_not_infer_human_control(engine): + for principal_type in ("service", "human", None): + obj = engine.create(binding(), validity()) + engine.add_entry(obj.id, "caller", principal_type=principal_type) + assert engine.claim(obj.id)["valid_now"] is True + assert engine.claim(obj.id)["binding"]["human_control"] is False + + +def test_two_humans_are_required_for_a_declared_dual_control(engine): + obj = engine.create(binding(), validity(), required_count=2, human_control=True) + engine.add_entry(obj.id, "first", principal_type="human") + assert not engine.claim(obj.id)["valid_now"] + with pytest.raises(Forbidden): + engine.add_entry(obj.id, "service", principal_type="service") + engine.add_entry(obj.id, "second", principal_type="human") + assert engine.claim(obj.id)["valid_now"] + + +def test_supersession_inherits_declaration_and_refuses_existing_downgrade_atomically(engine): + parent = engine.create(binding(), validity(), human_control=True) + weaker = engine.create(binding(), validity()) + with pytest.raises(Conflict, match="human_control"): + engine.supersede(parent.id, weaker.id) + assert engine.get(parent.id).status == "requested" + assert engine.get(parent.id).superseded_by is None + assert engine._conn().execute("SELECT COUNT(*) FROM outbox").fetchone()[0] == 0 + result = engine.supersede(parent.id) + child = engine.get(result["successor_id"]) + assert child.human_control is True and child.entries == [] + with pytest.raises(Forbidden): + engine.add_entry(child.id, "service", principal_type="service") + + +def test_existing_successor_must_retain_the_same_declaration(engine): + parent = engine.create(binding(), validity(), human_control=True) + matching = engine.create(binding(), validity(), human_control=True) + result = engine.supersede(parent.id, matching.id) + assert result["successor_created"] is False + assert engine.get(matching.id).human_control is True + undeclared = engine.create(binding(), validity()) + with pytest.raises(Conflict, match="human_control"): + engine.supersede(undeclared.id, matching.id) + + +def test_human_bind_and_outbox_still_roll_back_together(engine): + obj = engine.create(binding(), validity(), human_control=True) + engine.fail_outbox = True + with pytest.raises(StoreUnavailable): + engine.add_entry(obj.id, "human", principal_type="human") + engine.fail_outbox = False + assert engine.get(obj.id).entries == [] and engine.get(obj.id).status == "requested" + + +def test_legacy_v4_rows_are_not_reclassified_from_human_entries(tmp_path): + path = tmp_path/"v4.sqlite" + eng = Engine(path, clock=lambda: FROZEN) + obj = eng.create(binding(), validity()) + eng.add_entry(obj.id, "human", principal_type="human") + digest = obj.binding_digest + eng._conn().execute("ALTER TABLE approvals DROP COLUMN human_control") + eng._conn().execute("PRAGMA user_version=4") + eng._conn().commit(); eng.close() + upgraded = Engine(path, clock=lambda: FROZEN) + survivor = upgraded.get(obj.id) + assert survivor.status == "approved" and survivor.binding_digest == digest + 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 == 5 + upgraded.close() + + +def test_invalid_persisted_human_evidence_cannot_be_claimed_or_consumed(engine): + obj = engine.create(binding(), validity(), human_control=True) + engine.add_entry(obj.id, "human", principal_type="human") + # Simulate an inconsistent persisted row from a faulty writer. This is not + # a defence against a compromised database owner, which remains trusted. + engine._conn().execute("UPDATE entries SET principal_type='service' WHERE approval_id=?", (obj.id,)) + engine._conn().commit() + claim = engine.claim(obj.id) + assert not claim["valid_now"] and claim["reason_code"] == "human_control_unsatisfied" + with pytest.raises(Conflict, match="human"): + engine.consume(obj.id, obj.binding_digest) + assert engine.get(obj.id).status == "approved" + + +def test_control_declaration_does_not_change_the_five_field_act_digest(engine): + ordinary = engine.create(binding(), validity()) + controlled = engine.create(binding(), validity(), human_control=True) + assert ordinary.binding_digest == controlled.binding_digest 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 d94ef74..fee4da9 100644 --- a/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md +++ b/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md @@ -246,6 +246,35 @@ return changes neither existing rows nor production admission. The new rule is not satisfied merely by the already-recorded principal_type field. The separate R3 digest-exclusion condition is completed in T06 below. +### Declared human-control implementation — 2026-09-10 + +GH-DEC-2026-016 source implementation now records strict `human_control` intent +at request creation and refuses non-human binding at `add_entry` / `/entries`. +The creator may still be a service drafting a request; approval judgment comes +from the verified entry identity. This maps the ruling's bind/issue boundary to +this engine's two-step API and does not confer approval on an unapproved draft. +Schema v5 defaults legacy rows to undeclared, preserves act digests and recorded +entries, and inherits the declaration on supersession. An existing successor +with a mismatched declaration conflicts atomically. Spoofed request-body identity +cannot change the verified JWT type; invalid persisted human evidence also fails +claim/consume. The outbox retains the declaration with its existing atomic write. + +All 22 new cases fail on the previous implementation and pass after correction; +the full suite passes 152 tests. Persistent v4 migration, RS256/JWKS HTTP calls, +ordinary service compatibility, human quorum, rollback and successor cases are +included. Actual Secrets Engine claim parsing accepts both new flag values; +this is wire compatibility, not adoption of the new requirement. KeyCape's +current source fixes human type on its PKCE/user path and service type on its +client-credentials path; native issuer/human proof is still outstanding. + +T01 remains progress for exact requester/human registration and native proof. +T03 owns the matching schema-v5 image and admitted deployment. T05 with +SECRETS-WP-0009-T03 and INFD-WP-0001-T08 must explicitly declare and require +human_control for the factory human-approval workflow; old generic approvals +must not be treated as satisfying it. Evidence: +`docs/evidence/2026-09-10-human-control.json`. No production approval or database +migration occurred during these tests. + ## Harden durable storage and migrations ```task