diff --git a/config/service-clients.example.yaml b/config/service-clients.example.yaml index b203abd..da75f16 100644 --- a/config/service-clients.example.yaml +++ b/config/service-clients.example.yaml @@ -38,8 +38,24 @@ clients: roles: ["secrets-engine"] tokenLifetime: "15m" + # WITHDRAWN BY THE REQUESTING OWNER, 2026-09-09. approval-engine asked that this + # client not be provisioned: nothing in their repository obtains an OAuth token, + # and scope by scope the bundle does not describe a single actor — + # approval:approve now belongs to the informed-decision human client, + # approval:emit is redundant (the server emits its own heartbeat with no token), + # and no requester identity was ever settled for approval:create. It was a + # convenience bundle written when a lifecycle operator was assumed to exist. + # + # Kept rather than deleted at their request: verifier custody CCR-2026-0018 + # stands, so the client stays authenticable if a presenter ever appears. It is + # already live from the 2026-09-09 attended rollout, so this annotation does not + # remove it — see KEY-WP-0013-T02. + # + # Do not widen it, and do not treat it as a template. When a presenter is named, + # approval-engine will re-request NARROWED to that presenter's scopes, and + # approval:approve must not travel with the operational scopes. - clientId: "approval-engine-operator" - displayName: "approval-engine lifecycle operator" + displayName: "approval-engine lifecycle operator (withdrawn: no presenter)" audience: "approval-engine" allowedScopes: ["approval:create", "approval:read", "approval:approve", "approval:revoke", "approval:supersede", "approval:observe", "approval:emit"] grantTypes: ["client_credentials"] diff --git a/docs/approval-engine-provisioning-request.yaml b/docs/approval-engine-provisioning-request.yaml index 0875120..1de4348 100644 --- a/docs/approval-engine-provisioning-request.yaml +++ b/docs/approval-engine-provisioning-request.yaml @@ -92,6 +92,15 @@ verification: # gate-house's. presenting_actor_note: client_id: approval-engine-operator + # ANSWERED 2026-09-09 by approval-engine: the presenter is nobody, and they have + # withdrawn the request. Nothing in their repository obtains an OAuth token; the + # engine only verifies them. They asked railiance-platform to cancel the + # client-side reader CCR-2026-0020 as an owner decision. Verifier custody + # CCR-2026-0018 stands. The separation-of-duties observation below is what + # prompted the question and is retained because it constrains the re-request: + # approval:approve must not travel with the operational scopes. + status: withdrawn-by-requesting-owner + already_live: true # provisioned in the 2026-09-09 attended rollout; withdrawal does not unprovision it observation: | The registration as issued carries both approval:create and approval:approve, so a single presenter can create an entry and then approve it. That is a diff --git a/docs/tenant-claim-contract.md b/docs/tenant-claim-contract.md index 965952b..9f0926c 100644 --- a/docs/tenant-claim-contract.md +++ b/docs/tenant-claim-contract.md @@ -104,10 +104,28 @@ A client registration may therefore declare a `tenant`, and `humanTenant` in | a zone | the same zone | that zone; client and directory agree | | a zone | a **different** zone | **issuance is refused** | -The last row is the point. A registration can bind a zone for users the -directory has not placed, and can never relabel a user it *has* placed. That -case fails closed rather than picking a winner, because either answer would be a -silent cross-tenant assertion. The refusal is a `403` with +The last row is the point, and it is **normative** — GH-DEC-2026-013 condition +(a). A registration can bind a zone for users the directory has not placed, and +can never relabel a user it *has* placed. That case fails closed rather than +picking a winner, because either answer would be a silent cross-tenant +assertion. + +Note the direction that cuts, which is not the intuitive one: **a future change +preferring the directory is also void.** "The directory won" is still a winner, +and picking any winner converts a refusal into a silent cross-tenant assertion. +Row four is not a design choice available for later optimisation in either +direction. + +This shape is a **declared bounded gap**, not the terminal state. Directory- +sourced is (GH-DEC-2026-013 §1): a registration is a statement about the *actor*, +a tenant is a property of the *principal*, and sourcing the second from the first +collapses two identities in the direction that widens. It was granted because the +one case distinguishing it from the correct resolution fails closed, and because +it converges on the terminal state by subtraction rather than needing to be +unwound — the same code stops supplying the zone and starts enforcing agreement +with it the day the directory carries tenants. On that day §2 is *spent*, not +merely unused. Registered as a §13 gap at `net-kingdom@f9e1611`; the directory +adapter has no named owner yet. The refusal is a `403` with `error_type: tenant_binding`, distinct from an authentication failure, so an operator can tell a misconfigured registration from a rejected login. @@ -124,9 +142,15 @@ it is stated here so a future change to registration policy has to confront it. > **A client-declared tenant is safe only while client registrations are static > and deployment-owned.** If KeyCape ever admits dynamic client registration, > anyone able to register a client can name its users' tenant and relabel -> unplaced users into a zone. The deliberate binding becomes an escalation. That -> change may not be made without first deciding what happens to client-declared -> tenants — and removing this paragraph is not that decision. +> unplaced users into a zone. The deliberate binding becomes an escalation. +> +> **Lifting the exclusion voids this rule rather than reopening it** +> (GH-DEC-2026-013 condition (b)). This document previously said the rule "must +> be revisited", and gate-house strengthened that against us: *revisited* implies +> the answer might survive review, and it would not. If dynamic client +> registration is ever admitted, the registration-bound shape is void **that +> day** and the directory becomes the only source, whatever state the adapter is +> in. Removing this paragraph is not the decision. Recorded at the request of informed-decision, who will hold the approver registration and asked for it in the contract rather than in a message. diff --git a/workplans/KEY-WP-0013-approval-engine-resource-audience.md b/workplans/KEY-WP-0013-approval-engine-resource-audience.md index 8bc39b3..a4f98a5 100644 --- a/workplans/KEY-WP-0013-approval-engine-resource-audience.md +++ b/workplans/KEY-WP-0013-approval-engine-resource-audience.md @@ -311,6 +311,39 @@ separation-of-duties question about who may hold the credential, not a defect in the token: KeyCape issues the grants approval-engine requested. The decision is approval-engine's and the doctrine question is gate-house's. +**Answered 2026-09-09, and the answer withdrew the client.** approval-engine +checked and the presenter is nobody (`056aef0d`). Nothing in their repository +obtains an OAuth token — the engine only verifies them, there is no +client-credentials grant in the package, and the one outbound credential is a +mounted audit-token, not an OAuth client. Scope by scope the bundle never +described a single actor: `approval:approve` now belongs to the informed-decision +human client, `approval:emit` is redundant because the running server emits its +own heartbeat with no token, and no requester identity was ever settled for +`approval:create`. It was a convenience bundle written when a lifecycle operator +was assumed to exist. + +They asked railiance-platform to cancel the client-side reader CCR-2026-0020 as +an owner decision. Verifier custody CCR-2026-0018 stands, so the client remains +authenticable if a presenter appears, and they asked for it to be kept on that +basis rather than removed. Annotated as withdrawn in +`config/service-clients.example.yaml` and the provisioning packet; not deleted. +The separation-of-duties observation above is retained because it constrains the +re-request: when a presenter is named the registration comes back **narrowed**, +and `approval:approve` must not travel with the operational scopes. + +**One thing "do not provision" cannot undo, raised rather than filed.** This +client is already live — provisioned in the 2026-09-09 attended rollout and +verified in `docs/evidence/2026-09-09-keycape-verifier-admission.json`. So the +estate now holds a confidential credential that can create *and* approve +approvals, that nobody presents, and that no consumer is waiting on. That is +standing capability with no counterparty, which is a worse resting state than +either provisioning it for a named presenter or disabling it. KeyCape owns client +disablement, so the action is ours to take and not ours to decide alone; raised +with approval-engine and railiance-platform rather than exercised, since removing +a live registration is a deployment change. Recorded here so it is not lost: +**an unpresented create+approve credential should not be left standing +indefinitely.** + ## Assign and register the human approver browser client ```task