Write the key-cape client registration contract (T07 progress)

Everything except the host is now fixed: client_id informed-decision-approver,
callback path /auth/callback, authorization-code + S256 PKCE public client,
scopes [openid, approval:read, approval:approve], the expected token shape, and
the assurance shape cited from key-cape's contract rather than restated so it
cannot drift.

Section 5 declares the tenant provenance rather than assuming it. Nothing
populates domain.User.Tenant for approver users, so declaring tenant:platform
reaches the token by the gap route by construction rather than by accident.
GH-DEC-2026-013 was explicit that a tenant the directory does not carry must not
be declared unless we are prepared to say so in the record — so it is said,
with the two consequences: the claim is stored with its provenance, and it is
never used as the act-scope.

Deliberately NOT submitted. The origin needs a DNS A record, an Ingress manifest
and a certificate, which is work in railiance-apps rather than here.
decide.coulomb.social is proposed and consistent with the estate's existing
Traefik/TLS-per-host pattern, but proposing a plausible hostname is not the same
as owning one, and submitting a redirect for a host that does not resolve is the
exact failure approval-engine avoided by refusing to invent these strings.

T07 stays progress with the deployment dependency named and owned elsewhere.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1565372@bnt-lap001
Assistant-Session: 16bb2f25-b34c-49ef-8e94-5fec3567a568
This commit is contained in:
tegwick 2026-09-10 08:06:10 +02:00
parent 7e7ad0293c
commit 50367d0d78
2 changed files with 191 additions and 1 deletions

View file

@ -0,0 +1,168 @@
# OIDC browser-client registration — informed-decision approver surface
**Workplan task:** `INFD-WP-0001-T07`
**For:** `key-cape` (`KEY-WP-0013-T02`, `KEY-WP-0013-T05`)
**Status:** **draft — not yet submitted.** One input outstanding: the deployed
origin (§2). Everything else is fixed and stable.
**Rulings:** `GH-DEC-2026-012`, `GH-DEC-2026-013`
**Contracts cited:** `key-cape/docs/approval-engine-auth-contract.md`,
`key-cape/docs/tenant-claim-contract.md`,
`approval-engine/docs/approver-surface-requirements.md`
---
## 1. What this closes
On 2026-09-08 `key-cape` asked `approval-engine` for the human approver client's
`client_id` and full callback URI. `approval-engine` correctly declined to
invent them: it is a bearer-token resource server with no Ingress, no redirect
endpoint, and no browser client. It recorded that the two strings belong to
whichever browser-facing approver UI presents `approval:approve` tokens to it.
That component is this repository. These are the two strings.
---
## 2. The two strings
| Field | Value |
| --- | --- |
| `client_id` | `informed-decision-approver` |
| Redirect URI | `https://<origin>/auth/callback` |
**`client_id` is fixed and stable.** It will not change.
**The origin is the one outstanding input.** Redirects match exactly at
`/authorize`, so a near-miss fails closed — which is precisely why
`approval-engine` refused to guess and why this document is not yet submitted.
- **Proposed:** `decide.coulomb.social`, giving
`https://decide.coulomb.social/auth/callback`.
- **Consistent with** the estate's existing pattern (`forgejo.coulomb.social`,
`hub.coulomb.social`, `kc.coulomb.social`), Traefik `ingressClassName`, TLS
secret per host.
- **Requires, in `railiance-apps` rather than here:** a DNS A record for
`decide.coulomb.social`, an Ingress manifest, and a certificate.
**This document is submitted to `key-cape` when that origin resolves — not
before.** The path component `/auth/callback` is fixed now so only the host
remains variable.
---
## 3. Client shape
| Property | Value |
| --- | --- |
| Grant | Authorization code |
| PKCE | Required, `S256` |
| Client type | Public |
| Redirect matching | Exact |
| Scopes | `openid`, `approval:read`, `approval:approve` |
| `mfaRequired` | `true` |
**`approval:read` is required and was missing from the original specification.**
Without it this surface could submit an approval entry it was never able to
display — raised by `approval-engine`, confirmed by `key-cape`, now in their
published contract.
**`approval:consume` is never requested.** `approval-engine` refuses it for human
principals, and consumption belongs to the PEP that causes the side effect
(`GH-DEC-2026-003`).
---
## 4. Expected access token
Verified by `approval-engine` (RS256 against the issuer's `/jwks`):
| Claim | Value |
| --- | --- |
| `aud` | `approval-engine` — the resource server, never the OAuth client id |
| `tenant` | `tenant:platform`**exact** string equality, no alias or prefix handling |
| `principal_type` | `human` |
| `sub` | non-empty; becomes the approver identity in the entry |
| `scope` | non-empty, containing the exact scope for the route |
| `assurance` | a non-null JSON object — shape below |
| `iss`, `exp`, `iat` | verified, 30s leeway |
The **access** token is the evidence. `id_token` is never presented to
`approval-engine` and appears nowhere in that codebase.
### `assurance`
The shape is **`key-cape`'s**, defined in their
`docs/approval-engine-auth-contract.md``level` (`aal1|aal2`), `methods`,
`mfa`, `source`, `at`. **Cited here rather than restated, so it cannot drift.**
Two properties matter to this surface and are recorded rather than assumed:
- `at` is the **authentication** time, not the token mint time. `key-cape` fixed
a defect here on 2026-09-09; the two differ by hours when a browser session is
reused, and `approval-engine` stores this object verbatim as the only
downstream record that MFA happened. Freshness is judged by comparing `at`,
never by assuming it.
- `key-cape` emits `assurance` on every human token and never empty. This
surface nevertheless requires the object to carry authentication facts rather
than relying on it always arriving populated — `approval-engine` *accepts* an
empty object, so an empty one would produce an entry evidencing nothing.
---
## 5. Tenant provenance — declared, not assumed
**This registration declares a tenant the directory record does not carry, and
says so here rather than letting it read as a directory-asserted fact.**
`GH-DEC-2026-013` ruled directory-sourced tenancy the terminal state and granted
`key-cape`'s registration-bound shape (`329e48f`) as a **declared bounded gap**
admissible because its distinguishing case fails closed: registration and
directory disagreeing refuses issuance with `403 tenant_binding` rather than
relabelling.
Nothing populates `domain.User.Tenant` for approver users. So a registration
declaring `tenant:platform` reaches the token by the **gap route by
construction**, not by accident. Gate House was explicit: do not declare a tenant
the directory does not carry unless prepared to say so in the record.
Consequences accepted here:
1. **This is transitional.** When the directory carries tenants, the same
`key-cape` code stops supplying and starts enforcing agreement, with no second
migration. This registration is expected to survive that unchanged.
2. **The claim is stored with its provenance.** `key-cape` emits `tenant` as a
bare string, so a consumer cannot distinguish a directory-asserted tenant from
a registration-supplied one. Until the claim carries its own provenance
(`GH-DEC-2026-013` §5, second field deliberately undesigned), this surface
records which route the value arrived by. See `PR-09`.
3. **The tenant claim is never used as the act-scope.** `binding.target` is the
act-scope and is committed inside `view_hash`. Two different facts — which
scope this act enters, and which tenant this person belongs to — must not
share one field here just because they share one upstream. See
`EvidenceModel.md` §8c and `PR-08`.
---
## 6. What this surface does with the token
- `GET /v1/approvals/{id}` and `/claim``approval:read`, to render.
- `POST /v1/approvals/{id}/entries``approval:approve`, to record a binding.
- **Never** `/consume`.
- **Never** any path containing `check` or ending `/authorize` — they 404 by
design, and there is no "may this person approve" surface to call.
A `200` from `approval-engine` is not entitlement to view. A decision is
obtained from `access-engine` before an approval is rendered to a person
(`PR-05`) — noting that decision **attributability** is a declared open gap
(`GH-DEC-2026-010`, `FLEX-WP-0024`) and this path is not described as validated
while it stands.
---
## 7. Outstanding
| # | Item | Owner |
| --- | --- | --- |
| 1 | DNS A record + Ingress + TLS for the chosen origin | `railiance-apps` / deployment |
| 2 | Submit this document to `key-cape`, closing `KEY-WP-0013-T02` | this repo, once 1 lands |
| 3 | Prove a token issued against the registration is accepted by `approval-engine` | `T08` |

View file

@ -326,7 +326,7 @@ deployed.
```task
id: INFD-WP-0001-T07
status: todo
status: progress
priority: high
state_hub_task_id: "38a83a76-f152-55bf-8a9a-6132fd6d9642"
```
@ -366,6 +366,28 @@ The one remaining input is the **deployed origin**. Redirects match exactly, so
`client_id` and the callback URI must name a real origin — the reason not to
publish is now solely that, and no longer the tenant.
2026-09-10: `docs/keycape-client-registration.md` written and everything except
the host is fixed — `client_id` `informed-decision-approver`, callback path
`/auth/callback`, authorization-code + S256 PKCE public client, scopes
`[openid, approval:read, approval:approve]`, the expected token shape, and the
`assurance` shape cited from `key-cape` rather than restated so it cannot drift.
§5 declares the tenant provenance rather than assuming it. Nothing populates
`domain.User.Tenant` for approver users, so declaring `tenant:platform` reaches
the token by the **gap route by construction**, not by accident.
`GH-DEC-2026-013` was explicit that a tenant the directory does not carry must
not be declared unless we are prepared to say so in the record. We are, and it
is said there, along with the two consequences: the claim is stored with its
provenance, and it is never used as the act-scope.
**Not submitted, and the task stays `progress`.** The origin
(`decide.coulomb.social` proposed, consistent with the estate's existing
Traefik/TLS-per-host pattern) needs a DNS A record, an Ingress manifest and a
certificate — work in `railiance-apps`, not here. Submitting a redirect for a
host that does not resolve is the exact failure `approval-engine` avoided by
refusing to invent these strings in the first place, and choosing a plausible
hostname is not the same as owning one.
Superseded context: 2026-09-09: blocked on `INFD-IN-0002`. `key-cape` found that a human access
token cannot carry `tenant:platform` today — the tenant claim resolves from a
directory record no adapter populates, so every human token falls back to