Answer the approver-client questions, and fix what checking them turned up
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s

informed-decision and approval-engine both asked to hear problems with the human
approver registration now rather than at handover. Checking their requested shape
against the source rather than agreeing it on paper turned up three things.

Scope gap, accepted: [openid, approval:approve] cannot render a decision, since
GET /v1/approvals/{id} and /claim both need approval:read -- the surface could
submit an entry it was never able to display. Published
[openid, approval:read, approval:approve]. Reading through a service identity was
the alternative and is worse: it weakens the evidence-of-what-this-person-saw
claim the component exists to make. approval:consume stays excluded.

Assurance shape, published and a defect fixed. Both asked for a documented shape
and KeyCape already emitted one, so it is written down rather than renegotiated.
Writing it down surfaced that `at` was the token mint time rather than the
authentication time. Those differ by hours whenever a browser session is reused,
and approval-engine persists this object verbatim as the only downstream record
that MFA happened -- so a stored approval could have evidenced MFA at a moment
the person proved nothing. PKCESession.AuthTime now carries the original login
instant through session reuse, with mint time as the fallback.

Blocker found before anyone built on it: a human token cannot carry
tenant:platform. effectiveTenant resolves the human tenant from the directory
user, no adapter populates User.Tenant, and the per-client tenant field is read
only on the client_credentials path -- so every human token defaults to
tenant:coulomb, which approval-engine refuses by exact string equality. It would
have presented as a failed approval rather than a registration defect. Two
resolutions sent to the owners and neither implemented here: the choice decides
whether a human's tenant is a property of the person or of the registration, and
that is not KeyCape's alone to make.

Also recorded ops-warden's answers to KEY-WP-0014-T04, including their finding
that `warden plan` returns `autonomous` for a need containing generate and
CAS-write, because it has no read-versus-mutate intent. Their standing
instruction -- treat a warden plan verdict on any write, rotate or provision need
as unreliable until WARDEN-WP-0038 lands -- is recorded in the workplan rather
than left in an inbox.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uV8zoCKpA1WRAxsKRYbdH

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1182213@bnt-lap001
Assistant-Session: 966597b9-ae61-46a4-8b9e-1594ab3ec4ad
This commit is contained in:
tegwick 2026-09-09 14:25:38 +02:00
parent 0f5535eed9
commit a73da29093
8 changed files with 248 additions and 9 deletions

View file

@ -230,7 +230,7 @@ id: KEY-WP-0013-T05
status: wait
priority: high
assignee: the-custodian
blocking_reason: "An actual approver UI owner, client ID and deployed callback are not yet supplied. approval-engine is a bearer-only resource server."
blocking_reason: "Owner found 2026-09-09 (informed-decision); client ID and deployed callback still pending INFD-WP-0001-T07, and a human token cannot carry tenant:platform yet."
state_hub_task_id: "9a782909-91db-59fa-aae7-83766f4fbb0d"
```
@ -240,6 +240,58 @@ Require public S256 PKCE, exact redirect, MFA, approval:approve without consume,
and a real human access-token proof. No service credential substitutes for this.
HFACT-WP-0001-T03 consumes the acceptance where a human approval is required.
2026-09-09. The owner exists: `informed-decision` (hub repo cf4c7da8) claims the
approver surface and owns the two strings. approval-engine was right that a
bearer-only resource server never owned them. Both wrote in asking to be told
early about problems with the registration shape rather than at handover, so
their requested shape was checked against the source rather than agreed on
paper. Three results.
**Scope gap — accepted, theirs was right.** `[openid, approval:approve]` cannot
render a decision: `GET /v1/approvals/{id}` and `/claim` both need
`approval:read`, so the surface could submit an entry it was never able to
display. Published `[openid, approval:read, approval:approve]` in
`docs/approval-engine-auth-contract.md` and the provisioning packet. Reading
through a service identity was the alternative and is worse: it weakens the
evidence-of-what-this-person-saw claim the component exists to make.
`approval:consume` stays excluded.
**Assurance shape — published, and a defect fixed.** Both asked for a documented
shape; KeyCape already emitted one, so it is written down rather than
renegotiated: `level` (aal1|aal2, ~acr), `methods` (~amr), `mfa`, `source`, `at`.
Writing it down surfaced that `at` was the token **mint** time, not the
authentication time. Those differ by hours whenever a browser session is reused,
and approval-engine persists this object verbatim as the only downstream record
that MFA happened — so a stored approval could have evidenced MFA at a moment the
person proved nothing. `PKCESession.AuthTime` now carries the original login
instant through session reuse, with mint time as the fallback for sessions
predating the field. Covered by `src/internal/server/oidc/assurance_time_test.go`.
**Blocker found before anyone built on it — a human token cannot carry
`tenant:platform`.** `effectiveTenant` resolves the human tenant from the
directory user, not the client registration, and no adapter populates
`domain.User.Tenant`, so every human token defaults to `tenant:coulomb`. The
per-client `tenant` field that carries `tenant:platform` on the two service
clients is read only on the `client_credentials` path. approval-engine compares
tenant by exact string equality and pins near-miss spellings as refused, so an
approver token issued today would be rejected — and it would present as a failed
approval, not as a registration defect.
Two resolutions, sent to both owners (`67132ddc`, `a6a070a1`, pointer
`42d435e8`) rather than chosen here, because the choice decides what a human's
tenant *means*: (1) directory-sourced, approvers carry `tenant:platform` on their
LLDAP record — keeps tenant a property of the person, needs a directory attribute
and an owner, and changes those users everywhere; (2) registration-bound and
fail-closed, symmetric with the service clients and with decision
`5ed3fb35-eca9-413a-82b9-95171ba85bf6` — defensible only because registrations
are static and deployment-owned, since dynamic registration is excluded by
design. KeyCape leans to (2) and deliberately implemented neither: (1) is a
directory-ownership question and (2) writes a cross-tenant capability into the
issuer.
Task stays `wait` on the tenant decision, then `client_id` and callback URI from
INFD-WP-0001-T07 once that repo has a deployed origin.
## Make negative rollout evidence discriminate actual issuer refusal
```task

View file

@ -159,3 +159,56 @@ Both catalog corrections were sent, receipts readable via
half-true and should say so precisely rather than be flipped either way.
Neither message asks for a route change and neither was one.
2026-09-08/09 — ops-warden answered both, and the second answer matters more than
the question did.
**Item 1 answered: option (a).** Keep the proxy as-is and run `keycape login`
alongside it, "for your reason rather than out of caution -- they are different
credential types with different verifiers, so this is not a cutover deferred, it
is two things that were never one." They verified our pointer-layer reading
rather than accepting it, and added a fact we did not have: `warden access`
already excludes `is_login` lanes from raw-value streaming
(WARDEN-WP-0032-T05). Lane `key-cape-oidc-login` moved to `owner-confirmed` with
our boundary written in verbatim (commit c133004). It stays `interim` on purpose:
per their ADR-0003 a lane retires when a front door exists, not when ownership is
agreed, and by our own analysis `keycape login` is not that front door. The entry
now names the retirement condition instead of implying we owe one.
**Item 2 answered: nobody owns it, admittedly.** Steps 1-2 mutate custody on
platform workload paths, which is railiance-platform's, with `secrets-engine exec`
as the admitted transport. ops-warden declined to take the act on the grounds
that fronting the read lane is not authority to mutate — their ADR-0002 and
ADR-0005 — and routed it themselves rather than describing the route and leaving
it to us. Lane `rapp-qonto-keycape-client` narrowed exactly as asked and moved
`source-read` -> `owner-confirmed`, crediting the correction to us rather than to
a re-read they did not perform. Rotation step 3 now points at
`keycape verify-client` instead of restating its assertions.
**They found a defect in their own front door because of our caution, and told
us.** Running our need through `warden plan` returns `verdict: autonomous`,
`lane_id: rapp-qonto-keycape-client`, answered with three read transports — a
need containing *generate* and *CAS-write* inherits the verdict of the lane that
reads the same path, because `warden plan` has no read-versus-mutate intent at
all. `autonomous` is documented as the signal to proceed without the founder.
Raised as WARDEN-WP-0038. Their stated conclusion, which we should not soften: "A
counterparty declining to trust our output is not a control, and we are not going
to record it as one."
Standing instruction from them until WP-0038 lands, recorded here so it is not
lost in an inbox: **treat a `warden plan` verdict on any need containing a write,
rotate or provision act as unreliable.** That applies to this repository whenever
it plans a rotation.
**On `automatable`, they did not do what we suggested and said so.** We asked
them not to flip the boolean and to state the half-truth precisely; the schema has
one boolean whose only consumer is a future executable driver, and a driver told
`true` would attempt the custody steps. So it is `false`, failing safe, with the
precision moved into per-step notes. They offered to add a per-step field if we
would rather. We would not: a lane-level flag that fails safe plus per-step notes
is the right shape, and asking them to change a schema for our convenience would
be worse than the imprecision. No reply needed on that point.
T04 stays `wait`: the rotation authority now has a named owner
(railiance-platform, transport `secrets-engine exec`) but does not yet exist, and
nothing here changed a route.