APPROVAL-WP-0007: record the verified revoker and park the inactive plans

Schema v9 stores who revoked an approval and an optional bounded reason.
The bounded-execution carrier example is proven in process. APPROVAL-WP-0005,
0006, and 0007 are blocked because they are not current work. Task statuses
stay as they were, apart from T03 and T04, which are done in source.

Assistant: grok
Assistant-Session: 01a1b5fd-0c33-7ba1-b383-53937d525595
This commit is contained in:
tegwick 2026-11-07 14:10:01 +01:00
parent f41a9ca82c
commit 4aa0b75449
17 changed files with 599 additions and 36 deletions

View file

@ -15,9 +15,9 @@
| workplan | APPROVAL-WP-0002 | blocked | — | workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md |
| workplan | APPROVAL-WP-0003 | finished | — | workplans/APPROVAL-WP-0003-reissue-expired-approvals.md |
| workplan | APPROVAL-WP-0004 | finished | — | workplans/APPROVAL-WP-0004-exact-consume-source-provenance.md |
| workplan | APPROVAL-WP-0005 | active | — | workplans/APPROVAL-WP-0005-access-claim-fact-contract.md |
| workplan | APPROVAL-WP-0006 | active | — | workplans/APPROVAL-WP-0006-fractional-consumption-time.md |
| workplan | APPROVAL-WP-0007 | active | — | workplans/APPROVAL-WP-0007-bounded-execution-carrier.md |
| workplan | APPROVAL-WP-0005 | blocked | — | workplans/APPROVAL-WP-0005-access-claim-fact-contract.md |
| workplan | APPROVAL-WP-0006 | blocked | — | workplans/APPROVAL-WP-0006-fractional-consumption-time.md |
| workplan | APPROVAL-WP-0007 | blocked | — | workplans/APPROVAL-WP-0007-bounded-execution-carrier.md |
| task | APPROVAL-WP-ADHOC-2026-10-10-T01 | done | — | workplans/ADHOC-2026-10-10.md |
| task | APPROVAL-WP-ADHOC-2026-10-11-T01 | done | — | workplans/ADHOC-2026-10-11.md |
| task | APPROVAL-WP-ADHOC-2026-10-11-T02 | done | — | workplans/ADHOC-2026-10-11.md |
@ -47,8 +47,8 @@
| task | APPROVAL-WP-0006-T02 | progress | — | workplans/APPROVAL-WP-0006-fractional-consumption-time.md |
| task | APPROVAL-WP-0007-T01 | done | — | workplans/APPROVAL-WP-0007-bounded-execution-carrier.md |
| task | APPROVAL-WP-0007-T02 | done | — | workplans/APPROVAL-WP-0007-bounded-execution-carrier.md |
| task | APPROVAL-WP-0007-T03 | todo | — | workplans/APPROVAL-WP-0007-bounded-execution-carrier.md |
| task | APPROVAL-WP-0007-T04 | todo | — | workplans/APPROVAL-WP-0007-bounded-execution-carrier.md |
| task | APPROVAL-WP-0007-T03 | done | — | workplans/APPROVAL-WP-0007-bounded-execution-carrier.md |
| task | APPROVAL-WP-0007-T04 | done | — | workplans/APPROVAL-WP-0007-bounded-execution-carrier.md |
| task | APPROVAL-WP-0007-T05 | wait | — | workplans/APPROVAL-WP-0007-bounded-execution-carrier.md |
| task | APPROVAL-WP-0007-T06 | todo | — | workplans/APPROVAL-WP-0007-bounded-execution-carrier.md |
| intake | APPROVAL-IN-0001 | open | blue | intakes/intakes.md |

View file

@ -161,8 +161,13 @@ class App:
)
return 200, obj.as_dict()
if rest == ["revoke"] and method == "POST":
self.identity(environ, "approval:revoke")
return 200, self.engine.revoke(approval_id).as_dict()
identity = self.identity(environ, "approval:revoke")
data = _read_json(environ)
if set(data) - {"reason"}:
raise Unprocessable("revoke accepts only an optional reason")
return 200, self.engine.revoke(
approval_id, revoker=identity, reason=data.get("reason")
).as_dict()
if rest == ["supersede"] and method == "POST":
self.identity(environ, "approval:supersede")
data = _read_json(environ)

View file

@ -43,7 +43,10 @@ EVENT_CLASSES = ("issuance", "use", "supersession", "revocation", "heartbeat")
# class and says nothing about the quiet one — here that is `revocation`, which
# is the class whose silence actually matters.
HEARTBEAT_CLASSES = ("issuance", "use", "supersession", "revocation")
LATEST_SCHEMA_VERSION = 8
LATEST_SCHEMA_VERSION = 9
# APPROVAL-WP-0007-T03. A revocation reason is optional evidence, one line,
# bounded so a caller cannot put an unbounded blob in the local outbox.
REVOCATION_REASON_MAX = 200
# APPROVAL-WP-0007-T01: authenticator assurance levels, weakest first. A level
# outside this list is unknown, and an unknown level never meets a floor.
ASSURANCE_LEVELS = ("aal1", "aal2", "aal3")
@ -108,6 +111,12 @@ CREATE TABLE IF NOT EXISTS approvals (
human_control INTEGER NOT NULL DEFAULT 0 CHECK (human_control IN (0, 1)),
required_assurance TEXT CHECK (required_assurance IS NULL OR required_assurance IN ('aal1', 'aal2', 'aal3')),
actor_consumes INTEGER NOT NULL DEFAULT 0 CHECK (actor_consumes IN (0, 1)),
revoked_by_subject TEXT,
revoked_by_principal_type TEXT CHECK (
revoked_by_principal_type IS NULL OR revoked_by_principal_type IN ('human', 'service', 'agent')
),
revoked_by_assurance TEXT,
revocation_reason TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@ -167,6 +176,10 @@ class Approval:
human_control: bool
required_assurance: str | None
actor_consumes: bool
revoked_by_subject: str | None
revoked_by_principal_type: str | None
revoked_by_assurance: str | None
revocation_reason: str | None
actor: str
principal: str
action: str
@ -208,6 +221,17 @@ class Approval:
if self.actor_consumes:
# APPROVAL-WP-0007-T02, same rule: stated only when declared.
result["binding"]["actor_consumes"] = True
if self.revoked_by_subject:
# APPROVAL-WP-0007-T03. Absent when no verified revoker was
# recorded, including legacy rows and internal revoke calls.
revoked_by = {
"subject": self.revoked_by_subject,
"principal_type": self.revoked_by_principal_type,
"assurance": json.loads(self.revoked_by_assurance),
}
if self.revocation_reason:
revoked_by["reason"] = self.revocation_reason
result["revoked_by"] = revoked_by
if self.consumed_digest:
result["consumption"] = {
"request_digest": self.consumed_digest,
@ -317,6 +341,18 @@ class Engine:
"ALTER TABLE approvals ADD COLUMN actor_consumes "
"INTEGER NOT NULL DEFAULT 0 CHECK (actor_consumes IN (0, 1))"
)
if "revoked_by_subject" not in approval_columns:
# v9, APPROVAL-WP-0007-T03. Legacy revocations stay NULL: the
# revoker was not verified into the row, and inventing one
# would manufacture evidence.
conn.execute("ALTER TABLE approvals ADD COLUMN revoked_by_subject TEXT")
conn.execute(
"ALTER TABLE approvals ADD COLUMN revoked_by_principal_type TEXT CHECK ("
"revoked_by_principal_type IS NULL OR "
"revoked_by_principal_type IN ('human', 'service', 'agent'))"
)
conn.execute("ALTER TABLE approvals ADD COLUMN revoked_by_assurance TEXT")
conn.execute("ALTER TABLE approvals ADD COLUMN revocation_reason TEXT")
entry_columns = {
row["name"] for row in conn.execute("PRAGMA table_info(entries)").fetchall()
}
@ -561,6 +597,10 @@ class Engine:
human_control=bool(row["human_control"]),
required_assurance=row["required_assurance"],
actor_consumes=bool(row["actor_consumes"]),
revoked_by_subject=row["revoked_by_subject"],
revoked_by_principal_type=row["revoked_by_principal_type"],
revoked_by_assurance=row["revoked_by_assurance"],
revocation_reason=row["revocation_reason"],
actor=row["actor"],
principal=row["principal"],
action=row["action"],
@ -685,8 +725,58 @@ class Engine:
raise
return self.get(approval_id)
def revoke(self, approval_id: str) -> Approval:
return self._terminal(approval_id, "revoked", "revocation")
def revoke(
self,
approval_id: str,
*,
revoker: Identity | None = None,
reason: str | None = None,
) -> Approval:
"""Terminal revoke. CAS and the closed machine are unchanged.
A verified revoker is recorded when the HTTP boundary supplies one.
The revoker is not required to be the bound principal: revocation
must stay possible without the holder. An internal call with no
revoker still revokes and leaves the evidence fields empty.
"""
return self._terminal(
approval_id,
"revoked",
"revocation",
revoked_by=self._revoker_evidence(revoker, reason),
)
def _revoker_evidence(
self, revoker: Identity | None, reason: str | None
) -> dict[str, str] | None:
if reason is not None and revoker is None:
raise Unprocessable("revocation reason requires a verified revoker")
if reason is not None:
if not isinstance(reason, str) or reason != reason.strip() or not reason:
raise Unprocessable(
"revocation reason must be a non-empty string without surrounding space"
)
if len(reason) > REVOCATION_REASON_MAX or any(ord(ch) < 32 for ch in reason):
raise Unprocessable(
f"revocation reason must be a single line of at most {REVOCATION_REASON_MAX} characters"
)
if revoker is None:
return None
if revoker.principal_type not in {"human", "service", "agent"} or not revoker.subject:
raise Unprocessable("verified revoker subject and principal_type are required")
if not isinstance(revoker.assurance, dict) or not revoker.assurance:
raise Unprocessable("verified revoker assurance is required")
assurance = json.dumps(revoker.assurance, sort_keys=True)
if len(assurance) > 1024:
raise Unprocessable("verified revoker assurance is too large")
evidence = {
"subject": revoker.subject,
"principal_type": revoker.principal_type,
"assurance": assurance,
}
if reason is not None:
evidence["reason"] = reason
return evidence
def supersede(
self,
@ -809,7 +899,14 @@ class Engine:
"successor_created": created_successor,
}
def _terminal(self, approval_id: str, status: str, event_class: str) -> Approval:
def _terminal(
self,
approval_id: str,
status: str,
event_class: str,
*,
revoked_by: dict[str, str] | None = None,
) -> Approval:
conn = self._conn()
now = iso(self.now())
try:
@ -818,10 +915,27 @@ class Engine:
if row is None:
conn.rollback()
raise NotFound(approval_id)
subject = principal_type = assurance = reason = None
extra: dict[str, Any] = {"binding_digest": row["binding_digest"]}
if revoked_by is not None:
subject = revoked_by["subject"]
principal_type = revoked_by["principal_type"]
assurance = revoked_by["assurance"]
reason = revoked_by.get("reason")
recorded = {
"subject": subject,
"principal_type": principal_type,
"assurance": json.loads(assurance),
}
if reason is not None:
recorded["reason"] = reason
extra["revoked_by"] = recorded
cur = conn.execute(
"UPDATE approvals SET status=?, updated_at=? "
"UPDATE approvals SET status=?, updated_at=?, "
"revoked_by_subject=?, revoked_by_principal_type=?, "
"revoked_by_assurance=?, revocation_reason=? "
"WHERE id=? AND status IN ('requested','approved')",
(status, now, approval_id),
(status, now, subject, principal_type, assurance, reason, approval_id),
)
if cur.rowcount != 1:
conn.rollback()
@ -831,7 +945,7 @@ class Engine:
event_class,
approval_id,
actor=row["actor"],
extra={"binding_digest": row["binding_digest"]},
extra=extra,
)
conn.commit()
except sqlite3.Error as exc:

View file

@ -27,10 +27,11 @@ provenance. The prior v5 image was
`sha256:251941a5cb2724b57cc32cff6b693b1ab0be695bee4f56f02d51961189c0fa49`.
It refuses a v6 store: rollback requires the matching verified pre-v6 backup
and the stopped-single-writer restore procedure in `docs/storage-operations.md`.
Source has since moved to schema v8 (v7 `approvals.required_assurance`,
APPROVAL-WP-0007-T01; v8 `approvals.actor_consumes`, APPROVAL-WP-0007-T02); the
pinned image predates schema v8 (and v7). Deploying needs a new release, a
verified pre-v7 backup and a founder-run rollout like the v6 one.
Source has since moved to schema v9 (v7 `approvals.required_assurance`,
APPROVAL-WP-0007-T01; v8 `approvals.actor_consumes`, APPROVAL-WP-0007-T02;
v9 `revoked_by_*`, APPROVAL-WP-0007-T03); the pinned image predates schema v9
(and v7). Deploying needs a new release, a verified pre-v7 backup and a
founder-run rollout like the v6 one.
The earlier September v5 release evidence remains historical at
[the image evidence](../docs/evidence/2026-09-10-human-control-image.json).

View file

@ -448,6 +448,22 @@ call with no authenticated consumer fails closed. The check does not change
Migration leaves existing rows without the flag. The declaration is separate
from the five-field act digest.
### Revoker evidence — `revoked_by`
APPROVAL-WP-0007-T03, schema v9 store. `POST /v1/approvals/{id}/revoke` still
moves `requested` or `approved` to terminal `revoked` by the same
compare-and-swap. The body accepts only an optional `reason`: one line, at
most 200 characters. The verified token supplies `subject`, `principal_type`
and `assurance`. A body cannot name the revoker. The revoker is not required
to equal `binding.principal`.
When a verified revoker is present, `GET` serves `revoked_by` and the
`approval.revocation` outbox event carries the same object in
`details.revoked_by`. The event `actor` stays the bound actor. The claim does
not gain `revoked_by`: it stays a validity fact, and undeclared claim bytes
stay exact. An internal revoke with no verified revoker still revokes and
omits the member, which is also how legacy rows read after migration.
### Actor is never an approver under `human_control`
GH-DEC-2026-026 Ruling 6. Under `human_control: true`, `POST /entries` refuses

View file

@ -18,7 +18,7 @@ Inputs: NetKingdom `canon/standards/bounded-execution-admission_v0.1.md`
| 1. Binding | **Supported today**, with one consequence: the executor (the `actor`) must create the request itself |
| 2. aal2 | **Recorded today, not enforced.** Enforcement at issue/entry/consume needs a change (APPROVAL-WP-0007-T01) |
| 3. Consume | **Supported today.** Scope `approval:consume`, service principal; proposed `request_digest` = the admission digest. The independent GET comparison is the intended reading |
| 4. Revoke | **Supported today** for unconsumed approvals. The revoker's identity is not yet recorded in evidence (APPROVAL-WP-0007-T03) |
| 4. Revoke | **Supported.** Unconsumed approvals revoke by CAS. Schema v9 records the verified revoker (APPROVAL-WP-0007-T03, in source, not deployed) |
## 1. Binding
@ -207,11 +207,13 @@ Limits:
after the executor's first-step consume, stopping it mid-run is the
executor's own kill switch (T07, NetKingdom runtime). This engine has no
"stop after start".
- **The engine does not record who revoked.** The event carries the bound
`actor` and the binding digest, not the revoker's subject or assurance, and
the engine does not check that the revoker is the approver or principal.
APPROVAL-WP-0007-T03 records the verified revoker identity, assurance and an
optional reason in the revocation event.
- **The verified revoker is recorded** (APPROVAL-WP-0007-T03, schema v9, in
source, not yet deployed). `GET` serves `revoked_by` with the token's
subject, principal type and assurance, plus an optional one-line `reason`
of at most 200 characters. The same object is on the `approval.revocation`
event. The event `actor` stays the bound actor. The revoker is not required
to be the principal or the approver. The claim does not carry `revoked_by`.
Legacy rows and an internal revoke with no verified identity omit it.
- Supersession (`approval:supersede`) also stops an unconsumed approval and
links a successor. For the pilot, revoke is the right kill switch.

View file

@ -94,3 +94,16 @@ Publish/scan and pin the same v6 artifact for both containers, take and verify
the pre-migration backup, and obtain per-change rollout approval. An old v5
server refuses a v6 store; rollback uses the verified pre-migration backup with
the matching release and the stopped single-writer restore procedure above.
## Schema v7–v9 — assurance floor, actor consumption, revoker evidence
v7 adds nullable `approvals.required_assurance`. v8 adds `approvals.actor_consumes`
(0 or 1, default 0). v9 adds nullable `revoked_by_subject`,
`revoked_by_principal_type`, `revoked_by_assurance`, and `revocation_reason`.
Migration is additive. Legacy rows stay NULL, and `actor_consumes` stays 0.
Nothing is inferred for old approvals, entries, or revocations.
A v6 server refuses a v9 store. Rollback uses the verified pre-v9 backup with
the matching v6 release and the stopped single-writer restore procedure above.
Take and verify that backup, and open an isolated copy with the new image,
before the sole pod is replaced.

View file

@ -0,0 +1,21 @@
{
"binding": {
"action": "bounded-execution",
"actor": "service:nk-bind-executor",
"principal": "admin",
"purpose": "synthetic bounded-execution carrier",
"target": {
"admission_id": "BEA-SYNTH-0001",
"admission_digest": "sha256:7b91434ed31962216e60117b989335bd55b0a679d2dfc1609cd4013dee45a947"
}
},
"validity": {
"not_before": "2026-08-29T11:00:00+00:00",
"expires_at": "2026-08-29T15:00:00+00:00"
},
"required_count": 1,
"human_control": true,
"pdp_path": false,
"required_assurance": {"level": "aal2"},
"actor_consumes": true
}

View file

@ -159,7 +159,7 @@ def test_legacy_v7_rows_migrate_without_the_flag(tmp_path):
survivor = upgraded.get(obj.id)
assert survivor.actor_consumes is False
assert "actor_consumes" not in upgraded.claim(obj.id)["binding"]
assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 8
assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 9
upgraded.consume(obj.id, obj.binding_digest, consumer=_identity("svc-other"))
upgraded.close()

View file

@ -0,0 +1,200 @@
"""APPROVAL-WP-0007-T04: synthetic bounded-execution carrier path.
The request is the published example. Nothing here contacts a live service.
"""
import json
from datetime import datetime, timezone
from pathlib import Path
from approval_engine.api import App, call
from approval_engine.auth import Identity, StaticTokenAuthenticator
from approval_engine.store import Engine
from tests.conftest import FROZEN
EXAMPLE = json.loads(
(Path(__file__).parents[1] / "examples" / "bounded-execution-approval-request.json").read_text()
)
ADMISSION_DIGEST = EXAMPLE["binding"]["target"]["admission_digest"]
OTHER_DIGEST = "sha256:" + "ab" * 32
APPROVER = "uid=admin,ou=people,dc=netkingdom,dc=local"
FOUNDER = "uid=founder,ou=people,dc=netkingdom,dc=local"
def _identity(subject, principal_type, level):
return Identity(
subject=subject,
issuer="https://keycape.example",
audiences=("approval-engine",),
principal_type=principal_type,
tenant="tenant:platform",
roles=frozenset(),
scopes=frozenset({
"approval:create", "approval:read", "approval:approve",
"approval:revoke", "approval:consume",
}),
assurance={"level": level, "amr": ["pwd", "otp"], "source": "key-cape"},
evidence_ref=f"test-identity:{subject}",
)
def _app(engine):
return App(engine, StaticTokenAuthenticator({
"executor": _identity(EXAMPLE["binding"]["actor"], "service", "aal1"),
"aal1": _identity(APPROVER, "human", "aal1"),
"human": _identity(APPROVER, "human", "aal2"),
"founder": _identity(FOUNDER, "human", "aal2"),
"other": _identity("service:someone-else", "service", "aal1"),
}))
def test_synthetic_bounded_execution_carrier():
clock = [FROZEN]
engine = Engine(":memory:", clock=lambda: clock[0])
app = _app(engine)
try:
request = json.loads(json.dumps(EXAMPLE))
assert request["binding"]["action"] == "bounded-execution"
assert request["human_control"] is True and request["pdp_path"] is False
assert set(request["binding"]["target"]) == {"admission_id", "admission_digest"}
status, created = call(
app, "POST", "/v1/approvals", request, authorization="Bearer executor",
)
assert status == 201
aid = created["id"]
assert created["status"] == "requested"
assert created["binding"]["actor"] == request["binding"]["actor"]
assert created["binding"]["target"] == request["binding"]["target"]
status, refused = call(
app, "POST", f"/v1/approvals/{aid}/entries", {}, authorization="Bearer aal1",
)
assert status == 403 and refused["error"] == "forbidden"
status, approved = call(
app, "POST", f"/v1/approvals/{aid}/entries", {}, authorization="Bearer human",
)
assert status == 200 and approved["status"] == "approved"
assert approved["entries"][0]["subject_id"] == APPROVER
assert approved["entries"][0]["principal_type"] == "human"
assert json.loads(approved["entries"][0]["assurance"])["level"] == "aal2"
assert APPROVER != request["binding"]["principal"]
status, fetched = call(
app, "GET", f"/v1/approvals/{aid}", authorization="Bearer executor",
)
assert status == 200
for field in ("action", "actor", "principal", "purpose", "target"):
assert fetched["binding"][field] == request["binding"][field]
assert fetched["binding"]["digest"] == created["binding"]["digest"]
assert fetched["binding"]["human_control"] is True
assert fetched["binding"]["pdp_path"] is False
assert fetched["binding"]["required_assurance"] == {"level": "aal2"}
assert fetched["binding"]["actor_consumes"] is True
assert fetched["validity"] == request["validity"]
status, other = call(
app, "POST", f"/v1/approvals/{aid}/consume",
{"request_digest": ADMISSION_DIGEST, "decision_id": "run-1"},
authorization="Bearer other",
)
assert status == 403 and other["error"] == "forbidden"
status, spent = call(
app, "POST", f"/v1/approvals/{aid}/consume",
{"request_digest": ADMISSION_DIGEST, "decision_id": "run-1"},
authorization="Bearer executor",
)
assert status == 200
assert spent["status"] == "consumed"
assert spent["request_digest"] == ADMISSION_DIGEST
assert spent["idempotent"] is False
status, again = call(
app, "POST", f"/v1/approvals/{aid}/consume",
{"request_digest": ADMISSION_DIGEST, "decision_id": "run-1"},
authorization="Bearer executor",
)
assert status == 200 and again["idempotent"] is True
assert again["request_digest"] == ADMISSION_DIGEST
assert again["consumed_at"] == spent["consumed_at"]
status, conflict = call(
app, "POST", f"/v1/approvals/{aid}/consume",
{"request_digest": OTHER_DIGEST, "decision_id": "run-2"},
authorization="Bearer executor",
)
assert status == 409 and conflict["error"] == "conflict"
# Revoke before consume, by someone who is not the bound principal.
status, second = call(
app, "POST", "/v1/approvals", request, authorization="Bearer executor",
)
assert status == 201
second_id = second["id"]
assert call(
app, "POST", f"/v1/approvals/{second_id}/entries", {},
authorization="Bearer human",
)[0] == 200
status, revoked = call(
app, "POST", f"/v1/approvals/{second_id}/revoke",
{"reason": "founder stopped the run before consume"},
authorization="Bearer founder",
)
assert status == 200 and revoked["status"] == "revoked"
assert revoked["revoked_by"]["subject"] == FOUNDER
assert revoked["revoked_by"]["subject"] != request["binding"]["principal"]
status, blocked = call(
app, "POST", f"/v1/approvals/{second_id}/consume",
{"request_digest": ADMISSION_DIGEST, "decision_id": "run-revoked"},
authorization="Bearer executor",
)
assert status == 409 and blocked["error"] == "conflict"
# Consumption outside the window is refused. Issue and approve while
# the clock is inside, then move it onto the exclusive expiry.
short = json.loads(json.dumps(request))
short["validity"] = {
"not_before": "2026-08-29T11:00:00+00:00",
"expires_at": "2026-08-29T12:30:00+00:00",
}
status, third = call(
app, "POST", "/v1/approvals", short, authorization="Bearer executor",
)
assert status == 201
third_id = third["id"]
assert call(
app, "POST", f"/v1/approvals/{third_id}/entries", {},
authorization="Bearer human",
)[0] == 200
clock[0] = datetime(2026, 8, 29, 12, 30, tzinfo=timezone.utc)
status, late = call(
app, "POST", f"/v1/approvals/{third_id}/consume",
{"request_digest": ADMISSION_DIGEST, "decision_id": "run-late"},
authorization="Bearer executor",
)
assert status == 409 and "window" in late["message"]
early = json.loads(json.dumps(request))
early["validity"] = {
"not_before": "2026-08-29T13:00:00+00:00",
"expires_at": "2026-08-29T15:00:00+00:00",
}
clock[0] = FROZEN
status, fourth = call(
app, "POST", "/v1/approvals", early, authorization="Bearer executor",
)
assert status == 201
fourth_id = fourth["id"]
assert call(
app, "POST", f"/v1/approvals/{fourth_id}/entries", {},
authorization="Bearer human",
)[0] == 200
status, soon = call(
app, "POST", f"/v1/approvals/{fourth_id}/consume",
{"request_digest": ADMISSION_DIGEST, "decision_id": "run-early"},
authorization="Bearer executor",
)
assert status == 409 and "window" in soon["message"]
finally:
engine.close()

View file

@ -144,7 +144,7 @@ def test_legacy_v4_rows_are_not_reclassified_from_human_entries(tmp_path):
assert survivor.entries[0]["principal_type"] == "human"
assert survivor.human_control is False
assert upgraded.claim(obj.id)["binding"]["human_control"] is False
assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 8
assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 9
upgraded.close()

View file

@ -159,5 +159,5 @@ def test_legacy_v6_rows_migrate_to_null_floor(tmp_path):
survivor = upgraded.get(obj.id)
assert survivor.status == "approved" and survivor.required_assurance is None
assert upgraded.claim(obj.id)["valid_now"] is True
assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 8
assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 9
upgraded.close()

160
tests/test_revoker.py Normal file
View file

@ -0,0 +1,160 @@
"""APPROVAL-WP-0007-T03: verified revoker evidence on the object and the event.
The revoker is whoever holds approval:revoke. They are not required to be
the bound principal. The body cannot name the revoker.
"""
import json
from pathlib import Path
import pytest
from approval_engine.api import App, call
from approval_engine.auth import Identity, StaticTokenAuthenticator
from approval_engine.errors import Conflict, Unprocessable
from approval_engine.store import Engine, LATEST_SCHEMA_VERSION
from tests.conftest import FROZEN, approve, binding, validity
AAL2 = {"level": "aal2", "amr": ["pwd", "otp"], "source": "key-cape"}
def _identity(subject, principal_type="human", assurance=None):
return Identity(
subject=subject,
issuer="https://keycape.example",
audiences=("approval-engine",),
principal_type=principal_type,
tenant="tenant:platform",
roles=frozenset(),
scopes=frozenset({"approval:create", "approval:read", "approval:approve",
"approval:revoke", "approval:consume"}),
assurance=assurance or dict(AAL2),
evidence_ref=f"test-identity:{subject}",
)
@pytest.fixture
def people(engine):
return App(engine, StaticTokenAuthenticator({
"executor": _identity("service:nk-bind-executor", "service", {"level": "aal1", "source": "key-cape"}),
"founder": _identity("uid=founder,ou=people,dc=netkingdom,dc=local"),
"holder": _identity("admin"),
}))
def _issued(app):
status, created = call(
app, "POST", "/v1/approvals",
{"binding": binding(actor="service:nk-bind-executor", principal="admin"),
"validity": validity(), "human_control": True},
authorization="Bearer executor",
)
assert status == 201
status, _ = call(
app, "POST", f"/v1/approvals/{created['id']}/entries", {},
authorization="Bearer holder",
)
assert status == 200
return created["id"]
def _revocation(engine, approval_id):
payload = engine._conn().execute(
"SELECT payload_json FROM outbox WHERE approval_id=? AND class='revocation'",
(approval_id,),
).fetchone()[0]
return json.loads(payload)
def test_http_records_the_token_revoker_not_the_body(people, engine):
aid = _issued(people)
status, body = call(
people, "POST", f"/v1/approvals/{aid}/revoke",
{"reason": "pre-start kill switch", "subject": "admin", "principal_type": "human"},
authorization="Bearer founder",
)
assert status == 422
assert body["error"] == "unprocessable"
status, revoked = call(
people, "POST", f"/v1/approvals/{aid}/revoke",
{"reason": "pre-start kill switch"},
authorization="Bearer founder",
)
assert status == 200
assert revoked["status"] == "revoked"
assert revoked["revoked_by"] == {
"subject": "uid=founder,ou=people,dc=netkingdom,dc=local",
"principal_type": "human",
"assurance": AAL2,
"reason": "pre-start kill switch",
}
assert revoked["revoked_by"]["subject"] != revoked["binding"]["principal"]
event = _revocation(engine, aid)
assert event["action"] == "approval.revocation"
assert event["actor"] == "service:nk-bind-executor"
assert event["details"]["revoked_by"] == revoked["revoked_by"]
assert event["details"]["binding_digest"] == revoked["binding"]["digest"]
status, claim = call(people, "GET", f"/v1/approvals/{aid}/claim", authorization="Bearer founder")
assert status == 200
assert claim["reason_code"] == "revoked"
assert "revoked_by" not in claim
def test_reason_is_optional_and_bounded(people):
aid = _issued(people)
status, revoked = call(
people, "POST", f"/v1/approvals/{aid}/revoke", {},
authorization="Bearer founder",
)
assert status == 200
assert "reason" not in revoked["revoked_by"]
other = _issued(people)
status, body = call(
people, "POST", f"/v1/approvals/{other}/revoke",
{"reason": "x" * 201},
authorization="Bearer founder",
)
assert status == 422
status, body = call(
people, "POST", f"/v1/approvals/{other}/revoke",
{"reason": "two\nlines"},
authorization="Bearer founder",
)
assert status == 422
status, still = call(people, "GET", f"/v1/approvals/{other}", authorization="Bearer founder")
assert still["status"] == "approved"
assert "revoked_by" not in still
def test_internal_revoke_omits_evidence_and_stays_terminal(engine):
obj = approve(engine)
revoked = engine.revoke(obj.id)
assert revoked.status == "revoked"
assert "revoked_by" not in revoked.as_dict()
event = _revocation(engine, obj.id)
assert "revoked_by" not in event["details"]
with pytest.raises(Conflict):
engine.revoke(obj.id, revoker=_identity("uid=founder,ou=people,dc=netkingdom,dc=local"))
with pytest.raises(Unprocessable, match="reason"):
engine.revoke(obj.id, reason="too late")
def test_legacy_rows_migrate_without_a_revoker(tmp_path):
path = tmp_path / "v8.sqlite"
eng = Engine(path, clock=lambda: FROZEN)
obj = approve(eng)
eng.revoke(obj.id)
eng._conn().execute("ALTER TABLE approvals DROP COLUMN revoked_by_subject")
eng._conn().execute("ALTER TABLE approvals DROP COLUMN revoked_by_principal_type")
eng._conn().execute("ALTER TABLE approvals DROP COLUMN revoked_by_assurance")
eng._conn().execute("ALTER TABLE approvals DROP COLUMN revocation_reason")
eng._conn().execute("PRAGMA user_version=8")
eng._conn().commit()
eng.close()
upgraded = Engine(path, clock=lambda: FROZEN)
survivor = upgraded.get(obj.id)
assert survivor.status == "revoked"
assert "revoked_by" not in survivor.as_dict()
assert upgraded.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 9
upgraded.close()

View file

@ -79,7 +79,7 @@ def test_v5_migration_preserves_unknown_provenance_and_existing_evidence(tmp_pat
with pytest.raises(StoreUnavailable, match="migrate"):
Engine(path, auto_migrate=False)
engine = Engine(path, clock=lambda: FROZEN)
assert engine.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 8
assert engine.storage_status()["schema_version"] == LATEST_SCHEMA_VERSION == 9
assert engine.get(obj.id).entries[0]["tenant_source"] is None
assert engine.get(obj.id).binding_digest == digest
assert engine.get(obj.id).human_control is True

View file

@ -4,12 +4,12 @@ type: workplan
title: "Approval fact contract and reusable Go data adapter"
domain: infotech
repo: approval-engine
status: active
status: blocked
flavor: implementation
owner: codex
topic_slug: netkingdom
created: "2026-10-16"
updated: "2026-10-23"
updated: "2026-11-07"
depends_on: []
state_hub_workstream_id: "777b17de-2c1e-5ce5-aa82-c99e5137e956"
---
@ -21,6 +21,12 @@ T03's pure reusable data adapter, agreed with Access before implementation.
No broad residual activation, credential transport runtime, trust admission,
policy eligibility or live action is included.
2026-11-07: status is `blocked`. T01 and T03 are done in this repo. T02 stays
`wait` on access-engine. The producer Go adapter bytes match the copy in
access-engine `third_party/approvalfact`. Closing T02 needs Access
conformance evidence under FLEX-WP-0024-T06, which is still `progress`. No
further change in this repo closes it.
## Review authoritative claim guarantees and publish thin consumer vectors
```task

View file

@ -4,12 +4,12 @@ type: workplan
title: "Preserve fractional authoritative approval and consumption time"
domain: infotech
repo: approval-engine
status: active
status: blocked
flavor: implementation
owner: codex
topic_slug: netkingdom
created: "2026-10-16"
updated: "2026-10-23"
updated: "2026-11-07"
depends_on: []
state_hub_workstream_id: "9b859b2f-f98a-5104-b0d5-72bfdaf56da4"
---
@ -20,6 +20,11 @@ the actual signed decision. INTENT basis: valid right now, exact-bound atomic
consumption and faithful source evidence. Fix source time, not Access backdating
or a consumer waiver. This does not promote native issuer/deployment authority.
2026-11-07: status is `blocked`. This plan is not current work. T01 stays
`done`. T02 stays `progress`; the remaining receipt is the Access and
secrets-engine immediate strict CAS remeasurement, and no task status was
changed to park the plan.
## Preserve clock precision and one post-lock consumption instant
```task

View file

@ -4,12 +4,12 @@ type: workplan
title: "Bounded-execution admission carrier: assurance floor, correspondence and revoker evidence"
domain: infotech
repo: approval-engine
status: active
status: blocked
flavor: implementation
owner: approval-engine
topic_slug: netkingdom
created: "2026-10-31"
updated: "2026-11-01"
updated: "2026-11-07"
depends_on: []
state_hub_workstream_id: "af365506-f451-50b5-a0a2-f0ba9a0a4d9c"
---
@ -22,6 +22,10 @@ revoke already work. This plan closes the engine-side gaps the answer found.
Every change is opt-in at issue, so existing approvals and consumers keep
their behavior. Native digest, CAS consumption and no-unconsume are unchanged.
2026-11-07: status is `blocked`. This plan is not current work. T01 through
T04 stay as recorded. T05 stays `wait` on KeyCape subject equality. T06 stays
`todo` for the later self-acceptance exception.
## Declared minimum approver assurance
```task
@ -100,7 +104,7 @@ the entry subject itself (`docs/bounded-execution-admission-carrier.md` §3).
```task
id: APPROVAL-WP-0007-T03
status: todo
status: done
priority: medium
state_hub_task_id: "7ceb5ffd-3ba8-5e9d-96a2-5e89c38a5c43"
```
@ -112,11 +116,18 @@ terminal semantics. This is the evidence for the founder's pre-start kill
switch (NK-WP-0056-T07). Do not require the revoker to equal the principal:
revocation must stay possible without the holder.
Done 2026-11-07 in source (schema v9, `tests/test_revoker.py`, contract in
`docs/approval-claim.md`). `POST /revoke` accepts only an optional one-line
reason of at most 200 characters. The token supplies the revoker. GET and
`details.revoked_by` serve it. The claim omits it. An internal revoke with no
verified identity still revokes and omits the member, as do migrated rows.
Not released or deployed: a v9 image and a founder-run rollout are still needed.
## Synthetic bounded-execution carrier proof
```task
id: APPROVAL-WP-0007-T04
status: todo
status: done
priority: medium
state_hub_task_id: "673890fe-3d49-5fe3-8ad7-c8c73e4d9376"
```
@ -130,6 +141,14 @@ different digest returns 409; revoke before consume gives 409 on consume;
consumption outside the window is refused. Synthetic fixtures only, with no
live service.
Done 2026-11-07. Example `examples/bounded-execution-approval-request.json`.
Test `tests/test_bounded_execution_carrier.py` runs that request in process:
aal1 entry refused by the declared floor, human aal2 approves, GET matches
the binding, the actor consumes the admission digest, the same digest retries,
a different digest is 409, a founder who is not the principal revokes before
consume and consume is 409, and consume before the window and at expiry is
409. No live service.
## Founder decision 2026-10-31
Approved by the founder (`proposed` -> `ready`) after the NK-WP-0056-T02 owner answers (hub decision 19afd15e).
@ -140,6 +159,7 @@ Approved by the founder (`proposed` -> `ready`) after the NK-WP-0056-T02 owner a
id: APPROVAL-WP-0007-T06
status: todo
priority: low
state_hub_task_id: "db25c052-3c60-5328-9993-b51f87fc5ba6"
```
Founder direction, 2026-11-01 (gate-house GH-DEC-2026-026 addendum, relayed by the net-kingdom coordinator).