Enforce declared human controls at approval binding

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-10 19:26:12 +02:00
parent a3c94fb241
commit be1a388a84
17 changed files with 441 additions and 40 deletions

View file

@ -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()

View file

@ -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"

View file

@ -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

View file

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

View file

@ -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

View file

@ -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

View file

@ -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"
]
}

View file

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

View file

@ -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",

View file

@ -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"
}

View file

@ -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",

View file

@ -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",

View file

@ -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."
}
}
},

View file

@ -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"

View file

@ -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}

168
tests/test_human_control.py Normal file
View file

@ -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

View file

@ -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