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:
parent
a3c94fb241
commit
be1a388a84
17 changed files with 441 additions and 40 deletions
|
|
@ -124,6 +124,7 @@ class App:
|
||||||
int(data.get("required_count") or 1),
|
int(data.get("required_count") or 1),
|
||||||
pdp_digest=data.get("pdp_digest"),
|
pdp_digest=data.get("pdp_digest"),
|
||||||
pdp_path=bool(data.get("pdp_path", False)),
|
pdp_path=bool(data.get("pdp_path", False)),
|
||||||
|
human_control=data.get("human_control", False),
|
||||||
approval_id=data.get("id"),
|
approval_id=data.get("id"),
|
||||||
)
|
)
|
||||||
return 201, obj.as_dict()
|
return 201, obj.as_dict()
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ from .binding import binding_digest, canonical_binding, require_digest
|
||||||
from .errors import (
|
from .errors import (
|
||||||
Conflict,
|
Conflict,
|
||||||
DuplicateApprover,
|
DuplicateApprover,
|
||||||
|
Forbidden,
|
||||||
NotFound,
|
NotFound,
|
||||||
StoreUnavailable,
|
StoreUnavailable,
|
||||||
Unprocessable,
|
Unprocessable,
|
||||||
|
|
@ -35,7 +36,7 @@ AUDIT_SCHEMA = "audit-core.event.v1alpha1"
|
||||||
SOURCE = "approval-engine"
|
SOURCE = "approval-engine"
|
||||||
SCOPE = "netkingdom-approvals"
|
SCOPE = "netkingdom-approvals"
|
||||||
EVENT_CLASSES = ("issuance", "use", "supersession", "revocation", "heartbeat")
|
EVENT_CLASSES = ("issuance", "use", "supersession", "revocation", "heartbeat")
|
||||||
LATEST_SCHEMA_VERSION = 4
|
LATEST_SCHEMA_VERSION = 5
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS approvals (
|
CREATE TABLE IF NOT EXISTS approvals (
|
||||||
|
|
@ -57,6 +58,7 @@ CREATE TABLE IF NOT EXISTS approvals (
|
||||||
consumed_decision_id TEXT,
|
consumed_decision_id TEXT,
|
||||||
consumed_at TEXT,
|
consumed_at TEXT,
|
||||||
pdp_path INTEGER NOT NULL DEFAULT 0,
|
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,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
@ -112,6 +114,7 @@ class Approval:
|
||||||
binding_digest: str
|
binding_digest: str
|
||||||
pdp_digest: str | None
|
pdp_digest: str | None
|
||||||
pdp_path: bool
|
pdp_path: bool
|
||||||
|
human_control: bool
|
||||||
actor: str
|
actor: str
|
||||||
principal: str
|
principal: str
|
||||||
action: str
|
action: str
|
||||||
|
|
@ -137,6 +140,7 @@ class Approval:
|
||||||
"digest": self.binding_digest,
|
"digest": self.binding_digest,
|
||||||
"pdp_digest": self.pdp_digest,
|
"pdp_digest": self.pdp_digest,
|
||||||
"pdp_path": self.pdp_path,
|
"pdp_path": self.pdp_path,
|
||||||
|
"human_control": self.human_control,
|
||||||
},
|
},
|
||||||
"validity": {"not_before": self.not_before, "expires_at": self.expires_at},
|
"validity": {"not_before": self.not_before, "expires_at": self.expires_at},
|
||||||
"required_count": self.required_count,
|
"required_count": self.required_count,
|
||||||
|
|
@ -231,6 +235,14 @@ class Engine:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"ALTER TABLE approvals ADD COLUMN pdp_path INTEGER NOT NULL DEFAULT 0"
|
"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 = {
|
entry_columns = {
|
||||||
row["name"] for row in conn.execute("PRAGMA table_info(entries)").fetchall()
|
row["name"] for row in conn.execute("PRAGMA table_info(entries)").fetchall()
|
||||||
}
|
}
|
||||||
|
|
@ -348,12 +360,15 @@ class Engine:
|
||||||
*,
|
*,
|
||||||
pdp_digest: str | None = None,
|
pdp_digest: str | None = None,
|
||||||
pdp_path: bool = False,
|
pdp_path: bool = False,
|
||||||
|
human_control: bool = False,
|
||||||
approval_id: str | None = None,
|
approval_id: str | None = None,
|
||||||
) -> Approval:
|
) -> Approval:
|
||||||
if required_count < 1:
|
if required_count < 1:
|
||||||
raise Unprocessable("required_count must be >= 1")
|
raise Unprocessable("required_count must be >= 1")
|
||||||
if not isinstance(pdp_path, bool):
|
if not isinstance(pdp_path, bool):
|
||||||
raise Unprocessable("pdp_path must be a boolean")
|
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)
|
canon = canonical_binding(binding)
|
||||||
digest = binding_digest(canon)
|
digest = binding_digest(canon)
|
||||||
pdp = require_digest(pdp_digest)
|
pdp = require_digest(pdp_digest)
|
||||||
|
|
@ -382,8 +397,8 @@ class Engine:
|
||||||
id, status, binding_json, binding_digest, pdp_digest,
|
id, status, binding_json, binding_digest, pdp_digest,
|
||||||
actor, principal, action, purpose, target_json,
|
actor, principal, action, purpose, target_json,
|
||||||
not_before, expires_at, required_count, superseded_by,
|
not_before, expires_at, required_count, superseded_by,
|
||||||
pdp_path, created_at, updated_at
|
pdp_path, human_control, created_at, updated_at
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
(
|
(
|
||||||
aid,
|
aid,
|
||||||
"requested",
|
"requested",
|
||||||
|
|
@ -400,6 +415,7 @@ class Engine:
|
||||||
required_count,
|
required_count,
|
||||||
None,
|
None,
|
||||||
1 if pdp_path else 0,
|
1 if pdp_path else 0,
|
||||||
|
1 if human_control else 0,
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
),
|
),
|
||||||
|
|
@ -456,6 +472,7 @@ class Engine:
|
||||||
binding_digest=row["binding_digest"],
|
binding_digest=row["binding_digest"],
|
||||||
pdp_digest=row["pdp_digest"],
|
pdp_digest=row["pdp_digest"],
|
||||||
pdp_path=bool(row["pdp_path"]),
|
pdp_path=bool(row["pdp_path"]),
|
||||||
|
human_control=bool(row["human_control"]),
|
||||||
actor=row["actor"],
|
actor=row["actor"],
|
||||||
principal=row["principal"],
|
principal=row["principal"],
|
||||||
action=row["action"],
|
action=row["action"],
|
||||||
|
|
@ -511,6 +528,10 @@ class Engine:
|
||||||
if row["status"] not in MUTABLE:
|
if row["status"] not in MUTABLE:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
raise Conflict(f"cannot add entries in status {row['status']}")
|
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:
|
try:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO entries "
|
"INSERT INTO entries "
|
||||||
|
|
@ -582,7 +603,11 @@ class Engine:
|
||||||
if cur.rowcount != 1:
|
if cur.rowcount != 1:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
raise Conflict("supersession lost the compare-and-swap")
|
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
|
created_successor = False
|
||||||
if existing is None:
|
if existing is None:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|
@ -590,8 +615,8 @@ class Engine:
|
||||||
id, status, binding_json, binding_digest, pdp_digest,
|
id, status, binding_json, binding_digest, pdp_digest,
|
||||||
actor, principal, action, purpose, target_json,
|
actor, principal, action, purpose, target_json,
|
||||||
not_before, expires_at, required_count, superseded_by,
|
not_before, expires_at, required_count, superseded_by,
|
||||||
pdp_path, created_at, updated_at
|
pdp_path, human_control, created_at, updated_at
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
(
|
(
|
||||||
successor_id,
|
successor_id,
|
||||||
"requested",
|
"requested",
|
||||||
|
|
@ -608,6 +633,7 @@ class Engine:
|
||||||
row["required_count"],
|
row["required_count"],
|
||||||
None,
|
None,
|
||||||
row["pdp_path"],
|
row["pdp_path"],
|
||||||
|
row["human_control"],
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
),
|
),
|
||||||
|
|
@ -720,6 +746,14 @@ class Engine:
|
||||||
if self.now() < parse_iso(row["not_before"]) or self.now() >= parse_iso(row["expires_at"]):
|
if self.now() < parse_iso(row["not_before"]) or self.now() >= parse_iso(row["expires_at"]):
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
raise Conflict("cannot consume outside validity window")
|
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(
|
cur = conn.execute(
|
||||||
"UPDATE approvals SET status='consumed', consumed_digest=?, "
|
"UPDATE approvals SET status='consumed', consumed_digest=?, "
|
||||||
"consumed_decision_id=?, consumed_at=?, updated_at=? "
|
"consumed_decision_id=?, consumed_at=?, updated_at=? "
|
||||||
|
|
@ -823,6 +857,11 @@ class Engine:
|
||||||
details = {"class": event_class, **(extra or {})}
|
details = {"class": event_class, **(extra or {})}
|
||||||
if approval_id:
|
if approval_id:
|
||||||
details["approval_id"] = 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 = {
|
payload = {
|
||||||
"schema_version": AUDIT_SCHEMA,
|
"schema_version": AUDIT_SCHEMA,
|
||||||
"event_id": event_id,
|
"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
|
# for the PDP path, so a consumer does not infer it from a digest that
|
||||||
# happens to be present.
|
# happens to be present.
|
||||||
binding["pdp_path"] = obj.pdp_path
|
binding["pdp_path"] = obj.pdp_path
|
||||||
|
binding["human_control"] = obj.human_control
|
||||||
return {
|
return {
|
||||||
"schema_version": CLAIM_SCHEMA,
|
"schema_version": CLAIM_SCHEMA,
|
||||||
"kind": "approval-claim",
|
"kind": "approval-claim",
|
||||||
|
|
@ -895,6 +935,8 @@ class Engine:
|
||||||
if obj.status == "approved":
|
if obj.status == "approved":
|
||||||
if now < parse_iso(obj.not_before):
|
if now < parse_iso(obj.not_before):
|
||||||
return "approved", False, False, "not_yet_valid"
|
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:
|
if len({e["subject_id"] for e in obj.entries}) < obj.required_count:
|
||||||
return "approved", False, False, "insufficient_approvers"
|
return "approved", False, False, "insufficient_approvers"
|
||||||
return "valid", True, False, "ok"
|
return "valid", True, False, "ok"
|
||||||
|
|
|
||||||
|
|
@ -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
|
MUST stay identical and MUST stay digests — a tag here would let the init
|
||||||
container and the server run different code against one database.
|
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
|
[`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
|
`LATEST_SCHEMA_VERSION = 3`; this repository is now at 5
|
||||||
(`entries.principal_type`, see [storage-operations.md](../docs/storage-operations.md)).
|
(`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
|
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
|
principal-type evidence requires cutting a new image at step 3 below. The
|
||||||
`migrate` init container then performs the additive upgrade on the existing
|
`migrate` init container then performs the additive upgrade on the existing
|
||||||
volume; `tests/test_deploy_manifest.py` holds this acknowledgement so the drift
|
volume; `tests/test_deploy_manifest.py` holds this acknowledgement so the drift
|
||||||
|
|
|
||||||
|
|
@ -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
|
`tests/test_examples.py` asserts the decorrelation, not merely that both values
|
||||||
appear somewhere.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
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.
|
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
|
**GH-DEC-2026-016 is now implemented in the source candidate.** An approval
|
||||||
`approval:approve` under the requested registrations. That is wrong twice: the
|
explicitly declared with `human_control: true` refuses service/agent binds at
|
||||||
`approval-engine-operator` service client holds `approval:approve`, and this
|
`/entries`, irrespective of what the caller submits in the body. The verified
|
||||||
engine does not restrict `/entries` by principal type at all — only `/consume`
|
human identity supplies the entry. The UI retains its own humans-bind-agents-draft
|
||||||
is restricted, to `service`/`agent`. So a non-human principal can supply
|
rule and must require the declared human-control object for that workflow; an
|
||||||
approver evidence today.
|
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
|
The engine still admits service-to-service approvals when no human control was
|
||||||
`gate-house`, not to us and not to you. What we have done instead is make it
|
declared. The flag neither grants a scope nor identifies which acts require a
|
||||||
visible: schema v4 records the verified `principal_type` on every entry
|
human; those remain owner doctrine. See [caller-authentication.md](caller-authentication.md)
|
||||||
(`tests/test_auth.py::test_entry_records_the_verified_principal_type`,
|
for the issuer's human-type provenance and remaining native acceptance. The
|
||||||
`::test_non_human_approver_is_recorded_as_such`). Your design principle 10
|
source candidate requires schema v5 and a new image; the current production
|
||||||
("humans bind, agents draft") is therefore enforceable in the evidence chain
|
manifest is not evidence that this enforcement is deployed.
|
||||||
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`.
|
|
||||||
|
|
||||||
## 3. Open question A — the human client cannot read the approval it renders
|
## 3. Open question A — the human client cannot read the approval it renders
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,15 +47,37 @@ Create additionally requires `binding.actor == sub`. Approval-entry subject,
|
||||||
assurance, evidence reference, and **principal type** are derived from the
|
assurance, evidence reference, and **principal type** are derived from the
|
||||||
verified JWT, never the request body.
|
verified JWT, never the request body.
|
||||||
|
|
||||||
`principal_type` is recorded on the entry (schema v4) because `subject_id`
|
`principal_type` is recorded from verified identity (since schema v4). Schema v5
|
||||||
alone cannot answer what kind of principal bound the approval — `user:alice` is
|
implements GH-DEC-2026-016: when an approval declares `human_control: true`,
|
||||||
a naming convention, not a verified claim. This engine does not restrict
|
`/entries` refuses a service, agent or unknown principal with 403 before inserting
|
||||||
`/entries` to human principals: whether a service or agent may supply approver
|
an entry or emitting issuance. The request body cannot supply the approver type
|
||||||
evidence is approval doctrine and belongs to `gate-house`, and the
|
or downgrade the declaration. Undeclared approvals retain service-to-service use;
|
||||||
`approval-engine-operator` registration holds `approval:approve` today. What
|
a human entry does not retroactively declare a human control.
|
||||||
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
|
**Requesting and binding are separate operations.** `POST /v1/approvals` creates
|
||||||
only verifies and enforces them. Requested registrations are:
|
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;
|
- audience/resource server `approval-engine` with the scopes above;
|
||||||
- the secrets-engine PEP service client with `approval:read` and
|
- the secrets-engine PEP service client with `approval:read` and
|
||||||
|
|
|
||||||
47
docs/evidence/2026-09-10-human-control.json
Normal file
47
docs/evidence/2026-09-10-human-control.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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
|
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
|
from a v4 backup onto a v3 release requires re-pinning forward, not editing
|
||||||
`user_version`.
|
`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.
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@
|
||||||
"purpose": "rotate-exposed-key",
|
"purpose": "rotate-exposed-key",
|
||||||
"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
"pdp_digest": null,
|
"pdp_digest": null,
|
||||||
"pdp_path": false
|
"pdp_path": false,
|
||||||
|
"human_control": false
|
||||||
},
|
},
|
||||||
"freshness": {
|
"freshness": {
|
||||||
"observed_at": "2026-08-29T12:05:00+00:00",
|
"observed_at": "2026-08-29T12:05:00+00:00",
|
||||||
|
|
|
||||||
34
examples/claim.valid-human-control.json
Normal file
34
examples/claim.valid-human-control.json
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,8 @@
|
||||||
"purpose": "rotate-exposed-key",
|
"purpose": "rotate-exposed-key",
|
||||||
"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
"pdp_digest": "sha256:3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f",
|
"pdp_digest": "sha256:3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f",
|
||||||
"pdp_path": true
|
"pdp_path": true,
|
||||||
|
"human_control": false
|
||||||
},
|
},
|
||||||
"freshness": {
|
"freshness": {
|
||||||
"observed_at": "2026-08-29T12:00:00+00:00",
|
"observed_at": "2026-08-29T12:00:00+00:00",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"schema_version": "0.1",
|
"schema_version": "0.1",
|
||||||
"kind": "approval-claim",
|
"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",
|
"issuer": "approval-engine",
|
||||||
"approval_id": "7c4e2b91-08da-4f63-b5c7-2a9e6d1f04b3",
|
"approval_id": "7c4e2b91-08da-4f63-b5c7-2a9e6d1f04b3",
|
||||||
"state": "valid",
|
"state": "valid",
|
||||||
|
|
@ -18,7 +18,8 @@
|
||||||
"purpose": "cut-0-1-0-release",
|
"purpose": "cut-0-1-0-release",
|
||||||
"digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
"digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||||
"pdp_digest": null,
|
"pdp_digest": null,
|
||||||
"pdp_path": false
|
"pdp_path": false,
|
||||||
|
"human_control": false
|
||||||
},
|
},
|
||||||
"freshness": {
|
"freshness": {
|
||||||
"observed_at": "2026-08-29T12:00:00+00:00",
|
"observed_at": "2026-08-29T12:00:00+00:00",
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,8 @@
|
||||||
"expired",
|
"expired",
|
||||||
"revoked",
|
"revoked",
|
||||||
"superseded",
|
"superseded",
|
||||||
"consumed"
|
"consumed",
|
||||||
|
"human_control_unsatisfied"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -152,6 +153,10 @@
|
||||||
"pdp_path": {
|
"pdp_path": {
|
||||||
"type": "boolean",
|
"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."
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,8 @@ def test_v3_entries_migrate_to_v4_without_inventing_a_principal_type():
|
||||||
eng.close()
|
eng.close()
|
||||||
|
|
||||||
upgraded = Engine(path, clock=lambda: FROZEN)
|
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)
|
survivor = upgraded.get(obj.id)
|
||||||
assert survivor.status == "approved"
|
assert survivor.status == "approved"
|
||||||
assert survivor.entries[0]["subject_id"] == "user:alice"
|
assert survivor.entries[0]["subject_id"] == "user:alice"
|
||||||
|
|
|
||||||
|
|
@ -96,3 +96,12 @@ def test_pdp_path_examples_always_carry_a_digest(path):
|
||||||
binding = json.loads(path.read_text())["binding"]
|
binding = json.loads(path.read_text())["binding"]
|
||||||
if binding["pdp_path"]:
|
if binding["pdp_path"]:
|
||||||
assert binding["pdp_digest"] is not None
|
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
168
tests/test_human_control.py
Normal 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
|
||||||
|
|
@ -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
|
not satisfied merely by the already-recorded principal_type field. The separate
|
||||||
R3 digest-exclusion condition is completed in T06 below.
|
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
|
## Harden durable storage and migrations
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue