diff --git a/docs/bounded-execution-admission-carrier.md b/docs/bounded-execution-admission-carrier.md new file mode 100644 index 0000000..090c5db --- /dev/null +++ b/docs/bounded-execution-admission-carrier.md @@ -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": "", + "target": {"admission_id": "", "admission_digest": "sha256:<64 hex>"} + }, + "validity": {"not_before": "", "expires_at": ""}, + "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:`). 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. diff --git a/workplans/APPROVAL-WP-0007-bounded-execution-carrier.md b/workplans/APPROVAL-WP-0007-bounded-execution-carrier.md new file mode 100644 index 0000000..fa1c1da --- /dev/null +++ b/workplans/APPROVAL-WP-0007-bounded-execution-carrier.md @@ -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.