# 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://decisions.coulomb.social/auth/callback` | **`client_id` is fixed and stable.** It will not change. **The origin is settled.** 2026-09-10: the operator assigned `decisions.coulomb.social` to this surface. It is not the `decide.coulomb.social` this document proposed — the assigned name is the one that counts, and it is recorded here rather than the proposal, because redirects match exactly at `/authorize` and a near-miss fails closed. That exactness is why `approval-engine` refused to guess in the first place, and it is the reason the proposed name is corrected here before submission rather than reconciled afterwards. - **Assigned:** `decisions.coulomb.social`, giving `https://decisions.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. - **DNS resolves** to the cluster address as of 2026-09-10. - **Still requires, in `railiance-apps` rather than here:** an Ingress manifest and an issued certificate. Manifest written 2026-09-10 (`manifests/informed-decision-ingress.yaml`); not yet applied. **The host answers. This document is ready to submit.** 2026-09-10 14:32 UTC: `railiance-apps` applied the Ingress and cert-manager issued a Let's Encrypt certificate for `decisions.coulomb.social`; `GET https://decisions.coulomb.social/auth/callback` returns `200` over a verified chain. The condition this document set for its own submission — that the origin be real rather than plausible — is met. The path component `/auth/callback` was fixed ahead of the host, so nothing else in this registration moved. The path is currently served by a placeholder while `INFD-WP-0001-T08` is built. That does not affect the registration: `key-cape` matches the redirect URI as a string at `/authorize` and never fetches it. --- ## 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 `decisions.coulomb.social`~~ — **done 2026-09-10**, cert issued, host answers 200 | `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` |