informed-decision's present_existing intake (INFD-WP-0010, 0e66a33) must GET an existing approval before presenting it. This grants read only: no consume, no approve, and the audience, subject, tenant and secret stay the same. The tests pin the exact scope pair and keep the published JSON equal to the source row. The live registry is not changed. docs/sitting-requester-read-scope.md has the record diff and the founder-run keycape-client.py remove/add/restart steps, and lists the railiance-platform and informed-decision copies that still pin create-only. 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
8.5 KiB
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:
"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):
{
"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_existingmust GET/v1/approvals/{id}to check the approval before presenting it. That call needsapproval:readand nothing else. Consumption still belongs to a PEP. Approval still belongs to the human PKCE clientinformed-decision-approver. The tests refuseapproval:consume,approval:approveandopenidon 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
RequesterTokenProviderasks for exactlyapproval:createor exactlyapproval:read, and it refuses a token whosescopeclaim differs from the one it asked for. - Audience
approval-engine, subjectinformed-decision, tenanttenant:platform: all unchanged. approval-engine serves a GET by scope and exact store tenant. The approval being presented was filed intenant:platformby another actor (for exampleservice:nk-bind-executor), so a read under this tenant is enough. A read does not let the presenter create in another actor's name, becausebinding.actor == substill applies to create. - Residual widening. KeyCape's client_credentials grant gives every
allowed scope when a caller sends no
scopeparameter (src/internal/server/oidc/token.go). After this change, a holder of the secret who sends noscopegets 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):
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=<verified keycape binary with check-config, built from key-cape at or after this commit>
ACC=<hub decision id recording the founder's acceptance of this scope change>
# 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 <uid> --expected-resource-version <rv>
python3 keycape-client.py apply --client-file "$S/old.json" --operation remove \
--case-acceptance "$ACC" --check-config "$KC" \
--expected-uid <uid> --expected-resource-version <rv>
# 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 <uid> --expected-resource-version <rv2>
# 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_credentialsexchange withscope=approval:readgives 200, withscopeexactlyapproval:read,subinformed-decision,audapproval-engineandtenanttenant:platform; scope=approval:createbehaves as before;approval:consumeandapproval:approveeach give 400invalid_scope;- one present-existing intake no longer gives 503
approval_unreadable. It can still be refused forrequester_mismatchuntil 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.pyasserts['approval:create']on both key-cape files, so it fails against this commit.scripts/provision-sitting-requester.pycontract()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}andtests/test_sitting_requester_registration.pylistapproval:readas forbidden.tools/create_sitting_approvals.pychecks thatapproval:readis 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.yamlanddocs/service-identity-inventory.mdrecord this client's scopes. KeyCape'sidentity-factsexport reports the new pair once the source is re-read.