# informed-decision-sitting-requester: add `approval:read` Prepared 2026-11-01 by key-cape, the owner of the registration source. The request came from informed-decision through the net-kingdom coordinator, with the founder's approval ("Ask both now"). Requesting source: informed-decision `0e66a33`, INFD-WP-0010, `docs/decision-services-alignment-answer.md`, blocker 2 and open question 4. **State:** the source change is committed in key-cape. The live registry is **not** changed. Until the founder runs the live steps below, informed-decision's `present_existing` intake keeps failing closed with 503 `approval_unreadable`. Every other submitter is unaffected. ## The change Only one field changes, `allowedScopes`: ```diff "audience": "approval-engine", "allowedScopes": [ - "approval:create" + "approval:create", + "approval:read" ], ``` Nothing else in the record changes: client id, audience, subject, tenant, role, lifetime, grant, client type and `secretRef`. Source: `config/service-clients.example.yaml`. Machine-readable copy: `docs/sitting-requester-registration.json`. A test keeps the two equal (`TestSittingRequesterRegistrationShapeIsExact`). The full live record after the change (non-secret; `secretRef` is a custody *name*, not a value): ```json { "clientId": "informed-decision-sitting-requester", "displayName": "Informed Decision compact-sitting approval requester", "audience": "approval-engine", "allowedScopes": ["approval:create", "approval:read"], "grantTypes": ["client_credentials"], "clientType": "confidential", "secretRef": "env:KEYCAPE_INFORMED_DECISION_SITTING_REQUESTER_CLIENT_SECRET", "serviceSubject": "informed-decision", "tenant": "tenant:platform", "roles": ["informed-decision-sitting-requester"], "tokenLifetime": "15m" } ``` The record before the change is the same file at key-cape `655d8d3` (`git show 655d8d3:docs/sitting-requester-registration.json`). That is the row railiance-platform provisioned on 2026-09-15 (CCR-2026-0026/0027). ## Why this shape - **Read only, never consume or approve.** `present_existing` must GET `/v1/approvals/{id}` to check the approval before presenting it. That call needs `approval:read` and nothing else. Consumption still belongs to a PEP. Approval still belongs to the human PKCE client `informed-decision-approver`. The tests refuse `approval:consume`, `approval:approve` and `openid` on this client. - **Same client, not a second read-only client.** The read belongs to the same actor, informed-decision, as the create. It authenticates with the same custody secret. A second client would add a second secret, a second CCR pair and a second ESO binding, and it would separate no actor. Each token still carries one scope: informed-decision's `RequesterTokenProvider` asks for exactly `approval:create` or exactly `approval:read`, and it refuses a token whose `scope` claim differs from the one it asked for. - **Audience `approval-engine`, subject `informed-decision`, tenant `tenant:platform`: all unchanged.** approval-engine serves a GET by scope and exact store tenant. The approval being presented was filed in `tenant:platform` by another actor (for example `service:nk-bind-executor`), so a read under this tenant is enough. A read does not let the presenter create in another actor's name, because `binding.actor == sub` still applies to create. - **Residual widening.** KeyCape's client_credentials grant gives every allowed scope when a caller sends no `scope` parameter (`src/internal/server/oidc/token.go`). After this change, a holder of the secret who sends no `scope` gets a create+read token. That is still no consume and no approve, and the secret has only two readers: KeyCape through ESO and the informed-decision workload. ## Who applies it live, and how The live registry is the `sso/keycape-config` Secret. KeyCape reads it only at startup. Changes to that Secret are founder-run. net-kingdom's contained helper `sso-mfa/k8s/keycape/keycape-client.py` makes them: it changes only this client's lines, compares and swaps on uid and resourceVersion, runs KeyCape's `check-config` on the live and proposed registry, and prints no values. That helper treats an update as **remove, then add**. Each step needs an acceptance record. **What governs it.** There is no acknowledged case type for this client. `oidc-service-client` 0.1 is still *proposed*. This client also would not fit it: the type requires `service:`-prefixed subjects, and this subject is the bare `informed-decision`, which approval-engine's create rule pins. So the acceptance record is the founder's explicit decision for this scope change, which is the same founder approval this request was raised under. A hub decision id is the natural record. **Custody does not change:** same secret, same env binding on the Deployment, same ESO object. So no new CCR is needed, and CCR-2026-0026/0027 stand as they are. Steps (founder, attended, admin kubeconfig as for the other keycape-client runs; run from the net-kingdom checkout): ```bash cd ~/net-kingdom/sso-mfa/k8s/keycape S=$(mktemp -d) git -C ~/key-cape show 655d8d3:docs/sitting-requester-registration.json > "$S/old.json" cp ~/key-cape/docs/sitting-requester-registration.json "$S/new.json" KC= ACC= # 1. inspect: old row must be present and identical (change_needed true for remove) python3 keycape-client.py inspect --client-file "$S/old.json" --operation remove --check-config "$KC" # 2. remove the old row (uid / resourceVersion from step 1's "before") python3 keycape-client.py dry-run --client-file "$S/old.json" --operation remove \ --expected-uid --expected-resource-version python3 keycape-client.py apply --client-file "$S/old.json" --operation remove \ --case-acceptance "$ACC" --check-config "$KC" \ --expected-uid --expected-resource-version # 3. add the new row (fresh resourceVersion from step 2's "after") python3 keycape-client.py inspect --client-file "$S/new.json" --check-config "$KC" python3 keycape-client.py apply --client-file "$S/new.json" \ --case-acceptance "$ACC" --check-config "$KC" \ --expected-uid --expected-resource-version # 4. one restart of the single Recreate replica, then readiness # (restart-readiness.py pattern, NK-WP-0048-T12); sign-in pauses briefly for all clients ``` Steps 2 and 3 happen with no restart in between. KeyCape reads the registry only at startup, so a running pod never sees the gap. Run both steps before the one restart. **Verify** (non-secret, from the informed-decision workload or an attended exchange with the custody secret): - a `client_credentials` exchange with `scope=approval:read` gives 200, with `scope` exactly `approval:read`, `sub` `informed-decision`, `aud` `approval-engine` and `tenant` `tenant:platform`; - `scope=approval:create` behaves as before; - `approval:consume` and `approval:approve` each give 400 `invalid_scope`; - one present-existing intake no longer gives 503 `approval_unreadable`. It can still be refused for `requester_mismatch` until access-engine's policy is changed (blocker 1 in the requesting document, not KeyCape's). **Rollback:** run the same two applies the other way round (remove `new.json`, add `old.json`), then restart. ## Owner follow-ups outside key-cape (not edited here) These copies of the old contract pin create-only. Each owner must update its copy in step with the live apply. - **railiance-platform:** `tests/test_sitting_requester_provisioning.py` asserts `['approval:create']` on both key-cape files, so it fails against this commit. `scripts/provision-sitting-requester.py` `contract()` also requires create-only. It is a one-shot provisioner that refuses existing custody, so it should not run again. Its test needs to accept the read scope and keep refusing consume and approve. - **informed-decision:** `docs/keycape-sitting-requester-registration.{md,json}` and `tests/test_sitting_requester_registration.py` list `approval:read` as forbidden. `tools/create_sitting_approvals.py` checks that `approval:read` is refused (`excess_scope_not_refused`), so after the live apply that tool stops until the check is narrowed to consume and approve. - **net-kingdom:** `tools/service-identity/declarations/keycape.yaml` and `docs/service-identity-inventory.md` record this client's scopes. KeyCape's `identity-facts` export reports the new pair once the source is re-read.