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.