--- id: KEY-WP-0028 type: workplan title: "Reject service-identity fields that a browser client silently ignores" domain: infotech repo: key-cape status: finished owner: claude topic_slug: browser-client-field-validation created: "2026-09-09" updated: "2026-09-09" state_hub_workstream_id: "6d0cb4fd-17da-5466-8011-5ade1ac46171" --- `serviceSubject` and `roles` are read only on the `client_credentials` path. On a browser client they are accepted at startup and then ignored: the subject and roles come from the directory user. A registration that looks effective and is not fails later, somewhere else, as a downstream rejection rather than as a registration defect. `tokenLifetime` was already rejected this way for non-service clients, so the rule exists — it was just incomplete. ## Reject the ignored fields ```task id: KEY-WP-0028-T01 status: done priority: medium state_hub_task_id: "e4bce8c7-0e60-55e8-ad32-a3a0c4e46044" ``` Config validation now rejects `serviceSubject` and `roles` on any client that does not use `client_credentials`, naming where the value actually comes from. Nothing in `config/dev-config.yaml`, the example fixture or the live deployment sets either field on a browser client, so this breaks no existing configuration — checked against all three rather than assumed. Re-verified against `sso/keycape-config` on 2026-09-09, after a peer raised that this validation fails closed at startup and a rollout is imminent, so a stray field would surface as KeyCape failing to boot. The deployed config now holds six clients: `demo-app`, `netkingdom-bootstrap-console` and `openbao-admin` are browser clients carrying neither field, while `rapp-qonto-client`, `secrets-engine-approval` and `approval-engine-operator` carry both legitimately on the `client_credentials` path. The rule rejects none of them. Kept as errors rather than downgraded to warnings on that evidence: a warning for a field that does nothing is the state this change exists to end. **`tenant` is deliberately excluded from the rule**, and a test pins that. A browser client may declare a tenant; `humanTenant` resolves it against the directory and refuses issuance when the two disagree (KEY-WP-0013-T05). An earlier version of this change rejected `tenant` too, which would have made that feature unusable. ## Correction ```task id: KEY-WP-0028-T02 status: done priority: medium state_hub_task_id: "5ab951f8-2f9c-522c-b6ea-7f6d572cd618" ``` This work started from KEY-WP-0013-T05's blocker paragraph, which described a human token as unable to carry `tenant:platform`. That was true when written and had already been fixed in a concurrent session by the time this task began; the paragraph was read as current state rather than as a dated record. Two consequences, both corrected within the session: - the `tenant` rejection was removed before it was committed, and a test now asserts a browser client carrying `tenant: tenant:platform` validates cleanly; - a hub decision (`0145ab57`) recording the tenant question as open and unimplemented was withdrawn with a rationale pointing at the implementation. Nothing was built on it. A third instance followed the same day, outside this task: a message to railiance-platform (d361bf28) listed the upstream issuer pin as outstanding when `docs/evidence/2026-09-09-upstream-issuer-pin.json` showed it done ten hours earlier — `check_before.issuer_matches: false`, then pinned to `https://auth.coulomb.social`. Corrected in dd827b9f. The concern itself was sound; only the claim that it was still owed was wrong, and the two are worth separating rather than letting the correct half excuse the other. The lesson worth keeping: in a repository where several sessions work at once, prose ages faster than the code and the receipts it describes. `docs/evidence/` is the authoritative artifact for anything claiming a live proof here. Read it before writing "outstanding", "unproven" or "not done" — that check is cheaper than any of the corrections it would have prevented.