Answer NK-WP-0056-T02 bounded-execution carrier request; propose APPROVAL-WP-0007

Owner answer in docs/bounded-execution-admission-carrier.md: binding, consume
and revoke are supported today; aal2 is recorded but not enforced. Workplan
APPROVAL-WP-0007 proposes an assurance floor, opt-in approver/consumer
correspondence, revoker evidence and a synthetic carrier proof.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 994874@bnt-lap001
Assistant-Session: 6017c416-d619-47eb-bd1a-f670a02d7d87
This commit is contained in:
tegwick 2026-10-31 14:19:29 +01:00
parent 297e866e2c
commit 5028fe2231
2 changed files with 295 additions and 0 deletions

View file

@ -0,0 +1,203 @@
# Bounded Execution Admission — Approval Engine carrier answer
Status: owner answer to NetKingdom NK-WP-0056-T02 (hub message
`40344380-ea33-4670-9b27-dafca1699d36`), 2026-10-31. Source reviewed at the
commit that adds this file. This is an engine-owner technical answer, not a
founder decision, identity registration or runtime admission. Follow-up work:
`workplans/APPROVAL-WP-0007-bounded-execution-carrier.md`.
Inputs: NetKingdom `canon/standards/bounded-execution-admission_v0.1.md`
(proposed), its schema and validator, the example
`examples/bounded-execution-admission/bind-rotation-svc-authelia.yaml`, and
`docs/proposals/2026-10-31-nk56-t02-owner-requests.md`.
## Summary
| Question | Answer |
| --- | --- |
| 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) |
## 1. Binding
The native binding has exactly five fields — `action`, `actor`, `principal`,
`purpose`, `target` — and `target` must be an object of any shape
(`approval_engine/binding.py` `canonical_binding`). The native digest is
SHA-256 over their sorted-key JSON (`binding_digest`). So this binding fits
unchanged:
```json
{
"binding": {
"action": "bounded-execution",
"actor": "service:nk-bind-executor",
"principal": "admin",
"purpose": "<spec.purpose>",
"target": {"admission_id": "<spec.id>", "admission_digest": "sha256:<64 hex>"}
},
"validity": {"not_before": "<window start>", "expires_at": "<window end>"},
"required_count": 1,
"human_control": true,
"pdp_path": false
}
```
Validity is a separate `not_before`/`expires_at` pair (`store.py`
`Engine.create`); consume enforces it with one post-lock instant
(not-before inclusive, expiry exclusive; `Engine.consume`). Nothing in the
engine interprets `action`, so `bounded-execution` needs no registration here.
The binding is immutable after create: no route updates it (`api.py`
`dispatch`).
Things the requester must know:
- **The actor creates.** `POST /v1/approvals` (scope `approval:create`) refuses
unless `binding.actor` equals the authenticated token subject
(`api.py` `dispatch`, "binding.actor must match the authenticated subject").
The executor's service identity therefore files the request in `requested`
state; the founder then approves it. Neither the founder nor NetKingdom
tooling can create an approval whose actor is the executor. The string
`service:nk-bind-executor` must equal the `sub` KeyCape issues for that
client; the engine does no mapping.
- **Declare `human_control: true`.** Then `add_entry` refuses any non-human
approver and consume refuses without the required human entries
(`Engine.add_entry`, `Engine.consume`). This keeps the service actor from
approving its own request.
- **`pdp_path: false` for the pilot.** `pdp_path: true` requires a PDP request
digest at issue (GH-DEC-2026-008). There is no access-engine decision for
executor operations yet, so the pilot is off the PDP path. When access-engine
adds one, issue with `pdp_path: true` and its `pdp_digest`.
- **`principal` is a bound label, not an approver constraint.** The engine does
not check that the approving human equals `binding.principal`. Any human
holding `approval:approve` for the tenant can approve. Proposal
APPROVAL-WP-0007-T02 lets an issue declare that correspondence; until then
the executor checks the entry subject itself (see §3).
## 2. Assurance (BEA-C4)
**Recorded today.** `POST /v1/approvals/{id}/entries` (scope
`approval:approve`) takes no body fields for identity. It stores the verified
token's `subject`, `principal_type`, `tenant_source`, `evidence_ref` and the
whole `assurance` claim (for example `{"level":"aal2","amr":["pwd","otp"],
"source":"key-cape"}`) as JSON (`api.py` `dispatch`, `auth.py`
`identity_from_claims`, `store.py` `Engine.add_entry`). A caller cannot spoof it
in the body (`tests/test_auth.py`, spoofed-assurance test). The entry and its
assurance appear in `GET /v1/approvals/{id}` (`Approval.as_dict`) and in the
`approval.issuance` outbox event threshold evidence (`_threshold_evidence`).
**Not enforced today.** The engine stores whatever level the token carries.
Nothing at issue declares a minimum level, and `add_entry`, `_evaluate` and
`consume` do not compare levels. An aal1 approval of a bounded-execution
request would become `approved` and consumable.
**Change (in owner scope): APPROVAL-WP-0007-T01.** Add an optional
`required_assurance` (minimum level, for example `aal2`) declared at issue and
stored with the object. `add_entry` refuses a weaker entry, consume and the
claim refuse when the requirement is unmet, the claim reports it, and
supersession carries it forward. The level comes only from the verified token.
Whether KeyCape puts `aal2` into the `assurance` claim for a step-up login is
KeyCape's question (identity owner), not this engine's.
**Until T01 ships,** the executor's GET check (§3) must require that every
entry has `principal_type: "human"` and `assurance.level == "aal2"`. Note that
this makes the executor the only enforcement point.
## 3. Consume
**Scope and principal.** `POST /v1/approvals/{id}/consume` requires scope
`approval:consume`, the store's tenant, and `principal_type` `service` or
`agent` (`api.py` `dispatch`). For the executor's service principal (pilot
`service:nk-bind-executor`), KeyCape registers `approval:create`,
`approval:read` and `approval:consume`. The founder's human identity needs
`approval:approve` and `approval:revoke`. Registration belongs to KeyCape.
**`request_digest` (proposal).** The engine requires only syntax
(`sha256:<64 lowercase hex>`, `binding.py` `require_digest`). It does not
compare the digest with anything (`Engine.consume`, and
`docs/task-execution-consumption-contract.md` "Exact binding
responsibilities"). The normal meaning (`docs/approval-consumption.md`) is the
PDP decision's request digest, but this pilot has no PDP decision. The owner
proposes:
- `request_digest` = the admission digest (`target.admission_digest`, already
`sha256:<hex>`). The consumption record then names exactly what was run. A
retry with the same admission is idempotent (`idempotent: true`, no second
use event), and any other digest conflicts with 409.
- `decision_id` = the executor's `run_id` (optional provenance; stored and
returned on idempotent retries).
- Once access-engine gates executor operations, move to the PDP path:
`pdp_path: true`, `pdp_digest` at issue, and the PDP request digest at
consume.
This choice belongs to NetKingdom (it owns the standard). The engine accepts
either one.
**Independent GET comparison: yes, that is the intended reading.**
`GET /v1/approvals/{id}` (scope `approval:read`) is the existing authoritative
read surface for confirming consumption
(`docs/task-execution-consumption-contract.md`, "Existing route and trust
boundary"). The engine does not check whether a digest is equivalent and does
not check that the consuming subject equals `binding.actor`. Any service in
the tenant that holds `approval:consume` could spend the object. That cannot
make anything run, but it can deny the run. Proposal APPROVAL-WP-0007-T02 adds
an opt-in check that the consumer is the actor. The reading we intend for the
executor:
1. Before consuming, GET the object and require: `status == "approved"`; the
served `binding` (with `binding.digest`) equals the binding the executor
computes from the admission document it holds, with `target.admission_digest`
equal to its own digest of that document; `actor` is its own identity;
`validity` covers now; `human_control: true`; entries are human, at `aal2`
(until T01), and come from the expected approver. Use the entries only in
memory and discard them. Never copy approver entries into receipts or logs.
2. Consume once with the request digest above. Validate the direct response:
exact `approval_id`, `status: consumed`, the same `request_digest`, and a
strict boolean `idempotent`. `execution_confirmation.py`
`ApprovalHTTPClient.consume_exact` does this. Treat `idempotent: true` as
historical, never as new authority to run.
3. If the outcome is unknown (timeout), do not consume again to find out. GET
and compare `consumption.request_digest` and `consumed_at`. An unknown
outcome stops the run (`on_unknown: stop`).
The staged `confirm_consumption` helper requires `pdp_path` and
`human_control` both true, so it does not fit the off-PDP pilot as written.
The executor runtime (NetKingdom-owned) should implement the checks above. A
pilot-mode confirmation helper can come later if the runtime wants one.
Revocation, supersession and expiry all leave the binding immutable, and
consume refuses every non-`approved` status. That makes GET-then-consume safe:
if the founder revokes between the two calls, consume returns 409.
## 4. Revocation (kill switch before start)
**Supported today.** `POST /v1/approvals/{id}/revoke` (scope `approval:revoke`)
moves `requested` or `approved` to terminal `revoked` by compare-and-swap. It
writes an `approval.revocation` outbox event in the same transaction
(`store.py` `Engine.revoke` → `_terminal`). It needs no cooperation from the
holder. Every later consume refuses (`cannot consume from status revoked`),
and the claim reports `valid_now: false`, `reason_code: revoked`. An
`audit-core` outage does not block a revocation (outbox is local; SCOPE.md).
Limits:
- **A consumed approval cannot be revoked.** Consumption is terminal, so
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.
- Supersession (`approval:supersede`) also stops an unconsumed approval and
links a successor. For the pilot, revoke is the right kill switch.
## Not owned here
- Executor and founder scope registrations, the client `sub` and `aal2` in
`assurance`: KeyCape.
- A policy decision for executor operations: access-engine.
- Execution journal, budget, in-flight stop and receipts: NetKingdom executor
runtime and audit-core.

View file

@ -0,0 +1,92 @@
---
id: APPROVAL-WP-0007
type: workplan
title: "Bounded-execution admission carrier: assurance floor, correspondence and revoker evidence"
domain: infotech
repo: approval-engine
status: proposed
flavor: implementation
owner: approval-engine
topic_slug: netkingdom
created: "2026-10-31"
updated: "2026-10-31"
depends_on: []
---
Demand: NetKingdom NK-WP-0056-T02 owner request (hub message
40344380-ea33-4670-9b27-dafca1699d36) to carry a Bounded Execution Admission
v0.1 (proposed) as an approval. Owner answer:
`docs/bounded-execution-admission-carrier.md`. The binding, consume and
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.
## Declared minimum approver assurance
```task
id: APPROVAL-WP-0007-T01
status: todo
priority: high
```
Add an optional `required_assurance` (minimum `level`, ordered aal1 < aal2 <
aal3) to `POST /v1/approvals`. Store it and serve it in the object and the
claim, and inherit it on supersede. `add_entry` refuses an entry whose
verified token `assurance.level` is weaker or missing. `_evaluate` and
`consume` refuse when an existing entry does not meet it. Schema migration
leaves old rows NULL, meaning no floor. Tests cover aal1 refused, aal2
accepted, missing level refused, supersede inheritance and the claim
reason_code. Serves BEA-C4. Whether KeyCape emits `aal2` is KeyCape's
question, outside this plan.
## Opt-in approver and consumer correspondence
```task
id: APPROVAL-WP-0007-T02
status: wait
priority: medium
```
Proposal for owner and Gate House agreement before implementation, because
it gives `principal` and `actor` an enforced meaning. Two optional issue
flags:
- `principal_approves`: every entry subject must equal `binding.principal`.
- `actor_consumes`: the consuming subject must equal `binding.actor`.
Today any human with `approval:approve` can approve, and any tenant service
with `approval:consume` can spend the object (a denial-of-run risk). Wait
reason: semantic decision. NetKingdom's actor and principal strings must also
match KeyCape subjects exactly.
## Revoker identity in revocation evidence
```task
id: APPROVAL-WP-0007-T03
status: todo
priority: medium
```
Record the verified revoker's subject, principal_type and assurance, plus an
optional bounded `reason`, in the `approval.revocation` outbox event. Persist
them on the object (the `revoked_by` fields) without changing the CAS or
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.
## Synthetic bounded-execution carrier proof
```task
id: APPROVAL-WP-0007-T04
status: todo
priority: medium
```
Add an in-process test and an example request covering the full path: the
executor (service) creates the request (`action: bounded-execution`,
`target: {admission_id, admission_digest}`, `human_control: true`,
`pdp_path: false`); a human aal2 entry approves it; GET binding comparison;
consume with `request_digest` = admission digest; idempotent retry; a
different digest returns 409; revoke before consume gives 409 on consume;
consumption outside the window is refused. Synthetic fixtures only, with no
live service.