Founder decision 2026-11-01, relayed by the net-kingdom coordinator: "Agree to split APPROVAL-WP-0007-T02: build actor_consumes now (opt-in at issue; the bounded executor will set it); principal_approves keeps waiting for the KeyCape subject mapping." - Optional actor_consumes (boolean) at issue, schema v8. Consume refuses with 403 unless the consuming subject equals binding.actor, also on idempotent retries; internal calls without a consumer fail closed. Inherited on supersession (existing-successor mismatch 409), in audit details, served in GET/claim only when declared so undeclared claims keep exact bytes. Legacy rows migrate without the flag. - GH-DEC-2026-026 Ruling 6: under human_control, add_entry refuses (403) the binding actor as approver. human_control already covered a service actor; this covers a human actor. - Workplan: T02 done (actor_consumes); new T05 principal_approves (wait). - Not released or deployed: needs a v8 image and founder-run rollout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 5502@bnt-lap001 Assistant-Session: 68b3b02f-5d0f-4e71-8421-796f62e50196
224 lines
12 KiB
Markdown
224 lines
12 KiB
Markdown
# 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,
|
|
"required_assurance": {"level": "aal2"},
|
|
"actor_consumes": 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. GH-DEC-2026-026 Ruling 6 (the actor is never an
|
|
approver of its own approval) is therefore already met for this service
|
|
actor by `human_control` alone. For a human actor it is not, so `add_entry`
|
|
also refuses (403), under `human_control`, an entry whose subject equals
|
|
`binding.actor` (`docs/approval-claim.md`).
|
|
- **Declare `actor_consumes: true`** (APPROVAL-WP-0007-T02, schema v8, in
|
|
source, not yet deployed). Consume then refuses with 403 unless the
|
|
consuming subject equals `binding.actor` (see §3). The `required_assurance`
|
|
and `actor_consumes` members are accepted only by a v7/v8 image; until one is
|
|
live, omit them and keep the executor's own checks.
|
|
- **`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-T05 (`principal_approves`, split from T02 by founder
|
|
decision 2026-11-01) would let an issue declare that correspondence; it
|
|
waits for the KeyCape subject mapping. 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.
|
|
|
|
**Update (APPROVAL-WP-0007-T01, in source, not yet deployed):** the engine now
|
|
accepts `required_assurance: {"level": "aal2"}` at issue and enforces it at
|
|
entry, claim and consume (`docs/approval-claim.md`). The executor should issue
|
|
with it once the v7 image is live, and keep its own check until then.
|
|
|
|
**Until T01 is deployed,** 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. Without a
|
|
declaration it 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.
|
|
**Update (APPROVAL-WP-0007-T02, in source, not yet deployed):** issue with
|
|
`"actor_consumes": true` and consume refuses (403) every other subject,
|
|
including on idempotent retries; GET and the claim serve
|
|
`binding.actor_consumes: true`. 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.
|