docs: preserve reviewed NetKingdom session artifacts
Some checks are pending
CI Smoke / host-smoke (push) Waiting to run
CI Smoke / container-smoke (push) Waiting to run
Reject generated-only commits / generated-commit-guard (push) Waiting to run

This commit is contained in:
codex 2026-11-01 23:26:14 +01:00
parent 573f707348
commit 733a35025f
50 changed files with 6606 additions and 0 deletions

View file

@ -0,0 +1,100 @@
---
id: ADHOC-2026-09-27
type: workplan
title: "Fresh MFA no longer backdated to a stale kc_login cookie (INFD-IN-0005)"
domain: infotech
repo: key-cape
status: finished
owner: claude
created: "2026-09-27"
updated: "2026-09-27"
state_hub_workstream_id: "5217dd06-73cb-50f8-ba64-528e421b29de"
---
## Stop completeAuthorization from reusing a stale prior session's IssuedAt after real MFA
```task
id: ADHOC-2026-09-27-T01
status: done
priority: high
state_hub_task_id: "ce98e4af-cfaa-5620-8f21-15ade18ee9e8"
```
`informed-decision` intake `INFD-IN-0005` (open, `origin_ref: SECRETS-WP-0010`):
platform-root's approval of `infd-20260927-b01` was refused by flex-auth's
`compact_sitting` policy with `policy_denied`, despite platform-root having just
completed a fresh key-cape MFA sign-in. flex-auth's 900-second review-freshness
check reads `assurance.at`, which key-cape derives from the PKCE session's
`AuthTime`.
Root cause in `src/internal/server/oidc/authorize.go`,
`AuthorizeHandler.completeAuthorization`: whenever a `kc_login` cookie already
existed for the same username, its `IssuedAt` was unconditionally reused as
`authTime` — even on the two call sites that had just called
`MFA.ValidateMFAToken` successfully in this exact request (the callback's
`mfaToken != ""` branch and `serveMFASubmission`). A user who had an older
browser session and then stepped up with a fresh MFA challenge got the *old*
session's timestamp stamped onto the *new*, just-proven authentication —
understating freshness for every downstream binding-grade check, exactly as
`INFD-IN-0005` describes ("completeAuthorization currently copies a prior
timestamp... only after actual reauthentication").
The reuse itself is correct when MFA was **not** just performed in this
request (`decision.MFAVerified` carried forward from an existing AAL2 session,
via `domain.DecideAssurance`) — reporting mint time there would overstate
freshness (`TestAssuranceReportsAuthenticationTimeNotMintTime`). The two cases
were conflated under a single `mfaVerified` bool.
Fix: added a `freshMFA bool` parameter to `completeAuthorization`, `true` only
at the two call sites that just validated an MFA token in this request, `false`
at the carried-forward-assurance call site. `authTime` now falls back to the
prior session's `IssuedAt` only when `!freshMFA`.
Added `TestAuthorizeCallback_MFASubmission_FreshEvenWithStalePriorLoginCookie`,
which seeds a 4-hour-old `kc_login` cookie for the same user, then submits a
valid MFA token and asserts the resulting `AuthTime` is within a minute of now.
Confirmed it fails against the pre-fix code (`AuthTime` ~4h old) and passes
after. Full suite (`go test ./...`) green, no other test needed adjustment —
the carried-forward-assurance case (`TestPolicy_...`, `TestLogout_...`,
`assurance_time_test.go`) is unaffected since none of it exercises a fresh MFA
submission with a pre-existing cookie for the same user.
Does not touch flex-auth (its 900s window is correct; it was consuming a bad
timestamp) or informed-decision (correctly refuses to interpret the PDP's
denial). `INFD-IN-0005` can close once informed-decision confirms platform-root
can approve after a fresh sign-in against this build.
## Discovery/JWKS fetch was missing the split-horizon origin headers
```task
id: ADHOC-2026-09-27-T02
status: done
priority: high
```
Rolling out T01 (image digest `sha256:248e449a...`) broke every KeyCape login,
including unrelated clients (`user-engine-portal`), with `auth_failure` /
`provider_metadata_unavailable` at `/api/oidc/token` on the KeyCape side and
Authelia logging `invalid X-Forwarded-Proto header value 'http'` on
`GET /.well-known/openid-configuration` from KeyCape's pod IP.
Root cause, unrelated to T01's change: `AutheliaAdapter.setPublicOriginHeaders`
(the NK-WP-0041-T02/path-B fix, key-cape@3b0446e) annotates only the token
exchange (`newTokenRequest`) with `X-Forwarded-Proto`/`X-Forwarded-Host` so
Authelia can derive the public issuer for a split-horizon (in-cluster HTTP,
public HTTPS) deployment. `idTokenVerifier.get` in `verify.go` — used for the
provider-metadata and JWKS fetches that verify every upstream ID token — never
got the same treatment; it built a bare request with only an `Accept` header.
Authelia 4.38 fails closed on the resulting unmarked in-cluster request. This
gap predates today's session (present since 3b0446e); it surfaced now because
this was the first login attempted since that image went live.
Fix: extracted `setPublicOriginHeaders` from an `AutheliaAdapter` method to a
package-level function taking the target public base URL explicitly, and gave
`idTokenVerifier` its own resolved `publicBaseURL` (same
`BrowserBaseURL`-else-`BaseURL` fallback as the adapter) so `get()` applies
the same headers. Added `TestDiscoveryFetch_CarriesPublicOriginHeaders`
(confirmed it fails without the fix, passes with it). Full suite green.
New image `key-cape@<pending>` still needs a rollout to `sso-mfa/k8s/keycape/deployment.yaml`
before platform-root can sign in again.

View file

@ -0,0 +1,499 @@
---
id: KEY-WP-0001
type: workplan
title: "KeyCape Implementation — Lightweight IAM Profile"
domain: infotech
repo: key-cape
status: finished
owner: Bernd
topic_slug: netkingdom
topic_id: a6c6e745-bf54-4465-9340-1534a2be493e
repo_id: 8a99bb74-1ec0-4478-ac70-35a7cddb0e3c
created: 2026-03-13
updated: 2026-03-13
spec_refs:
- wiki/KeyCapeSpecification_v0.1.md
- wiki/KeyCapeSpecificationPack_v0.1.md
decisions:
- id: ADR-0001
title: "Implementation language: Go"
hub_decision_id: 620beb04-fa3f-4a9d-9806-02890a7a2b0d
status: accepted
ref: docs/adr/ADR-0001-choose-go-for-keycape.md
state_hub_workstream_id: "0d34dfc1-7ccb-4bd5-b872-5c7379b9adce"
---
# KEY-WP-0001 — KeyCape Implementation
Implements the **NetKingdom IAM Profile** via KeyCape: a stateless, profile-constrained OIDC
server orchestrating Authelia, LLDAP, and privacyIDEA. Replaceable by Keycloak without
application changes.
## Language Decision
**Go** — decided 2026-03-13 by Bernd. See `docs/adr/ADR-0001-choose-go-for-keycape.md`.
KeyCape is an orchestrating boundary service (HTTP, adapters, JWT via library, CLI tooling) —
Go's strongest domain. Rust revisitable if a subcomponent needs stronger guarantees later.
Mandatory guardrails: typed domain models, narrow adapter interfaces, layered architecture,
fuzz tests on validator/redirect/claim-mapping, no cleverness.
## Repo Structure (from spec §12)
```
src/
server/
oidc/ # Profile endpoints
telemetry/ # Structured event emission
errors/ # Error taxonomy + enforcement middleware
adapters/
authelia/ # Auth flow delegation
lldap/ # Identity directory reads
privacyidea/ # MFA enforcement
validator/ # Canonical LDAP schema validator binary
migration/
lldap-export/ # LLDAP → canonical
keycape-to-keycloak/ # Canonical → Keycloak realm import
lldap-to-ldap/ # LLDAP → OpenLDAP/389DS/AD LDIF
spec/
canonical-model.yaml # Source of truth for all identity data
ldap-schema.yaml # Canonical LDAP schema rules
tests/
profile/ # Scenario A — lightweight baseline
negative/ # Scenario D — unsupported feature rejection
migration/ # Scenarios B & C — replacement
```
## Dependency Order
```
T01 (project setup)
└─ T02 (canonical model) T04 (error taxonomy)
└─ T03 (LDAP validator) └─ T13 (telemetry)
└─ T10 (LLDAP adapter) └─ T14 (enforcement layer)
└─ T11 (Authelia) │
└─ T12 (privacyIDEA) │
│ │
T05 ─ T06 ─ T07 ─ T08 ─┴─ T09 (OIDC server)
│
T18 (profile tests / Scenario A)
T21 (negative tests / Scenario D)
│
T15 → T16 → T19 (Scenario B)
T15 → T17 → T20 (Scenario C)
│
T22 (dev stack)
T23 (production packaging)
```
---
## Phase 1 — Foundations
## T01 — Project setup: Go module, repo layout, CI skeleton
```task
id: KEY-WP-0001-T01
hub_task_id: 25613e3f-2a65-409e-afaa-d23ded0bc256
priority: high
status: done
state_hub_task_id: "38822bc0-4189-4909-874e-ea40e5771250"
```
Initialise language module in `src/`. Create directory skeleton per spec §12. Add Makefile
targets: `build`, `test`, `lint`. Set up CI (build + test on every push). Scaffolding only —
no application code. **Agent must call `record_decision()` with chosen language (Go or Rust).**
## T02 — Canonical identity model: machine-readable schema
```task
id: KEY-WP-0001-T02
hub_task_id: deee2929-9386-41db-bf91-fbd9ad646c28
priority: high
status: done
depends_on: [T01]
state_hub_task_id: "940c118b-c1e6-4dda-bd4c-4fac105822be"
```
Write `spec/canonical-model.yaml`. Six entities: User, Group, Role, Client, Membership,
MFAEnrollment (fields per spec §2). Include JSON Schema or CUE schema for programmatic
validation. This file is the **source of truth** — all other code derives from it.
## T03 — Canonical LDAP schema + validator
```task
id: KEY-WP-0001-T03
hub_task_id: 02592c65-db23-474b-b06b-019e95df8146
priority: high
status: done
depends_on: [T01, T02]
state_hub_task_id: "c1715d70-f10f-45e9-b73a-b54a3d360342"
```
Write `spec/ldap-schema.yaml`: tree layout (`ou=users`, `ou=groups`, `ou=clients` under
`dc=netkingdom,dc=local`), object classes (`inetOrgPerson`, `groupOfNames`), required/optional
attributes. Implement `validator/` binary. Structural rules: valid DN, required attrs, no unknown
attrs, valid group memberships. Semantic rules: referenced users exist, no cycles, usernames
unique, email format valid. Validator runs in `--mode=ci|provisioning|migration`. Emits
machine-readable report.
## T04 — Error taxonomy: types, JSON format, middleware
```task
id: KEY-WP-0001-T04
hub_task_id: 46870fd6-0672-432b-8824-6bc2e24811b3
priority: high
status: done
depends_on: [T01]
state_hub_task_id: "6e3b6b97-ac77-44c5-959e-be12751f1b63"
```
Implement four error types (spec §5):
- `feature_not_supported_by_profile`
- `available_in_keycloak_mode_only`
- `rejected_for_profile_safety`
- `invalid_profile_usage`
JSON format: `{"error": "...", "description": "...", "feature": "..."}`. HTTP middleware wraps
all handler errors. Error type strings are stable and test-assertable.
---
## Phase 2 — OIDC Server
## T05 — OIDC discovery endpoint (/.well-known/openid-configuration)
```task
id: KEY-WP-0001-T05
hub_task_id: 92eb8916-cb22-4786-9f16-a8a07272f818
priority: high
status: done
depends_on: [T04]
state_hub_task_id: "0dbc08e3-c465-4c37-a219-832a580bedfd"
```
`GET /.well-known/openid-configuration`. Advertise **only** profile-supported features:
`authorization_code`, S256 PKCE, RS256, static scopes. Must NOT advertise: dynamic registration,
implicit flow. Issuer configurable. Cacheable response.
## T06 — Authorization endpoint with PKCE and redirect URI validation
```task
id: KEY-WP-0001-T06
hub_task_id: c3df620b-9864-4ff1-ba6d-26057c6f4d59
priority: high
status: done
depends_on: [T04, T11, T12, T13, T14]
state_hub_task_id: "cdb4b06d-3d54-49dd-ac05-ca9ed6d7322f"
```
`GET/POST /authorize`. Validate: `client_id` (static config), `redirect_uri` (exact match —
wildcard → `rejected_for_profile_safety`), `response_type=code`, `scope` contains `openid`,
`code_challenge` present (missing → `invalid_profile_usage`), `code_challenge_method=S256`.
Delegate to Authelia adapter. Store PKCE state server-side. No implicit or hybrid flow.
## T07 — Token endpoint: JWT/RS256, canonical claim mapping
```task
id: KEY-WP-0001-T07
hub_task_id: d3248f4d-e0e9-4144-9844-c9768dc896d6
priority: high
status: done
depends_on: [T06, T08, T10]
state_hub_task_id: "534d8616-90de-4d32-961c-c2ef719642e4"
```
`POST /token`. Validate PKCE `code_verifier`. Issue RS256 JWT via standard library (no custom
crypto). Mandatory claims: `iss`, `sub` (canonical user ID), `aud`, `exp`, `iat`. Optional
claims: `preferred_username` (LDAP `uid`), `email` (LDAP `mail`), `groups` (groupOfNames),
`roles`. Short, explicitly configured token lifetime. Any other grant type →
`feature_not_supported_by_profile`.
## T08 — JWKS endpoint (/jwks)
```task
id: KEY-WP-0001-T08
hub_task_id: 58a1d705-b788-4d66-8c0a-33edff63a885
priority: high
status: done
depends_on: [T01]
state_hub_task_id: "7e2167be-bcc7-49c2-8681-e518abd5bc0c"
```
`GET /jwks`. RS256 public key in JWK Set format. Key loaded from config. Key rotation: serve
multiple keys during rotation window, keyed by `kid`. Standard library key generation only.
## T09 — Userinfo endpoint (/userinfo)
```task
id: KEY-WP-0001-T09
hub_task_id: 742d3924-21e9-4304-86e9-0400af0e81ee
priority: medium
status: done
depends_on: [T07, T10]
state_hub_task_id: "78094ca5-a831-4443-9ccf-fc476ff87b91"
```
`GET /userinfo`. Optional per spec — implement if any registered client requires it. Validate
Bearer token (RS256 + expiry). Return claim subset scoped to granted scopes. Claims must be
identical to ID token for same scopes. If no client needs it: stub returning
`available_in_keycloak_mode_only`.
---
## Phase 3 — Backend Adapters
## T10 — LLDAP adapter: user and group reads
```task
id: KEY-WP-0001-T10
hub_task_id: 2043b10a-6822-45f8-abcc-4e233d918fb0
priority: high
status: done
depends_on: [T02, T03]
state_hub_task_id: "97d19662-f482-4ea5-84fd-9fccb84ff317"
```
`adapters/lldap`. LDAP protocol connection to LLDAP. Interface: `LookupUser(username) → canonical
User`, `LookupGroups(userDN) → []Group`, `ValidatePassword(username, password) → bool`. Attribute
map: `uid→username`, `cn→displayName`, `mail→email`, `memberOf→groups`. Run canonical LDAP schema
validator on every read. No LDAP internals exposed to `server/`.
## T11 — Authelia adapter: session and auth flow delegation
```task
id: KEY-WP-0001-T11
hub_task_id: ad129a14-1552-4717-b1dd-b529d18ce681
priority: high
status: done
depends_on: [T04, T13]
state_hub_task_id: "6461865b-f57c-4591-9cf3-68c79af22723"
```
`adapters/authelia`. Initiate auth redirect to Authelia, receive callback, extract authenticated
user identity, hand off to MFA check (T12). Must not leak Authelia session tokens/cookies into
profile layer. Unavailable Authelia → fail closed (`auth_failure` event).
## T12 — privacyIDEA adapter: MFA enforcement
```task
id: KEY-WP-0001-T12
hub_task_id: 1ef196e6-2304-4cf6-b205-47ac1da879ec
priority: high
status: done
depends_on: [T02, T13]
state_hub_task_id: "e403a783-c856-4d6d-b859-a9cad7545fe1"
```
`adapters/privacyidea`. **KeyCape must NOT implement MFA logic.** Interface:
`CheckMFARequired(user) → bool`, `ValidateMFAToken(user, token) → bool`. MFA failure → no token
issued + `auth_failure` telemetry. MFA enrollment state from canonical `MFAEnrollment` entity.
privacyIDEA remains stable across lightweight → expanded migration.
---
## Phase 4 — Telemetry & Enforcement
## T13 — Telemetry pipeline: structured event emission
```task
id: KEY-WP-0001-T13
hub_task_id: 704146bf-cd60-4922-b18b-3d209cff3ac3
priority: high
status: done
depends_on: [T01]
state_hub_task_id: "4df7bda1-5b84-4b4c-9b16-bcb1d3cca096"
```
`server/telemetry`. Event types (spec §6.1): `auth_start`, `auth_success`, `auth_failure`,
`token_issued`, `unsupported_feature`, `invalid_request`, `migration_event`. Required fields
(spec §6.2): `timestamp`, `client_id`, `endpoint`, `feature`, `result`, `error_type`, `scopes`,
`grant_type`, `environment`, `trace_id`. Pluggable outputs: structured log (default), Prometheus
metrics endpoint. Every auth and error path emits an event — **no silent paths**.
## T14 — Unsupported feature enforcement layer
```task
id: KEY-WP-0001-T14
hub_task_id: 71f44886-ab61-4160-a435-72b35af472a0
priority: high
status: done
depends_on: [T04, T13]
state_hub_task_id: "ae16fba9-5bb4-4780-ac77-558e3ed7e1dd"
```
`server/errors` enforcement middleware. Intercept any parameter, grant type, scope, or client
config exceeding the profile. Return correct error type + emit `unsupported_feature` telemetry.
Maintain a **registry** of unsupported features (adding new ones requires no handler changes):
`dynamic_client_registration`, `identity_broker`, `wildcard_redirect_uri`, `implicit_flow`, etc.
Every registry entry must have a corresponding test in T21.
---
## Phase 5 — Migration Tooling
## T15 — Migration: LLDAP → canonical export
```task
id: KEY-WP-0001-T15
hub_task_id: 1bd13f76-2d62-429d-b230-d785ef6a3f2f
priority: medium
status: done
depends_on: [T02, T03, T10]
state_hub_task_id: "f7549cd7-33f0-4407-a656-ab8f5a184e64"
```
`migration/lldap-export` tool. Read all users, groups, memberships, attributes from LLDAP. Map
to canonical model. Validate against LDAP schema validator before writing. Output:
`canonical-export.yaml`. Emit `migration_event` telemetry. Idempotent. Include incompatibility
report for unmappable LLDAP data.
## T16 — Migration: canonical → Keycloak import
```task
id: KEY-WP-0001-T16
hub_task_id: f3d50e80-f6b5-4c0c-a5f3-08308ea1a95e
priority: medium
status: done
depends_on: [T15]
state_hub_task_id: "96486c41-9f33-42a5-b7b6-ad0a9eb2bdee"
```
`migration/keycape-to-keycloak` tool. Read canonical export (T15). Transform to Keycloak realm
import format: users, groups, clients, roles, scope mappings. Preserve: same issuer, same claims,
same scopes, same client behavior. Output: `keycloak-realm-import.json`. Emit `migration_event`.
Include round-trip validation report.
## T17 — Migration: LLDAP → full LDAP (OpenLDAP / 389DS / AD)
```task
id: KEY-WP-0001-T17
hub_task_id: 044d99c8-39cb-4f35-9308-912ae829bd22
priority: medium
status: done
depends_on: [T15]
state_hub_task_id: "1ec335a2-80ca-4c34-b08e-211f537e4214"
```
`migration/lldap-to-ldap` tool. Export via T15 canonical export. Generate LDIF for target
(`--target=openldap|389ds|ad`). Migrate: users, groups, memberships, attributes. Run validator
on output LDIF before import. Produce validation report. **Orthogonal to T16** — the two
migration dimensions are independent (spec §14.1).
---
## Phase 6 — Replacement Tests
## T18 — Profile test suite: Scenario A (lightweight baseline)
```task
id: KEY-WP-0001-T18
hub_task_id: 76abc3f6-9c4e-4aca-9995-72b728925812
priority: high
status: done
depends_on: [T05, T06, T07, T08, T09, T22]
state_hub_task_id: "1b0e9f26-d441-42b8-b532-1eb713fb355d"
```
`tests/profile`. Provision canonical fixtures into LLDAP + Authelia + KeyCape. Test categories
(spec §15.3): discovery, login flow (PKCE), token claim assertions (all mandatory + optional),
redirect validation, client config, MFA policy, logout (if implemented). Tests are
**backend-agnostic** — same suite runs in T19 and T20. Must pass for Scenario A conformance.
## T19 — Replacement test suite: Scenario B (IAM swap, same directory)
```task
id: KEY-WP-0001-T19
hub_task_id: 56d03e89-934b-4992-bfe4-b32f275882e3
priority: medium
status: done
depends_on: [T18, T16]
state_hub_task_id: "a02d24e7-32de-4be6-935c-896c10dde020"
```
Run T18 suite against Keycloak + LLDAP (configured from T16 canonical export). **No test code
changes allowed.** Migration successful only if all T18 tests pass. Proves IAM replaceability
without directory migration.
## T20 — Replacement test suite: Scenario C (full expansion)
```task
id: KEY-WP-0001-T20
hub_task_id: ec3cae5c-9942-4be7-acc0-1eb9f02aba45
priority: medium
status: done
depends_on: [T19, T17]
state_hub_task_id: "545f319f-053d-48bd-8d94-c8c05cd56736"
```
Apply T17 LLDAP→OpenLDAP migration, then T16 Keycloak import. Run T18 suite. Migration successful
only if all tests pass. privacyIDEA must remain stable (no MFA re-enrollment required).
## T21 — Negative profile tests: Scenario D (unsupported feature rejection)
```task
id: KEY-WP-0001-T21
hub_task_id: a5112b63-121f-4d17-ac1a-fb46d160413e
priority: high
status: done
depends_on: [T14]
state_hub_task_id: "5856afe0-2a9e-4489-b057-35e59f86c359"
```
`tests/negative`. For every entry in T14 unsupported-feature registry: attempt usage, assert
correct `error.error` string, assert `unsupported_feature` telemetry event emitted. Covered
cases: `dynamic_client_registration`, `implicit_flow`, wildcard redirect URIs, identity brokering,
missing PKCE, unknown scopes, unknown grant types. **Must run in CI.** Proves enforcement layer
is complete.
---
## Phase 7 — Operations
## T22 — Dev mode stack: docker-compose with LLDAP + Authelia
```task
id: KEY-WP-0001-T22
hub_task_id: e840963f-cd19-4b38-857a-7c40df165d3d
priority: medium
status: done
depends_on: [T01]
state_hub_task_id: "b98f2671-a20a-4438-99c9-fbe0e5324534"
```
`docker-compose.dev.yml`: KeyCape, LLDAP, Authelia, privacyIDEA (or stub). Pre-seeded with
canonical fixtures from T02. Makefile targets: `make dev` (start + verify basic auth flow),
`make seed` (re-apply fixtures without full restart). Deterministic: same seed → same state.
Test environment for T18 and T21.
## T23 — Slim production packaging: binary + config + health
```task
id: KEY-WP-0001-T23
hub_task_id: d5723683-f739-4362-b62e-71213dc5a89e
priority: low
status: done
depends_on: [T18, T21]
state_hub_task_id: "8c1752c2-7fb3-4da5-aab3-6b7acf12ea64"
```
Single stateless binary. Declarative YAML config: profile version, client definitions, backend
connections, LDAP schema rules, privacyIDEA settings, telemetry destination, token lifetime,
issuer URL. Static config validation on startup. `/healthz` endpoint. Minimal container image
(distroless or Alpine). Config environment-promotable via env var overrides only.
---
## Acceptance Criteria (from spec §15.4 and §20)
A release is conformant when:
- [x] Scenario A tests pass (T18) — `src/tests/profile/profile_test.go` (8 tests)
- [x] Scenario D tests pass (T21) — `src/tests/negative/negative_test.go` (8 tests)
- [x] Scenario B tests pass (T19) — `src/tests/migration/scenario_b_test.go` (7 tests)
- [x] Scenario C tests pass (T20) — `src/tests/migration/scenario_c_test.go` (6 tests)
- [x] All error responses use taxonomy types from spec §5 — `internal/errors/taxonomy.go`
- [x] All auth/error paths emit structured telemetry (T13) — `internal/server/telemetry/`
- [x] Canonical LDAP schema validator passes on all fixtures (T03) — `internal/validator/`
- [x] No handwritten cryptography anywhere in the codebase — stdlib `crypto/rsa` only
- [x] Config is statically validated at startup (T23) — `internal/config/validate.go`

View file

@ -0,0 +1,245 @@
---
id: KEY-WP-0002
type: workplan
title: "KeyCape Container Image — Build & Publish to Gitea OCI Registry"
domain: infotech
repo: key-cape
status: finished
owner: netkingdom
topic_slug: netkingdom
created: "2026-03-22"
updated: "2026-03-21"
capability_request_id: ""
state_hub_workstream_id: "c8843c7a-460a-47a2-b45a-b8d3940f9aa2"
---
# KEY-WP-0002 — KeyCape Container Image — Build & Publish to Gitea OCI Registry
## Problem
KeyCape has a `Dockerfile` but no automated build pipeline and no published
image. Other services (k3s deployments, local dev) that need to run KeyCape
must build locally from source. There is no versioned artefact to reference
in Helm charts or manifests.
The capability request for this work was originally misrouted to railiance.
It belongs here: KeyCape owns its own image.
## Goal
Produce a versioned OCI image for KeyCape, published to the Gitea container
registry on CoulombCore (`92.205.130.254:32166`), triggered automatically on
every merge to `main` and on semver tags (`v*`).
**Gitea OCI registry endpoint:** `92.205.130.254:32166`
**Image name:** `92.205.130.254:32166/coulomb/key-cape`
> **Why Gitea, not GHCR?**
> The net-kingdom cluster is self-hosted. Keeping images in Gitea (also
> self-hosted on CoulombCore) avoids any external registry dependency and
> keeps image pulls within the cluster network. GHCR is a future option
> once public distribution is needed.
## Design
### Image naming & tagging
| Trigger | Tags applied |
|---------|-------------|
| push to `main` | `latest`, `main-<short-sha>` |
| tag `v1.2.3` | `1.2.3`, `1.2`, `1`, `latest` |
### Build
Multi-stage Dockerfile already present — no changes needed to the build
itself. The image builds to a distroless static binary (~10 MB).
### Registry auth
Gitea issues a personal access token (or machine account token) with
`write:packages` scope. Stored as Gitea Actions secret `REGISTRY_TOKEN`;
username stored as `REGISTRY_USER`.
For local `make push`, credentials are passed via `docker login` before
the push target runs.
### Makefile targets
```makefile
IMAGE_REGISTRY ?= 92.205.130.254:32166
IMAGE_REPO ?= coulomb/key-cape
IMAGE_TAG ?= latest
IMAGE := $(IMAGE_REGISTRY)/$(IMAGE_REPO):$(IMAGE_TAG)
image:
docker build -t $(IMAGE) .
push: image
docker push $(IMAGE)
image-tag:
docker tag $(IMAGE) $(IMAGE_REGISTRY)/$(IMAGE_REPO):$(IMAGE_TAG)
```
### Gitea Actions workflow
`.gitea/workflows/image.yaml` — triggers on push to `main` and on `v*` tags:
- Checkout
- Set up Docker Buildx
- Login to `92.205.130.254:32166` using secrets
- Build and push with metadata-action tags
- (Optional) sign with cosign if available
### k3s insecure registry
Gitea runs over plain HTTP on port 32166 (NodePort). k3s must be configured
to treat this endpoint as an insecure registry so image pulls work from
within the cluster:
```yaml
# /etc/rancher/k3s/registries.yaml (on CoulombCore)
mirrors:
"92.205.130.254:32166":
endpoint:
- "http://92.205.130.254:32166"
```
k3s picks this up on restart (or SIGHUP). Worker nodes (if any) need the
same file.
---
## Tasks
### T01 — Makefile: image, push, image-tag targets
```task
id: KEY-WP-0002-T01
status: done
priority: high
state_hub_task_id: "749472fc-edb9-4948-9ebc-58d5f38327ee"
```
Add `image`, `push`, and `image-tag` targets to `Makefile` with
`IMAGE_REGISTRY`, `IMAGE_REPO`, `IMAGE_TAG` variables defaulting to the
Gitea endpoint and `coulomb/key-cape:latest`.
Gate: `make image` builds successfully locally; `IMAGE_TAG=dev make image`
produces a differently-tagged image.
---
### T02 — Gitea Actions workflow
```task
id: KEY-WP-0002-T02
status: done
priority: high
state_hub_task_id: "8ecf18cc-a3bb-4ede-a09c-fcd0d26d7f9d"
```
Create `.gitea/workflows/image.yaml`:
- Trigger: `push` to `main`, `push` tags matching `v*`
- Runner: `act_runner` label (or `ubuntu-latest` if configured)
- Steps: checkout → docker buildx → login → build+push
- Tags via `docker/metadata-action`: `latest` on main, semver on tags
Secrets required (document in README.md under "CI"):
- `REGISTRY_USER` — Gitea username or machine account
- `REGISTRY_TOKEN` — Gitea personal access token with `write:packages`
Gate: workflow file is syntactically valid; documented in README.
---
### T03 — k3s insecure registry config on CoulombCore
```task
id: KEY-WP-0002-T03
status: done
priority: high
state_hub_task_id: "2dde67f9-944f-418d-a2e9-7367bc556425"
```
On CoulombCore, create/update `/etc/rancher/k3s/registries.yaml` to add
the Gitea NodePort as an HTTP mirror. Restart k3s (or send SIGHUP) and
verify `crictl pull 92.205.130.254:32166/coulomb/key-cape:latest` works.
Gate: image pull from within the cluster succeeds without TLS errors.
---
### T04 — Create Gitea machine account & token
```task
id: KEY-WP-0002-T04
status: done
priority: medium
state_hub_task_id: "25775e10-3164-4adb-9c41-835c86fde5f8"
```
In Gitea (http://92.205.130.254:32166), create a machine account
`ci-netkingdom` (or reuse an existing service account) with access to
the `netkingdom` organisation. Generate a token with `write:packages`
scope and store it in:
- Gitea Actions secrets on the `key-cape` repo: `REGISTRY_USER`, `REGISTRY_TOKEN`
- The net-kingdom credential store (SOPS-encrypted) under
`credentials/gitea-ci-token.enc.yaml`
Gate: `docker login 92.205.130.254:32166` succeeds with the token;
secret is in the credential store.
---
### T05 — Smoke test: push and pull a dev image
```task
id: KEY-WP-0002-T05
status: done
priority: medium
state_hub_task_id: "0f6ab38f-6d34-41af-9180-f19c687947b5"
```
Manually trigger a build-and-push:
```bash
docker login 92.205.130.254:32166
IMAGE_TAG=dev make push
```
Then verify the image is pullable from CoulombCore:
```bash
# on CoulombCore
crictl pull 92.205.130.254:32166/coulomb/key-cape:dev
```
Gate: pull succeeds; image is listed in Gitea -> Packages -> coulomb/key-cape.
---
### T06 — Update README with registry & CI docs
```task
id: KEY-WP-0002-T06
status: done
priority: low
state_hub_task_id: "946cd34d-94da-4fa9-a781-ed36f6c827a3"
```
Add a "Container Image" section to `README.md` documenting:
- Registry URL and image name
- How to pull (`docker pull 92.205.130.254:32166/coulomb/key-cape:latest`)
- How to build and push locally (Makefile targets)
- CI secrets required for the Actions workflow
Gate: README section present and accurate.
---
## Done Criteria
- [ ] `make image` and `make push` work locally
- [ ] `.gitea/workflows/image.yaml` present and documented
- [ ] k3s can pull the image from Gitea without TLS errors
- [ ] Machine account token stored in credential store
- [ ] Smoke test: `dev` image pushed and pulled successfully
- [ ] README updated

View file

@ -0,0 +1,200 @@
---
id: KEY-WP-0003
type: workplan
title: "Bootstrap Console OIDC Login and MFA Verification"
domain: infotech
repo: key-cape
status: finished
owner: codex
topic_slug: netkingdom
created: "2026-05-24"
updated: "2026-05-24"
state_hub_workstream_id: "02990009-a2b3-44f6-a579-487fbacae41a"
---
# KEY-WP-0003 - Bootstrap Console OIDC Login and MFA Verification
## Problem
The NetKingdom security bootstrap console now acts as a local OIDC client
callback so the operator can verify the dedicated platform-root login before
approving custody mode. The current live KeyCape deployment rejects that flow
with:
```json
{
"error": "invalid_profile_usage",
"description": "redirect_uri does not match any registered URI",
"feature": "redirect_uri"
}
```
That error is correct profile enforcement: KeyCape only accepts exact
registered redirect URIs. The live `demo-app` registration has not yet been
updated to allow the local bootstrap console callback:
- `http://127.0.0.1:8876/oidc/callback`
- `http://localhost:8876/oidc/callback`
After that is fixed, there is a second usability/security gap. KeyCape checks
privacyIDEA MFA after the Authelia callback, but the browser flow currently
expects an `mfa_token` query parameter instead of presenting a proper OTP
challenge page to the human operator.
## Goal
Make the bootstrap console's "Start demo OIDC login" button a real
end-to-end verification path for the current lightweight IAM stack:
1. KeyCape accepts the bootstrap console callback URI by exact registration.
2. The browser leaves KeyCape for the public Authelia login URL.
3. After password login, KeyCape presents a minimal MFA challenge when
privacyIDEA requires one.
4. KeyCape issues an OIDC authorization code to the bootstrap console callback.
5. The console can exchange the code and let the operator mark
`OIDC login verified` without exposing tokens or secrets.
This keeps KeyCape's security posture intact: no wildcard redirect URIs, no
dynamic client registration, no token display, and no storage of OTP material.
## Design Notes
- Prefer a dedicated public client named `netkingdom-bootstrap-console` for
long-lived clarity. Reusing `demo-app` is acceptable for the immediate
unblock only if the deployment/runbook clearly labels it as a bootstrap test
client.
- The bootstrap callback is local-only and operator-attended. It must be an
exact URI in config, not a wildcard or dynamic registration exception.
- Browser-facing Authelia redirects must use the public Authelia base URL
(`https://auth.coulomb.social`) so the human login page opens correctly.
- KeyCape may still need an internal service URL for back-channel token
exchange. If so, split the current single Authelia URL into browser-facing
authorize URL and internal token URL instead of making the browser use an
in-cluster hostname.
- The MFA prompt should collect only a one-time code, post it back to KeyCape,
validate with privacyIDEA, and then continue the normal OIDC code flow.
- This work unblocks the NetKingdom custody gate in
`NET-WP-0015-platform-root-custody-and-openbao-identity-bootstrap`.
## Implementation Notes
**2026-05-24:** Implemented in source:
- added `netkingdom-bootstrap-console` as a public OIDC client in the sample
KeyCape config, while keeping the local callback registered on `demo-app`
for compatibility,
- split Authelia browser redirects from server-side token exchange via
`browserBaseURL` and `tokenBaseURL`,
- added a browser MFA challenge page at `POST /authorize/callback` that
validates the one-time code with privacyIDEA before issuing the downstream
OIDC authorization code,
- updated NetKingdom's `keycape-config` generation template and bootstrap
console to use the dedicated client,
- added regression tests for callback registration, split Authelia URLs, MFA
challenge rendering, valid OTP continuation, and invalid OTP failure.
Live use still requires deployment: build/publish the updated KeyCape image,
refresh the live `keycape-config` Secret through the custodian age-key unlock
ceremony, and restart the KeyCape deployment.
---
## T01 - Register the bootstrap console callback client
```task
id: KEY-WP-0003-T01
status: done
priority: high
state_hub_task_id: "b396c99f-d711-475a-9cba-4f03a1db561d"
```
Add a KeyCape client registration for the bootstrap console. Either create a
dedicated `netkingdom-bootstrap-console` public client or update `demo-app`
temporarily with these exact redirect URIs:
- `http://127.0.0.1:8876/oidc/callback`
- `http://localhost:8876/oidc/callback`
Update the sample config, tests, and deployment/runbook references so the
registered client is reproducible and not just a live-cluster patch.
Gate: an authorize request using the local callback no longer returns
`invalid_profile_usage` for `redirect_uri`.
## T02 - Separate browser-facing and internal Authelia URLs if needed
```task
id: KEY-WP-0003-T02
status: done
priority: high
state_hub_task_id: "46172e6d-3e11-493c-b223-79c2fc321aec"
```
Confirm whether the current `authelia.baseURL` is safe to use for both browser
redirects and server-side token exchange. If not, add explicit configuration
for the browser authorize base URL and internal token/userinfo base URL.
Gate: the first browser redirect leaves `https://kc.coulomb.social` for
`https://auth.coulomb.social/...`; server-side token exchange still works from
inside the deployment.
## T03 - Add a browser MFA challenge step
```task
id: KEY-WP-0003-T03
status: done
priority: high
state_hub_task_id: "92fca4d0-6215-4ea6-9f80-9178ae183acb"
```
When `CheckMFARequired` returns true after the Authelia callback, render a
minimal KeyCape MFA challenge page instead of requiring `mfa_token` in the
callback query string. The page should:
- show the authenticated username and client display name,
- collect only the OTP code,
- preserve the pending OIDC state server-side,
- validate with privacyIDEA,
- continue to issue the normal authorization code on success,
- fail closed with the existing telemetry on invalid MFA.
Gate: a user enrolled in privacyIDEA can complete password + OTP in the
browser and is returned to the registered downstream callback.
## T04 - Add end-to-end profile tests for the bootstrap login path
```task
id: KEY-WP-0003-T04
status: done
priority: medium
state_hub_task_id: "079a5929-1864-4461-a64c-746cebca469d"
```
Add tests that cover:
- local bootstrap callback registration,
- rejection of unregistered callbacks remains intact,
- Authelia browser redirect uses the expected public URL,
- MFA-required login presents a challenge instead of immediate failure,
- invalid OTP fails closed,
- valid OTP produces an authorization code bound to the original PKCE session.
Gate: `make test` passes and the negative redirect URI tests remain green.
## T05 - Document the live rollout ceremony
```task
id: KEY-WP-0003-T05
status: done
priority: medium
state_hub_task_id: "1d67225d-a20b-4e36-9b2e-20836be2f439"
```
Document the deployment path for updating live KeyCape config without
regenerating unrelated secrets. The runbook must fit the NetKingdom custodian
age-key model: decrypt or unlock only during an attended ceremony, apply the
updated client registration/config, restart KeyCape, and remove plaintext
secret material afterward.
Gate: an operator can update the live `keycape-config` Secret and verify the
bootstrap console OIDC login without printing or committing secrets.

View file

@ -0,0 +1,332 @@
---
id: KEY-WP-0004
type: workplan
title: "Binky Hedgehog GmbH as first NetKingdom tenant + qonto runtime workload identity"
domain: infotech
repo: key-cape
status: finished
owner: codex
topic_slug: netkingdom
created: "2026-07-23"
updated: "2026-07-29"
state_hub_workstream_id: "817709be-5756-5ef7-9ccd-dcb84b6b9c64"
---
# Binky Hedgehog GmbH as first NetKingdom tenant + qonto runtime workload identity
Binky Hedgehog GmbH ("binky") becomes the **first real tenant** provisioned in
the NetKingdom IAM stack, per the NetKingdom IAM Profile v0.2's tenant claim
model (`net-kingdom/canon/standards/iam-profile_v0.2.md` §"Tenant Claim") —
distinct from `tenant:platform` (control plane) and `tenant:coulomb` (the
ecosystem-development tenant this workstation already operates under).
This closes **Gap #1** flagged in `qonto-assistant`'s
`workplans/QONTO-WP-0003-mcp-surface.md` closure: no OIDC issuer existed
anywhere in the fleet, so `qonto-assistant`'s MCP surface shipped with an
interim shared-secret bearer token instead of real workload identity.
`key-cape` is "Implementation complete (v0.1)" and already implements the
profile's `client_credentials`/workload-identity grant for service accounts
— it's the component that should close this gap, not `user-engine`
(downstream user/profile data layer, not an identity provider) or
`identity-canon` (terminology research only).
**Depends on:** `key-cape` v0.1 (done, `KEY-WP-0001`), NetKingdom IAM Profile
v0.2 tenant + service-account claim rules (already ratified canon — no new
profile work needed, this is provisioning against an existing contract), and
the cross-repo runtime direction now tracked in
`railiance-master/workplans/RMASTER-WP-0019-knative-qonto-runtime-on-reef-railiance.md`.
This workplan no longer targets a permanent direct `qonto-assistant`
deployment shape. Its workload-identity lane is intended for the future
`rapp-qonto` runtime on `rail-knative` hosted by `reef-railiance`, with
current `qonto-assistant` consumption allowed only as a migration bridge until
that package/rail path exists.
## Task: Confirm tenant identifier and provision the tenant plane
```task
id: KEY-WP-0004-T01
status: done
priority: high
state_hub_task_id: "c3bdbdb6-6b3f-51b5-8521-d67f113f1c3d"
```
**Decided 2026-07-23 (Bernd):** the IAM profile's `tenant:customer:<name>` /
`tenant:sandbox:<name>` suggested identifiers name a tenant by **capability
role** (customer, vendor, ...) — but tenants already known to hold multiple
roles at once (see `net-kingdom/docs/princedom-isolation-exploration.md`'s
unratified `PLTF`/`IAM`/`VEN`/`CUS` role model; Binky itself is both `CUS`
and, later, `VEN`). Using a role word as the tenant *grouping* would collide
with that separate role dimension. Decision: classify tenants by an
onboarding-risk / entity-shape **grouping**, orthogonal to capability role:
```text
trial — test/trial/showcase tenants only
friendly — known, easily reached, tolerant of experimentation/instability
single — one-person business entities (freelance consultants)
small — up to 10 employees at time of onboarding (attoo)
medium — up to 100 employees (attoo)
large — up to 1000 employees (attoo)
enterprise — 1001+ employees (attoo)
consumer — private individuals
family — a legal family
community — a non-legal group of people
association — a legal association of people
agentic — financially enabled AI entities
```
`tenant:<grouping>:<name>` replaces `tenant:customer:<name>` /
`tenant:sandbox:<name>` as the identifier shape. **Binky maps to `friendly`**
— known, reachable, tolerant of early instability — giving
**`tenant:friendly:binky`** (slug matches the OpenBao path already in
production, `tenants/binky/qonto-api`).
**Open question, not yet resolved by this decision:** this grouping list
classifies tenants as external-shaped entities: it has no obvious slot for
`tenant:coulomb` itself (the platform's own internal/reference tenant,
ADR-0006) — none of `trial`/`friendly`/`single`/.../`agentic` fit "the
platform building itself." Confirm with Bernd whether `tenant:coulomb` stays
an ungrouped special case (alongside `tenant:platform`) or gets folded into
this scheme somehow, before treating the grouping list as complete.
**Also open:** this is a fleet-wide vocabulary decision — the ratified
`net-kingdom/canon/standards/iam-profile_v0.2.md` "Tenant Claim" section
still documents the old `customer`/`sandbox` suggested identifiers, and
NetKingdom owns that canon per ADR-0011. Recording this grouping list only
in this key-cape workplan lets it drift from canon. Recommend a follow-up:
either a decision record or an ADR in `net-kingdom` adopting this grouping
list as the tenant-identifier standard, superseding the old suggested
identifiers. Not drafted here — flagging so it isn't lost.
Provision whatever tenant/org-unit boundary key-cape's current backend
(LLDAP/Authelia) needs so tokens issued for subjects under this tenant
correctly carry `tenant:friendly:binky` and nothing implies platform-root
authority for it (profile rule: tenant administration for a tenant must
never imply platform-root authority).
Done when: `tenant:coulomb`'s place in the grouping scheme is resolved; the
`net-kingdom` canon-update question above is answered (drafted or explicitly
deferred); a test-subject token carries the `tenant:friendly:binky` claim.
2026-07-27: IAM Profile v0.2 now preserves `tenant:platform` and
`tenant:coulomb` as reserved ungrouped tenants and ratifies the external
grouping vocabulary. The live service-token proof carries
`tenant:friendly:binky` without platform authority.
## Task: Provision bernd.worsch@binky-hedgehog.com as tenant-admin
```task
id: KEY-WP-0004-T02
status: done
priority: high
state_hub_task_id: "202c419e-838f-5c2f-8a52-9b1c36a6d6b9"
```
Create the user in key-cape's current backend, enroll MFA per profile
requirements (`assurance` claim, `net-kingdom/canon/standards/iam-profile_v0.2.md`),
and assign a tenant-admin role/group scoped to `tenant:friendly:binky` only.
2026-07-27 direction update: do not complete this through a one-shot operator
script. `USER-WP-0020` and `NK-WP-0023` now own a reusable self-service and
administration portal plus NetKingdom provisioning adapters. This task is
their first production acceptance case.
The `binky-hedgehog.com` mailbox must exist and be reachable for account
verification, but mailbox availability is now an input to the reusable
registration flow rather than a manual provisioning procedure.
Done when: `bernd.worsch@binky-hedgehog.com` completes OIDC/PKCE + MFA login
and receives a token carrying `tenant:friendly:binky` and a tenant-admin role
claim; the same token does **not** grant access to `tenant:platform` or any
other tenant.
2026-07-28: user-engine's deployed portal API created the tenant-admin
membership and provisioned/linked the LLDAP identity through the reusable
NetKingdom adapter. The identity is intentionally in
`password_setup_required`; LLDAP has no SMTP configuration for its native
reset delivery. First-password handoff, MFA enrollment, and final human-token
claim/denial evidence remain. No operator-set password or raw credential was
used as a shortcut.
2026-07-28 update: the reusable password handoff is now live at the canonical
KeyCape host and is issued/renewed through the user-engine administration UI.
Links are opaque, expire after 15 minutes, are single use, and older links are
revoked on renewal. A disposable-user live test proved password registration,
directory login, replay denial, and cleanup. This task now awaits only the
Binky user's own password choice, MFA enrollment, and final scoped-token
acceptance.
2026-07-29: the first portal attempt correctly denied cross-tenant access
because KeyCape had not projected the established `net-kingdom-admins` group
to the new platform-operator role. Commit `90a2078` adds and tests the explicit
`tenant:platform` / `platform-operator` compatibility mapping and is deployed
Ready. The operator must start a fresh OIDC session so the corrected claims
are minted.
2026-07-29 human acceptance: the Binky administrator completed the reusable
password setup, enrolled a privacyIDEA TOTP factor, and successfully signed in
through KeyCape with password plus OTP. Enrollment revealed that privacyIDEA's
default QR label exposed only the token serial. The live
`coulomb-friendly-token-labels` enrollment policy now emits issuer `Coulomb`
and label `{user}@{realm}` for future tokens; existing authenticator entries
must be renamed locally because wallets do not accept remote label updates.
The live token then carried `tenant:friendly:binky`, roles `user` and
`tenant-admin`, only the Binky users/admins groups, and AAL2 assurance with
`pwd` + `otp` and `mfa: true`. The same session was denied access to both
`tenant:platform` and `tenant:coulomb` administration. Acceptance complete.
## Task: Register a workload-identity OIDC client for the qonto runtime
```task
id: KEY-WP-0004-T03
status: done
priority: high
state_hub_task_id: "0d3ccd35-bad5-5c30-95a3-61fce584425d"
```
Register an OIDC client per the profile's service-account/workload-identity
flow (`client_credentials` or the documented workload-token-exchange
equivalent — §"Service Account Flow"), scoped to `tenant:friendly:binky`,
with the minimum role/scope the qonto runtime surface actually needs. The
intended steady-state consumer is the future `rapp-qonto` runtime on
`rail-knative`; current `qonto-assistant` callers may use the same lane only
as a documented migration bridge until that package split exists. No
interactive login for this client — it's for agent-harness sessions and other
automated callers, not humans.
The resulting `client_secret` must never be pasted into chat, Git, this
workplan, or a State Hub message or progress event — hand-off happens only
through T04's secrets-engine/OpenBao lane.
Done when: a `client_credentials` token exchange against key-cape succeeds
for this client and produces a short-lived service token carrying
`tenant:friendly:binky`; the `client_id`, granted scopes, and intended future
binding to `rapp-qonto` are documented here; no secret material appears
anywhere in this repo's history.
2026-07-26: Added `docs/qonto-runtime-identity-contract.md` with stable
principal/client names, least-privilege claims, non-secret evidence, and the
automation/custody boundary. Actual client issuance remains open.
2026-07-27: `KEY-WP-0006` delivered `rapp-qonto-client`. Live exchange
produced only the documented tenant, role, and `qonto:read` scope; wrong
secret and excessive scope were denied.
## Task: Route client_secret custody through secrets-engine + OpenBao
```task
id: KEY-WP-0004-T04
status: done
priority: high
state_hub_task_id: "9cb09dbf-1fd0-55c5-9c45-b3b7296ecd91"
```
Coordinate with `secrets-engine` to create a scoped OpenBao lane for this
`client_secret`, mirroring the existing `tenants/binky/qonto-api` pattern
already in production use for the Qonto bank credential itself. The steady
target is a dedicated future runtime role for `rapp-qonto`; if a temporary
bridge via the current `qonto-assistant` runtime role is needed before the
package split lands, it must be recorded as temporary rather than treated as
the final contract.
Done when: the intended qonto runtime role can fetch the `client_secret` via
`secrets-engine`/OpenBao; any temporary `qonto-assistant` bridge is explicitly
marked transitional; Bernd never handles the raw value at any point in this
flow.
2026-07-26: `warden plan` classified first-time provisioning as one explicit
founder authority act through `warden desk`. No secret was requested or read.
All repeatable verification and revocation work is designated for sanctioned
agent execution and later functional automation.
2026-07-27: Custody is established at
`platform/workloads/rapp-qonto/keycape-client`, with runtime injection through
`sso/keycape-rapp-qonto-client`. No raw value entered evidence or human
handling.
## Task: Register the credential lane with ops-warden
```task
id: KEY-WP-0004-T05
status: done
priority: high
state_hub_task_id: "39f1f803-732e-5905-97d9-c7493c66c8ee"
```
This is the task that directly minimizes Bernd's ongoing engagement: add an
entry to ops-warden's routing catalog (`registry/routing/catalog.yaml`)
pointing at this new OIDC client / login lane, following the pattern already
documented in `ops-warden/SCOPE.md`'s "Issue vs route" table ("Login / OIDC /
MFA → key-cape / Keycloak → Assist — route; proxy `login` lane when
`exec_capable`"). ops-warden does not mint tokens or take custody here — it
routes/assists, same as every other credential lane it fronts. The lane should
describe the future `rapp-qonto` runtime path on `rail-knative`, not only the
current source repo.
Once the OpenBao lane from T04 exists, run ops-warden's workload security
posture conformance checker
(`ops-warden/scripts/check_secret_posture_conformance.py`) against the qonto
runtime workload that will later become `rapp-qonto`; until the split lands,
`qonto-assistant` may be used as the migration subject if that temporary state
is recorded. Confirm it meets the expected maturity tier (dev/test/prod
posture, M0–M3 workload maturity) before this goes live with real
credentials, and record the result (pass or documented exception).
Done when: `warden route show` (or equivalent) resolves this lane correctly;
posture conformance check passes or exceptions are explicitly recorded; from
this point on, discovering and using this credential lane needs `warden
access`, not direct key-cape/OpenBao admin knowledge.
2026-07-26: Routing requirements and required conformance evidence are now
documented. Catalog publication waits for the real client and OpenBao role so
the route does not advertise an unusable lane.
2026-07-27: Published route `rapp-qonto-keycape-client`; route resolution and
the metadata-only M3/prod posture check both pass.
## Task: automation@binky-hedgehog.com service identity (deferred)
```task
id: KEY-WP-0004-T06
status: cancel
priority: low
state_hub_task_id: "fbee11fc-7a60-5dd7-8522-f80ddb4aea55"
```
Forward-looking per Bernd's framing ("I guess it will later be helpful") —
**do not provision until a concrete automation workflow actually needs its
own identity** distinct from the qonto runtime workload client from T03.
Dormant service-account credentials are themselves a posture risk (unused
attack surface, nothing to rotate against), so this stays parked rather than
front-loaded.
When a real consuming workflow exists: same pattern as T02 but scoped to an
automation-appropriate role (not tenant-admin), likely paired with its own
`client_credentials` registration for whichever agent/service authenticates
as it, following T03/T04/T05's pattern rather than inventing a new one.
Done when: explicitly triggered by a named consuming workflow — until then
this task stays `todo` and does not block closing the rest of this workplan.
## Task: Closure review
```task
id: KEY-WP-0004-T07
status: done
priority: low
state_hub_task_id: "77b0556f-62fd-5046-9ba1-718246151a6f"
```
Mark workplan finished when T01–T05 are done (T06 may legitimately remain
`todo`/parked by design — see its own task note). Confirm end to end: a
human token (bernd.worsch@binky-hedgehog.com, tenant-admin) and a service
token (future qonto runtime client, with any temporary `qonto-assistant`
bridge explicitly recorded) both resolve to `tenant:friendly:binky` and
nothing else; the credential lane is discoverable via `warden access` without
Bernd touching key-cape/OpenBao admin surfaces directly. The runtime follow-up
then continues in `QONTO-WP-0004` and `RMASTER-WP-0019`. Run
`statehub fix-consistency`.
2026-07-29 closure: T01–T05 are complete, T06 is intentionally cancelled,
the human Binky flow passed AAL2 and tenant-isolation acceptance, and the qonto
workload client passed its positive-scope and negative credential/scope checks.

View file

@ -0,0 +1,245 @@
---
id: KEY-WP-0005
type: workplan
title: "IAM Profile core claims: tenant, principal_type, groups, roles, assurance, tenant_roles"
domain: infotech
repo: key-cape
status: finished
owner: codex
topic_slug: netkingdom
created: "2026-07-23"
updated: "2026-07-23"
state_hub_workstream_id: "711a8303-69aa-5db6-9043-92530c6dc81b"
---
# IAM Profile core claims: tenant, principal_type, groups, roles, assurance, tenant_roles
Discovered while planning `tenant-engine/workplans/TEN-WP-0003-flex-auth-write-authorizer.md`:
`key-cape`'s token issuance (`src/internal/server/oidc/token.go`) does not
currently emit `tenant`, `principal_type`, `groups`, `roles`, or
`assurance` — none of `net-kingdom/canon/standards/iam-profile_v0.3.md`'s
Core Claims are implemented yet, only `preferred_username`/`email`/`groups`
(LDAP-sourced, not the profile's normalized `groups` claim) and OIDC's own
base claims (`nonce`, etc.).
This is bigger than "add the `tenant_roles` claim on top of existing tenant
support" — `key-cape` isn't yet profile-conformant at all on the tenant/
principal/assurance axis. `tenant_roles` (the reason this workplan exists)
is the last, optional claim in that same set (v0.3's addition on top of
v0.2's baseline) — implementing it in isolation without the claims it
depends on for meaning (`tenant`, `principal_type`) would be incoherent.
**This workplan is registered, not implemented, by the same session that
found the gap.** Token issuance is security-sensitive code in a Go codebase
with its own adapter conventions (`internal/adapters/{lldap,privacyidea,authelia}`)
that deserves dedicated context before changing, not a rushed edit from a
first read of `token.go`. Scoped and sequenced here so the work isn't lost.
**Depends on:** `net-kingdom/canon/standards/iam-profile_v0.3.md` (ratified).
**Related:** `tenant-engine/workplans/TEN-WP-0003-flex-auth-write-authorizer.md`
(the consumer waiting on `tenant_roles`); `key-cape/workplans/KEY-WP-0004-binky-hedgehog-tenant-onboarding.md`
(Binky's onboarding, which needs `tenant` claim support to produce a
correct `tenant:friendly:binky` token at all).
## Task: Emit `tenant`, `principal_type`, `groups`, `roles`, `assurance`
```task
id: KEY-WP-0005-T01
status: done
priority: high
state_hub_task_id: "32d5fbdc-cd2b-5bc8-9d2b-1a410b55e806"
```
Bring `token.go`'s claim assembly up to `iam-profile_v0.3.md`'s Core Claims
table baseline (the v0.2-originated set):
- `tenant`: resolve from whatever user/service/agent-to-tenant mapping
`key-cape`'s LLDAP/Authelia backend can provide today; if no such mapping
exists yet, that's itself a sub-task — this claim cannot be stamped from
nothing.
- `principal_type`: `human` for the existing Authorization Code + PKCE flow;
`service` for client-credentials flows (check whether `key-cape` already
issues client-credentials tokens at all — `grant_types_supported` in the
discovery contract claims it does, but that needs verifying against real
code, the same way this workplan verified `tenant` claim absence).
- `groups`, `roles`: normalize from LDAP group/role data into the profile's
flat string-array shape.
- `assurance`: `{level, methods, mfa, source, at}` per the profile —
`privacyIDEA`-backed MFA evidence maps to `aal2`; single-factor maps to
`aal1`.
Done when: `tools/iam-profile-conformance/` (already exists) passes against
these claims for both human and service token issuance paths.
**Done 2026-07-24, human flow only — scope split made explicit, not
silently narrowed:** verified first (as flagged): `grant_types_supported`
advertises `client_credentials` in `discovery.go`, but `token.go`'s
`ServeHTTP` only ever accepted `grant_type == "authorization_code"` — there
is no service-token issuance path at all, so "verifying against service
token issuance" isn't possible until that path exists. Building a
`client_credentials` grant handler from scratch is a distinct, materially
larger piece of work than extending the existing human flow (new endpoint
logic, new client-authentication semantics, no existing tests to extend)
— **not attempted in this pass**, left open below rather than declared done.
What shipped for the human Authorization Code + PKCE flow:
- `domain.User.Tenant` (new field, `omitempty`, empty-safe for existing
YAML configs) + `token.go`'s `effectiveTenant()`: falls back to
`tenant:coulomb` (this workstation's actual current tenant, ADR-0006)
when unset — never emits an empty `tenant` claim, never silently assigns
a user to a tenant they weren't given.
- `principal_type: "human"`, unconditional.
- `groups`/`roles`: promoted from scope-gated (only `groups`, only with the
`groups` scope) to unconditional core claims, always present as `[]`
when empty, never `null` (`nonNilStrings()`). One pre-existing test
asserted the old scope-gated behavior for `groups`
(`TestTokenHandler_ScopeFiltering_ProfileScope`) — updated to assert the
new, intentional behavior, not silently left failing or reverted.
- `assurance`: built from whether MFA was *actually verified in this
session* (`PKCESession.MFAVerified`, new field, threaded through
`completeAuthorization`'s two call sites in `authorize.go`), not from
static enrollment state — `aal2` only when MFA was required and passed
this time, `aal1` otherwise.
Verified for real: `go build ./...` and `go vet ./...` clean; `go test
./...` green across the whole repo (one pre-existing test updated to match
the new intentional behavior, described above, not silently broken). Two
new `authorize_test.go` cases directly assert `PKCESession.MFAVerified` is
set correctly on both the MFA-required and MFA-not-required paths.
`tests/profile/profile_test.go`'s `TestCompleteTokenFlow` — the repo's own
full HTTP integration test (real server, real PKCE flow, real JWT decode)
— extended with real assertions for all five new/changed claims, not just
presence checks: `tenant == "tenant:coulomb"` (the fallback path, since
the test's `testuser` has no explicit tenant), `principal_type == "human"`,
`groups == ["developers"]`, `roles == []`, and
`assurance == {level: "aal1", mfa: false, source: "key-cape", ...}` (this
test's `mockMFA` never requires MFA, so `aal1` is the correct expected
value, not `aal2`).
Not run: `net-kingdom/tools/iam-profile-conformance`'s Python conformance
tool against a live minted token — it needs a reachable issuer, which would
mean standing up the full `docker-compose.dev.yml` stack
(Authelia+LLDAP+privacyIDEA) beyond what's practical in this pass. The
equivalent claim-shape and value checks it would perform are covered by
`TestCompleteTokenFlow`'s real HTTP round trip instead — not a like-for-like
substitute, but real coverage, not an assumption.
**Explicitly still open** (not this task, not silently dropped):
`client_credentials` grant handling — service-token issuance for `key-cape`
callers with no human in the loop (`tenant-engine`'s own eventual key-cape
integration, and any other service caller). Needs its own scoped follow-up:
a new `principal_type: "service"` code path in `token.go` (or a sibling
handler), client secret/authentication semantics, and its own test suite —
this workplan's `T02` (`tenant_roles`) doesn't strictly require it, since
`tenant_roles` attaches to whatever principal_type a token already carries,
human included.
## Task: Emit `tenant_roles` (optional, cached)
```task
id: KEY-WP-0005-T02
status: done
priority: high
state_hub_task_id: "c3ee6021-b28d-50a5-be60-ecb5631f56e9"
```
At token-issuance time, call `tenant-engine`'s cache-read endpoint
(`GET /tenants/{tenant_id}/roles`) using the `tenant` claim resolved in T01,
and stamp the result as the optional `tenant_roles` claim
(`iam-profile_v0.3.md`, "Tenant Roles" section). Follows the same adapter
pattern as `internal/adapters/{lldap,privacyidea,authelia}` — a new
`internal/adapters/tenantengine` package, not inline HTTP calls in
`token.go`.
Fail open on this claim specifically, not closed: `tenant_roles` is
documented as a cache callers must not trust for privileged decisions
anyway (`flex-auth` re-validates live). If `tenant-engine` is unreachable at
issuance time, omit the claim rather than fail the entire token issuance —
losing a performance optimization is acceptable; blocking login because a
cache source is down is not. This is the one place in the whole `tenant_roles`
design where fail-open is correct, precisely because the live-lookup path
exists as the real gate.
Done when: token issuance still succeeds with `tenant_roles` omitted when
`tenant-engine` is unreachable (test simulates the outage); present and
correct when reachable, for a tenant with known role grants.
**Done 2026-07-24:** New `internal/adapters/tenantengine` package
(`Client.Roles(ctx, tenantID) ([]string, bool)`), mirroring
`internal/adapters/{lldap,privacyidea,authelia}`'s shape exactly. Fails
open by construction — unreachable, non-200, or malformed body all return
`(nil, false)`, never an error the caller has to specially handle; a `nil
*Client` also fails open safely rather than panicking, so `TokenHandler`
doesn't need a separate "is this configured" branch. Wired into
`TokenHandler.TenantEngine` (nil by default — existing tests and call
sites are unaffected); `token.go` stamps `tenant_roles` only when `ok`.
7 adapter-level tests (success, non-200, malformed body, connection
failure, timeout, nil client, empty base URL). 3 new `token_test.go` cases
prove the actual required behavior end-to-end through `TokenHandler`, not
just at the adapter layer: present and correct when reachable; **token
issuance still returns 200 with every other required core claim intact**
when unreachable, `tenant_roles` simply absent (the literal done-criteria);
absent when `TenantEngine` isn't configured at all. `go build`/`vet`
clean, `go test ./...` green repo-wide.
**A real bug found and fixed along the way, not worked around:** the first
live cross-process check (real `flex-auth`, real `tenant-engine`, this
adapter) returned `tenant_not_found` for a tenant that genuinely existed.
`tenant-engine`'s `GET /tenants/{tenant_id}/roles` was keyed by its
internal `tenant_id`, but `key-cape` (like any external caller) only ever
has the tenant's profile *identifier* (the `tenant` claim value) — it has
no way to know tenant-engine's internal id. Fixed at the source:
`tenant-engine/workplans/ADHOC-2026-07-24.md` (identifier-or-id resolution
added to `InMemoryTenantStore`), re-verified with the exact same live
three-process chain afterward — `roles=[IAM] ok=true`, resolved by
identifier. This is exactly why T02 insisted on a live-server-reachable
test rather than only an `httptest` fake: the fake would have used
whatever id shape the test author happened to pick and never caught this.
## Task: Closure review
```task
id: KEY-WP-0005-T03
status: done
priority: low
state_hub_task_id: "c88a3457-2964-5c44-8220-76f82df3a753"
```
Confirm T01–T02 done; conformance suite green for both tasks together.
Note in closure: this unblocks `KEY-WP-0004`'s Binky onboarding actually
producing conformant tokens, and unblocks `flex-auth`'s side of the
`tenant_roles` picture receiving a real claim to reason about (though
`flex-auth`'s live-lookup path doesn't depend on this claim existing — only
the cache-read/performance path does). Run `statehub fix-consistency`.
**Closed 2026-07-24.** T01–T02 done. `go build ./...`, `go vet ./...` clean;
`go test ./...` green across the whole repo. Python
`net-kingdom/tools/iam-profile-conformance` not run against a live instance
(needs the full Authelia+LLDAP+privacyIDEA stack, impractical in this
pass) — real coverage instead came from `tests/profile`'s own full HTTP
integration test (`TestCompleteTokenFlow`) with genuine claim-value
assertions, and from three separate live cross-process checks against a
real `tenant-engine` (one of which found and fixed a real bug in
`tenant-engine` itself, `ADHOC-2026-07-24`).
**What this closes:** `key-cape`'s human Authorization Code + PKCE flow now
emits every IAM Profile v0.3 core claim (`tenant`, `principal_type`,
`groups`, `roles`, `assurance`) plus the optional `tenant_roles` cache,
correctly sourced from a real `assurance` signal (MFA actually verified
this session, not static enrollment) and a real `tenant-engine` call
(fail-open, never blocking login). `KEY-WP-0004`'s Binky onboarding can now
produce conformant tokens for human logins.
**What stays explicitly open, not silently dropped:**
- `client_credentials` / service-token issuance — no such flow exists in
`token.go` at all yet (confirmed in T01); needed before any *service*
caller (not a human) can get a token with `principal_type: "service"` and
its own `tenant`/`tenant_roles`. A materially larger, separate piece of
work than either task in this workplan.
- `flex-auth`'s side of `tenant_roles` (`FLEX-WP-0008`, already closed
separately) doesn't depend on this claim existing at all — its
live-lookup path calls `tenant-engine` directly, so this workplan doesn't
block it, but also doesn't complete the full `tenant_roles` picture on
its own until service tokens exist too.

View file

@ -0,0 +1,116 @@
---
id: KEY-WP-0006
type: workplan
title: "Client credentials and service-token issuance"
domain: infotech
repo: key-cape
status: finished
owner: codex
topic_slug: netkingdom
created: "2026-07-27"
updated: "2026-07-27"
state_hub_workstream_id: "d8d20d8f-61e0-54e2-9a88-a3958f0b6f2d"
---
# KEY-WP-0006 - Client credentials and service tokens
Implement the missing service-principal half of the IAM Profile so
`KEY-WP-0004` can issue a least-privilege identity to `rapp-qonto`.
## T01 - Model confidential service clients
```task
id: KEY-WP-0006-T01
status: done
priority: high
state_hub_task_id: "993913a7-f951-50ee-9b10-d5b9e67b523c"
```
Extend static client registration with an explicit service subject, tenant,
roles, and an environment-backed secret reference. Refuse startup when a
`client_credentials` client is public, lacks its identity fields, or cannot
resolve its secret. Never serialize or log the resolved secret.
2026-07-27: Added explicit service subject, tenant, roles, and in-memory-only
resolved secret fields. Configuration validation rejects public or incomplete
service clients, and startup resolves only `env:` secret references while
reporting the variable name—not its value—on failure.
## T02 - Implement secure client_credentials exchange
```task
id: KEY-WP-0006-T02
status: done
priority: high
state_hub_task_id: "801a6d55-2f91-51b1-88e4-cc7f0867238d"
```
Accept confidential client authentication through HTTP Basic, compare secrets
in constant time, restrict requested scopes to the registered allowlist, and
issue a short-lived access token with `principal_type=service`,
tenant-specific subject/roles, empty groups, `aal1` client-secret assurance,
and optional cached `tenant_roles`. Do not issue an ID token for this
non-human grant.
2026-07-27: Implemented HTTP Basic confidential-client authentication with
fixed-length SHA-256 constant-time comparison, allowlisted scopes, short-lived
service claims, optional tenant-role cache, token-issued telemetry, and no ID
token.
## T03 - Prove positive and negative conformance
```task
id: KEY-WP-0006-T03
status: done
priority: high
state_hub_task_id: "e5c2a8b1-0879-57fa-a197-250a34c75bdb"
```
Cover valid exchange, unknown client, public client, missing/wrong secret,
unsupported grant, excess scope, tenant isolation, telemetry redaction, and
discovery metadata. Run the full Go build, vet, and test suite.
2026-07-27: Added positive service-claim and negative wrong-secret/excess-scope
tests, updated discovery to advertise only implemented grants/auth methods,
and verified `go test ./...`, `go vet ./...`, and `go build ./...` across the
module.
## T04 - Provision and verify rapp-qonto
```task
id: KEY-WP-0006-T04
status: done
priority: high
state_hub_task_id: "31af6b8c-96ff-5060-87db-3803b9a9dd12"
```
Generate the client secret without disclosure, store it through the approved
OpenBao lane, register the static `rapp-qonto` client, deploy KeyCape, and
verify a real exchange yields only the documented Binky service claims.
Publish non-secret evidence to `KEY-WP-0004-T03` through T05.
2026-07-27: Provisioned the confidential client secret at
`platform/workloads/rapp-qonto/keycape-client`, injected it into
`sso/keycape-rapp-qonto-client`, registered `rapp-qonto-client`, and deployed
image `main-e877d27-2` on railiance01. A real exchange produced only
`sub=rapp-qonto`, `tenant=tenant:friendly:binky`, `principal_type=service`,
`roles=[qonto-reader]`, empty groups, and `scope=qonto:read`; excessive scope
and a wrong secret were denied. Public DNS still resolves
`kc.coulomb.social` to the older CoulombCore endpoint, so the deployment proof
used TLS-preserving direct resolution to railiance01 (`92.205.62.239`).
## T05 - Closure review
```task
id: KEY-WP-0006-T05
status: done
priority: low
state_hub_task_id: "8c72d7d9-4086-5e64-b939-a12bff076124"
```
Close after T01-T04 pass and the repeatable verification path is documented.
2026-07-27: Closure review passed. Build, vet, unit/profile tests, live
positive claims, negative authentication/scope checks, and discovery metadata
all passed. The stale public DNS target is handed off as routing work rather
than weakening the service-token contract.

View file

@ -0,0 +1,81 @@
---
id: KEY-WP-0007
type: workplan
title: "User-engine portal OIDC relying-party integration"
domain: infotech
repo: key-cape
status: finished
owner: codex
topic_slug: netkingdom
created: "2026-07-27"
updated: "2026-07-29"
depends_on:
- KEY-WP-0005
- USER-WP-0020
state_hub_workstream_id: "24db4178-705c-5b09-b7db-ee7a345d71a0"
---
# KEY-WP-0007 - User-engine portal OIDC relying-party integration
Register and prove the reusable user-engine portal as a strict KeyCape public
client. This is the authentication edge required by `NK-WP-0023-T03`; it does
not move user-domain or authorization ownership into KeyCape.
## Register the static portal client
```task
id: KEY-WP-0007-T01
status: done
priority: high
state_hub_task_id: "7d722cae-bf6d-52e0-80f4-cc51e9fb42f0"
```
Register `user-engine-portal` for authorization code with mandatory S256 PKCE,
exact callback URIs and only `openid profile email groups`. No wildcard,
implicit flow, client secret, or dynamic registration is allowed.
## Deploy and verify the live client
```task
id: KEY-WP-0007-T02
status: done
priority: high
state_hub_task_id: "0b222062-4352-5ae6-9e85-95787af08409"
```
Update the railiance01 KeyCape configuration without exposing its signing key
or backend credentials. Verify discovery, authorize redirect validation,
unregistered callback denial and successful token exchange through the portal.
## Prove claims and MFA isolation
```task
id: KEY-WP-0007-T03
status: done
priority: high
state_hub_task_id: "859e7322-71dc-58cc-9f44-6f869c2d0b59"
```
Prove issuer, audience, tenant, groups, roles and assurance claims are verified
by the portal and that tenant administration does not imply platform-root.
Complete the Binky user/MFA acceptance through the reusable browser path.
2026-07-27: The live client accepts only the exact portal callback and rejects
an unregistered callback with `invalid_profile_usage`. The portal begins an
S256 PKCE flow and hands authentication to Authelia. LLDAP tenant envelope
mapping now recognizes unambiguous `tenant:<kind>:<slug>:users|admins` groups;
ambiguous multi-tenant directory envelopes fail closed to no explicit tenant.
2026-07-28: suspension now fails closed at both token exchange and
`/userinfo`; the deployed `key-cape:909bb32` image refuses subjects carrying
the `netkingdom-suspended` directory group. The portal admin surface uses this
through the provider-neutral lifecycle API. The human Binky MFA/claims
acceptance remains the final part of this task.
2026-07-29 complete: the reusable browser path issued the Binky password
handoff, completed privacyIDEA TOTP enrollment, and returned through the
portal authorization-code flow. `/api/v1/me` verified the canonical issuer,
`user-engine-portal` audience, `tenant:friendly:binky`, Binky-only groups,
roles `user` + `tenant-admin`, and AAL2 assurance with `pwd` + `otp` and
`mfa: true`. The same session was denied administration of
`tenant:platform` and `tenant:coulomb`. All workplan tasks are complete.

View file

@ -0,0 +1,118 @@
---
id: KEY-WP-0008
type: workplan
title: "Registration handoff and client-aware MFA policy"
domain: infotech
repo: key-cape
status: finished
owner: grok
topic_slug: netkingdom
created: "2026-08-09"
updated: "2026-08-16"
depends_on:
- NK-WP-0025
state_hub_workstream_id: "23eb67d4-cf10-57a1-92f6-90146a294bda"
---
# KEY-WP-0008 - registration handoff and client-aware MFA
Let a registered OIDC client offer NetKingdom signup and request step-up
without making KeyCape an account store or weakening high-assurance clients.
## T01 - Add a safe registration handoff
```task
id: KEY-WP-0008-T01
status: done
priority: high
state_hub_task_id: "e5fb4d73-1e19-565c-80d5-e0eee274ce63"
```
Define an allow-listed registration URL for eligible clients and preserve
client ID, redirect URI, PKCE intent, tenant hint, and return context in a
signed, expiring state envelope. Registration completion must restart the
normal authorization flow and must not mint a token directly.
Done when unknown users can choose signup from an eligible authorization flow
without open redirect, client substitution, or state replay.
Implemented `prompt=create` and `/authorize/register` against the client's
static `registrationUrl`, plus HMAC-signed `kc_handoff` envelopes consumed
once at `/authorize/return`. Return restarts `/authorize` and never mints a
code. Ineligible clients get no signup link. Live registration entry remains
user-engine-owned per NK-WP-0025; KeyCape only issues the return envelope.
## T02 - Replace global MFA with client-aware minimum assurance
```task
id: KEY-WP-0008-T02
status: done
priority: high
state_hub_task_id: "1ba50454-b6f5-578e-a526-da74b8082a89"
```
Replace the single require-for-all switch with policy that combines client
minimum assurance, requested ACR/step-up, tenant policy, protected action, and
current session assurance. Preserve mandatory MFA for platform/admin clients.
Allow coulomb-social ordinary login at password assurance when no stronger
rule applies.
Done when one low-assurance client cannot suppress MFA for another client or
reuse an under-assured session for a high-assurance request.
Implemented with nullable per-client `mfaRequired`: an explicit client value
overrides the provider default only for that client. Absent values preserve
the existing provider-driven policy.
2026-08-16: `DecideAssurance` now combines client minimum, requested ACR,
provider default, `max_age`, `prompt=login`, and current KeyCape login-session
level. An AAL1 session cannot satisfy an AAL2 client or `acr_values=aal2`.
`coulomb-social` in `config/dev-config.yaml` is `mfaRequired: false`; other
clients keep the provider default. Users without an enrolled factor are sent
to the client's `enrollmentUrl` instead of completing authorization.
## T03 - Support explicit step-up and fresh authentication
```task
id: KEY-WP-0008-T03
status: done
priority: high
state_hub_task_id: "f7acbc12-e597-5d1e-8b29-bfa02237730f"
```
Implement supported ACR/max-age or equivalent IAM Profile parameters, invoke
privacyIDEA only when policy requires it, and return verifiable assurance
claims. Handle users without an enrolled factor through a safe enrollment
handoff rather than an authorization bypass.
Done when coulomb.social can request MFA for a profile/action and verify the
result from token claims.
Implemented `acr_values` preservation and AAL2/MFA forcing through the
privacyIDEA challenge. The existing token `assurance` claim reports `aal2`
and `mfa: true` only after successful verification.
## T04 - Prove policy isolation and compatibility
```task
id: KEY-WP-0008-T04
status: done
priority: high
state_hub_task_id: "bc660767-387e-59a5-bfce-caccf4082c08"
```
Test known and unknown users, registration link eligibility, state expiry and
replay, password-only coulomb-social login, profile/action step-up, no-factor
enrollment, OpenBao mandatory MFA, cross-client session reuse, and logout.
Keep static client registration and exact redirect rules unchanged.
Done when existing high-assurance clients pass unchanged and the new
coulomb-social journey passes live.
2026-08-16: isolation tests cover known/unknown users, registration
eligibility, state expiry/replay, password-only coulomb-social, ACR step-up,
no-factor enrollment handoff, OpenBao mandatory MFA, cross-client AAL1
session reuse, logout, and exact redirect enforcement. Full Go suite passes.
Live coulomb-social AAL1/AAL2 isolation was already proven on railiance01
under NK-WP-0025-T05 (2026-08-14); this closeout adds the KeyCape-side
regression suite and `/logout`.

View file

@ -0,0 +1,138 @@
---
id: KEY-WP-0009
type: workplan
title: "Provider capability declarations and bounded service identities"
domain: infotech
repo: key-cape
status: finished
owner: codex
topic_slug: netkingdom
created: "2026-08-23"
updated: "2026-09-27"
depends_on:
- NK-WP-0030
- KEY-WP-0006
state_hub_workstream_id: "c1a9b1cc-2ff0-566a-b544-1a2ef967fc0d"
---
# KEY-WP-0009 — Provider capabilities and bounded service identities
Publish KeyCape-owned security-scenario interfaces and make the existing
service-token issuer precise enough for OpenBao machine-login consumers. This
work accepts identity issuance ownership without taking over OpenBao roles,
policies, secret custody, or privacyIDEA token lifecycle.
## Publish C1 and C2b provider declarations
```task
id: KEY-WP-0009-T01
status: done
priority: high
state_hub_task_id: "eaf7af48-b3d6-5f85-b0ce-ff2b640f9cc3"
```
Publish Playbook Capability Contract v0.1 declarations for the KeyCape C1
runtime and its privacyIDEA-backed C2b integration. Name exact entry points,
parameter authority, resource ownership, trust requirements, and readiness
evidence. Do not claim C2a or privacyIDEA-owned token lifecycle.
Published `capabilities/playbooks/key-cape.lightweight-sso.yaml` and
`capabilities/playbooks/key-cape.privacyidea-token-authority.yaml`. The latter
claims the KeyCape integration for C2b while leaving factor enrollment, token
state, custody keys, validation decisions, and lifecycle with privacyIDEA.
## Define bounded service-auth contracts
```task
id: KEY-WP-0009-T02
status: done
priority: high
state_hub_task_id: "212eb75c-a36c-5cb8-bf67-7d524f00f9ef"
```
Define the KeyCape service-auth claims, renewal/expiry, failure, custody, and
owner boundaries required by secrets-engine and OpenBao JWT roles. Add
per-client access-token lifetimes so a bounded client contract does not depend
on an unrelated global default.
Added a validated 1m-1h per-client `tokenLifetime` override, applied it to JWT
`exp` and `expires_in`, and documented claims, renewal, expiry, residual JWT
validity, explicit OpenBao cleanup, and no-fallback failure semantics in
`docs/openbao-service-auth-contract.md`.
## Accept coding-agent issuance ownership
```task
id: KEY-WP-0009-T03
status: done
priority: high
state_hub_task_id: "c41e0144-8169-57cf-8c34-eb52e65d6d76"
```
Publish the non-secret static registration for `codex-railiance-platform` with
the exact audience, subject, tenant, role, scope, and 15-minute lifetime already
accepted by railiance-platform. KeyCape owns JWT issuance and client disablement;
railiance-platform owns the exact-bound OpenBao role and policy; OpenBao owns
resulting token enforcement; secret values remain outside this repository.
Accepted ownership through the exact non-secret registration in
`config/service-clients.example.yaml`. Added the parallel reviewed
`secrets-engine-openbao` contract. Live value generation/materialization,
deployment merge, and OpenBao role/policy application remain with their named
custody and platform owners and are not implied by this source registration.
## Validate and hand off
```task
id: KEY-WP-0009-T04
status: done
priority: high
state_hub_task_id: "6945c0c0-bbb6-552b-8d62-d806d3fd0018"
```
Run the canonical declaration validator, Go formatting/build/vet/tests, and
repository checks. Reply to NetKingdom, secrets-engine, ops-warden, and
railiance-platform with revision-independent paths and exact ownership status.
Both declarations pass NetKingdom's canonical validator. The full Go suite,
vet, build, `gofmt`, YAML parsing, and `git diff --check` pass using an explicit
Go 1.23 toolchain and writable local cache; the workstation's default Go cache
is read-only and was not used.
2026-09-05 correction: the earlier statement that State Hub handoffs were
delivered was not supported by receipt IDs. The audit found no matching handoff
receipts in the Hub response. KEY-WP-0014-T02 records the correction.
2026-09-08 closure. Verification re-run on `main` with `GOTOOLCHAIN=go1.23.0` and
a writable `GOCACHE`: `make contract-test` PASS, `go build ./...`, `go vet ./...`
and `go test ./...` all pass, `git diff --check` clean. `gofmt` had drifted on
`src/internal/server/telemetry/events.go` and `src/tests/migration/scenario_c_test.go`
(comment alignment only, no semantic change); both were reformatted so the
formatting claim now holds.
The four handoffs were then actually sent, and each receipt is readable back via
`GET /messages/?from_agent=key-cape`:
| Recipient | Receipt id |
| --- | --- |
| netkingdom | `692ed0d7-e21b-48e7-8600-5b32b551c627` |
| secrets-engine | `479a1698-87dc-4ee2-a732-c2dc18bc4e84` |
| ops-warden | `5bc58847-eed5-4164-bb0f-a0d8455e75b8` |
| railiance-platform | `9c6a4e70-6bc0-4ad6-8293-35a56e507dfc` |
Each names revision-independent paths, the 2026-09-08 verification result, and
the same ownership split: KeyCape owns issuance, static registrations, client
disablement and the claim set; secret custody, OpenBao roles/policies and token
enforcement, privacyIDEA factor and token lifecycle, resource authorization and
deployment remain with their named owners. C2a is not claimed. This closes the
notification gap; it is still not evidence of live deployment or provider cutover.
### 2026-09-27 operator tenant-zero correction
OpenBao infrastructure identities belong to `tenant:platform`, not Coulomb.
Corrected both static infrastructure registrations, matching source tests and
the tenant/OpenBao contracts. Consumer preflight and platform role sources are
updated together; no live registration or token migration is claimed. The
provider contract remains complete; RPF-WP-0035-T02 and SECRETS-WP-0008-T06
retain coordinated custody/deployment and native rejection/revocation proof.
Human fallback and workload tenant grants are not elevated to platform.

View file

@ -0,0 +1,33 @@
---
id: KEY-WP-0010
type: workplan
title: "Admit the operator-tunneled OpenBao callback"
domain: infotech
repo: key-cape
status: finished
owner: codex
topic_slug: netkingdom
created: "2026-08-23"
updated: "2026-08-23"
depends_on:
- RAILIANCE-WP-0027
state_hub_workstream_id: "8bdf9a24-9dc1-53f9-9448-3b29c36d9950"
---
# KEY-WP-0010 — OpenBao operator loopback callback
## Admit and validate the exact callback
```task
id: KEY-WP-0010-T01
status: done
priority: high
state_hub_task_id: "46d78907-bda4-5cc9-853d-98c0026d9fe1"
```
Added the exact operator-only callback
`http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback` to the
source-owned `openbao-admin` static client in `config/dev-config.yaml`. The
registration retains exact-match redirects and does not authorize a login,
change the OpenBao role, broaden a listener, or handle a credential. A config
test pins the exact URI and proves the complete source config validates.

View file

@ -0,0 +1,68 @@
---
id: KEY-WP-0011
type: workplan
title: "Recover from live KeyCape Secret output exposure"
domain: infotech
repo: key-cape
status: finished
owner: codex
topic_slug: netkingdom
created: "2026-08-23"
updated: "2026-08-23"
state_hub_workstream_id: "158aa957-8818-52ea-b7a5-3962cf30ba5e"
---
# KEY-WP-0011 — Live KeyCape Secret exposure recovery
Recover safely after the base64 data map of Kubernetes Secret
`sso/keycape-config` was mistakenly rendered in agent tool output during a
metadata check. Never reproduce, decode, or store the exposed payload.
## Contain and notify owners
```task
id: KEY-WP-0011-T01
status: done
priority: high
state_hub_task_id: "a8f5e33d-c725-5181-849e-a42bba34d3d8"
```
Stopped live rollout work, treated the payload as exposed, and notified
railiance-platform and NetKingdom without values. The affected classes are the
KeyCape RS256 signing private key and credentials embedded in `config.yaml`
(LLDAP bind credential, Authelia client credential, and privacyIDEA admin
token). No value was decoded, reused, or intentionally repeated and no live
mutation was attempted.
## Approve and execute coordinated rotation
```task
id: KEY-WP-0011-T02
status: done
priority: high
state_hub_task_id: "9d60569c-a0f1-53a9-b4dc-01687ab92bcc"
```
Approved by the operator on 2026-08-23, accepting deliberate invalidation of
active sessions and a brief SSO interruption. The RS256 key, LLDAP bind/admin
credential, Authelia-KeyCape client credential, and privacyIDEA application
signing secret were replaced without rendering values. The exposed
privacyIDEA admin JWT was removed from live use. Replacement material is
recoverable only through the NetKingdom age recipient in the encrypted
recovery overlay under `history/`.
## Verify recovery without values
```task
id: KEY-WP-0011-T03
status: done
priority: high
state_hub_task_id: "a079573d-97fd-5c6c-b1f9-c0af65e2933a"
```
Verified changed resource versions and public JWKS fingerprints, four Ready
deployments, successful replacement LLDAP and Authelia authentication,
wrong-secret rejection, explicit privacyIDEA fail-closed denial, issuer
discovery, and the OpenBao callback handoff to Authelia. The temporary LLDAP
force-reset flag was removed. No Kubernetes Secret payload or authentication
value was rendered during recovery.

View file

@ -0,0 +1,57 @@
---
id: KEY-WP-0012
type: workplan
title: "Repair UserInfo canonical subject resolution"
domain: infotech
repo: key-cape
status: finished
owner: codex
topic_slug: userinfo-canonical-subject-resolution
created: "2026-08-31"
updated: "2026-09-01"
state_hub_workstream_id: "2aca83d8-4e80-52c6-8929-f1e957576b47"
---
## Repair subject lookup
```task
id: KEY-WP-0012-T01
status: done
priority: high
state_hub_task_id: "bc1ee2c7-a321-5914-96d1-9cb871f0ee1b"
```
Resolve the canonical LDAP-DN `sub` emitted by the token endpoint without
passing it to the username-only repository lookup. Preserve stable subject
semantics and verify any `preferred_username` lookup against the canonical ID.
## Regression verification
```task
id: KEY-WP-0012-T02
status: done
priority: high
state_hub_task_id: "7073cc30-a518-5c27-acaf-1a323fd981c6"
```
Cover canonical-ID, legacy username-sub, missing subject, and suspended-user
behavior. Run the KeyCape test suite and image build checks.
## Deploy and verify OpenBao OIDC
```task
id: KEY-WP-0012-T03
status: done
priority: high
state_hub_task_id: "571c5237-93fa-5c61-87af-974e02fd8929"
```
Publish and deploy the corrected KeyCape image, prove `/userinfo` accepts a
fresh human access token, then resume the governed Policy Nexus bootstrap.
Completed 2026-09-01. Forgejo Actions run 14 built commit
`153258b9d39273d53ca7ab5d1bdc09961ea9026e`; image digest
`sha256:4c7193efc3e50bd72a01812a583e823cc04f41ca96ef387b31ba9ae2533b80d0`
rolled out successfully. A fresh OpenBao OIDC flow passed `/userinfo`, and the
governed Policy Nexus bootstrap subsequently completed with deterministic
session revocation.

View file

@ -0,0 +1,573 @@
---
id: KEY-WP-0013
type: workplan
title: "Approval-engine resource audience and client registrations"
domain: infotech
repo: key-cape
status: finished
flavor: implementation
owner: codex
topic_slug: approval-engine-resource-audience
created: "2026-09-05"
updated: "2026-09-30"
state_hub_workstream_id: "6e815d88-b0e3-5ce0-be5d-13ab15917f7f"
---
Source: approval-engine inbox request 5583e896-52f2-45bd-895f-02f227b7e836,
reviewed against its local registration and caller-authentication contracts.
## Implement static resource audiences
```task
id: KEY-WP-0013-T01
status: done
priority: high
state_hub_task_id: "a92432a2-4e92-5b90-be2b-d82b784bf8f0"
```
Added optional static audience configuration for access tokens in both grants;
ID tokens retain the relying-party audience. Added human access-token scope.
Published bounded approval service fragments and the human registration contract.
Regression tests cover the default audience, request override resistance, JWKS
signature validation, ID-token separation and service registration scope isolation.
Browser requests and token exchanges now enforce the client scope allow-list,
including grants removed after authorization.
## Provision and prove live registrations
```task
id: KEY-WP-0013-T02
status: done
priority: high
state_hub_task_id: "607897c5-bad9-55e5-86df-7802f592d6e8"
```
Needs deployment-owned custody for both new service secret references and the
compatible image/configuration rollout. The upstream issuer is now verified and
pinned by NetKingdom (2026-09-09). Deploy the implementation and service registrations together,
then prove live JWKS verification and denied excess scopes without logging values.
Local signature proof is not live rollout evidence. See docs/approval-engine-auth-contract.md.
The separate human UI callback gate is retained in T05; a bearer-only resource
server does not own a redirect and its absence does not block these service clients.
2026-09-05 follow-up: read-only deployment metadata shows the current image is
forgejo.coulomb.social/coulomb/key-cape:main-153258b and only the Qonto service
secret environment reference is present. The two approval clients are not
materialized through deployment environment references. Published a concrete
non-secret admission packet at docs/approval-engine-provisioning-request.yaml.
Custody routing has no exact admitted lane for these two clients. `warden plan`
returned founder_required but matched an unrelated generic database lane; that
mismatch is not authority to provision. Human callback clarification is pending.
No secrets were read or production resources changed.
2026-09-08: the two blocking requests were sent and their receipts are readable
back via `GET /messages/?from_agent=key-cape`.
- `278a3ebe-b529-49f6-bd1a-e3ebcf318260` -> railiance-platform. Custody admission
for both clients, quoting `docs/approval-engine-provisioning-request.yaml`
verbatim: exact OpenBao paths and `client_secret` field, the two Kubernetes
delivery references, the two `KEYCAPE_*_CLIENT_SECRET` environment names,
`tenant:platform` per decision `5ed3fb35-eca9-413a-82b9-95171ba85bf6`, and the
15m lifetime. It asks for four things explicitly: confirm/correct the paths and
fields, confirm the delivery references, name the authority for the attended
first provision, and agree a rollout window in which registrations and the
KeyCape version that reads them deploy together. It restates that the
`warden plan` `founder_required` result matched an unrelated generic database
lane and is not being treated as authorization.
- `43fbd997-6e22-4859-8433-58b784c2df6c` -> approval-engine. Requests the two
missing human-registration inputs — the exact `client_id` and the full callback
URI — noting redirects are matched exactly and a near-miss fails closed at
`/authorize`. Restates the human client shape (public, S256 PKCE,
`audience: approval-engine`, `allowedScopes: [openid, approval:approve]`,
`mfaRequired: true`, no consume grant) and asks approval-engine to confirm it
validates the access token, not the ID token, against the deployed `/jwks`.
Deployment state is unchanged from the 2026-09-05 observation: neither approval
client is materialized. Task remains `wait` — the two admissions are owned by
railiance-platform and approval-engine respectively, and live provisioning proof
cannot begin until both land. Still no secret read and no production resource
changed.
2026-09-08, second pass. Custody and the human callback are still owed, but one
part of this task was ours all along and was not built: the task requires proving
"live JWKS verification and denied excess scopes without logging values", and
there was no way to do that except by hand, against production, at the moment
custody lands — which is the worst time to be improvising a check.
Shipped `keycape verify-client` (`src/internal/authclient/verify.go`, documented
in `docs/native-authentication.md`). Per registration it verifies discovery
origin, the `client_credentials` exchange and its RS256 signature against the
deployed JWKS, exact `sub`/`tenant`/`roles`/`principal_type`, that every
`-deny-scope` is refused, and that the token carries no scope that was not
requested — the last being a gap the caller commands do not cover, since they
prove requested scopes were granted and not that nothing extra came back.
Failures name the claim, never the observed value, and nothing is written to
disk, so it is safe to run against production. The exact invocation for each of
the two clients is now recorded in the `verification:` block of
`docs/approval-engine-provisioning-request.yaml`, so admission hands back a
command rather than a description.
Tests: `src/internal/authclient/verify_test.go` covers the passing case, an
over-broad registration, a live predecessor secret, an identical "rotation" and
output non-disclosure. Verified with teeth — neutering `mustFail` makes the suite
fail rather than pass silently.
Task stays `wait`. What is owed from elsewhere is unchanged and unreduced: the
two secret values through an admitted custody path, and the exact human
`client_id` and callback URI. Nothing here provisions anything.
### 2026-09-08 attended upstream issuer proof — T02 return
The user explicitly admitted the prepared temporary probe. At 21:44:44 UTC its
actual Authelia authorization-code exchange verified the signed issuer
**`https://auth.coulomb.social`**, including signature, keycape audience, validity
window and browser-bound nonce. The pinned Job exited 0. No token or user claim
was retained, and no downstream credential was issued.
All five temporary resources and the Pod were removed with scoped cleanup;
KeyCape Deployment and config Secret metadata remained unchanged. Receipt:
`docs/evidence/2026-09-08-upstream-issuer-live-proof.json`.
This closes the actual-token issuer observation gate. T02 remains wait for the
configuration owner to ensure `authelia.issuer` is pinned to the verified HTTPS
value, named CCR-2026-0017/0018 reviews, custody delivery and compatible rollout.
The diagnostic did not prove downstream KeyCape MFA/application login and did
not activate custody. Do not repeat discovery or request another observation
unless the provider/configuration or proof context changes.
2026-09-09 configuration-owner return: NetKingdom's exercised, revision-guarded
issuer-only helper inserted `authelia.issuer: https://auth.coulomb.social` into
`sso/keycape-config`; resourceVersion `51346058` -> `58713343`. Independent
readback matches. Every other configuration byte and Secret data entry, including
the private key, was preserved. Thirteen tests passed. Receipt:
`docs/evidence/2026-09-09-upstream-issuer-pin.json`.
The stored issuer configuration prerequisite is closed. No process restart,
image rollout, custody activation or downstream login proof occurred. T02 stays
wait for the named CCR reviews, attended custody and compatible rollout. The
single-instance replacement and existing-human-login checks remain mandatory.
2026-09-09 completion: both named CCR reviews are recorded. Verifier-side custody,
ESO delivery and the compatible pinned image/config are now live. Both service
clients passed the pinned verifier plus independent live signature and exact
claim/lifetime checks; wrong-secret and excess-scope requests were rejected.
The existing OpenBao human login passed before and after replacement; each
attended session self-revoked. Receipt: `docs/evidence/2026-09-09-keycape-verifier-admission.json`.
The deployment owner exercised compatible rollback and version-preserving resume.
The final helper checks containerd imageID against the pinned manifest and uses
the existing 30-second future-iat bound without extending token expiry. The
pinned native verifier runs in the contained owner process because workload
network policy excludes public issuer HTTPS. No network grant was broadened.
Live residual ownership remains KEY-WP-0013-T05 for the approval UI callback/MFA;
RPF-WP-0035-T05 for separate client-side delivery; AUDIT-WP-0009-T09 for audit
custody; APPROVAL-WP-0002 and SECRETS-WP-0009-T03 for end-to-end claim/consume,
expiry/revocation and native delivery. Natural JWT expiry and real predecessor
rotation were not exercised by initial provisioning.
## Reconcile tenant vocabularies across approval layers
```task
id: KEY-WP-0013-T03
status: done
priority: high
state_hub_task_id: "e98da2eb-1f05-5c7f-bc84-711fa38b8c4a"
```
Source: glas-harness inbox message 356f6977-d361-4e3b-83ab-b2c7f4759286
(GLAS-WP-0015), which asks for the exact store tenant, JWT tenant, CheckRequest
tenant, any permitted mapping and wrong-tenant denial evidence.
Published `docs/tenant-claim-contract.md`. The KeyCape-owned JWT tenant for all
four reviewed service registrations is `tenant:coulomb`, bound at registration
and required by config validation. Approval store `platform` and policy
`tenant:platform` are owned by approval-engine and flex-auth; KeyCape performs no
normalization or aliasing, so exact comparison does not match today. No mapping
was invented and no live registration or policy subject was changed — the two
admissible resolutions are recorded for the owning parties to decide.
Added `src/internal/server/oidc/tenant_test.go`: request-supplied `tenant` and
`tenant_hint` cannot alter the claim; two registrations never carry each other's
tenant (the wrong-tenant denial basis); human tokens default to `tenant:coulomb`
rather than an empty claim. Local issuance proof only, not live-rollout evidence.
## Align approval registrations to tenant:platform
```task
id: KEY-WP-0013-T04
status: done
priority: high
state_hub_task_id: "d93e21cc-a422-5e78-981f-9c00f3161375"
```
Source: glas-harness inbox message f487c63a-dd7e-4ad8-9188-eec9fbea59c6,
operator decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6 ("Use tenant:platform for
the Glas approval dependency chain", verified resolved in the hub). This closed
resolution 2 of the two options recorded in KEY-WP-0013-T03.
Changed the `tenant` field on exactly `secrets-engine-approval` and
`approval-engine-operator` in `config/service-clients.example.yaml` and the two
matching entries in `docs/approval-engine-provisioning-request.yaml` to
`tenant:platform`, each annotated with the decision id. `codex-railiance-platform`,
`secrets-engine-openbao` and the human directory default keep `tenant:coulomb`.
No audience, scope, subject, role, lifetime or MFA change; no cross-tenant grant.
Evidence: `TestApprovalClientIssuesExactPlatformTenantAndRejectsAliases` (exact
`tenant:platform` with `aud=approval-engine` even when the caller requests
`platform`, `tenant:coulomb` or `TENANT:PLATFORM`),
`TestUnrelatedServiceClientKeepsCoulombTenant`, and
`TestServiceRegistrationTenantsAreExactPerDecision`, which pins every reviewed
client's tenant against the real fixture so a reintroduced alias fails the build.
Full `go test ./...` and `go vet ./...` pass. Choice only — live provisioning and
the other admission gates remain with KEY-WP-0013-T02.
2026-09-09 observation, read-only. Custody has been admitted since the
2026-09-08 record and the deployment now carries the pieces T02 was waiting for:
`sso/keycape-config` registers `secrets-engine-approval` and
`approval-engine-operator` alongside the existing clients; the secrets
`keycape-secrets-engine-approval-client` and
`keycape-approval-engine-operator-client` exist in the namespace; both
`KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET` and
`KEYCAPE_APPROVAL_ENGINE_OPERATOR_CLIENT_SECRET` are wired into the pod; and the
image is pinned by digest to
`sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611`, the
candidate T06 published.
What that does and does not establish. The live issuer passes every conformance
check that needs no credential — it advertises itself, offers the profile
authorization surface, publishes usable RS256 keys and advertises no excluded
grant (`src/tests/conformance`, run against `https://kc.coulomb.social`). It does
**not** establish the claim contract: subject, tenant, roles, scopes, lifetime
and excess-scope denial per client are exactly what `keycape verify-client`
exists to prove, and that needs the client secret in the environment.
Deliberately not done here. Reading either secret to run the verifier is
client-side retrieval, which the provisioning packet records as **not admitted**
(`custody_return.scope`: verifier-side copies only). So the remaining proof is an
attended operator action, not something to take by reading the secret. T02 stays
`wait` on that run, not on custody.
**Correction, same day.** The refusal above was right; the conclusion drawn from
it was not. The attended run had already happened, on 2026-09-09T00:10Z, and its
receipt is committed in this repository at
`docs/evidence/2026-09-09-keycape-verifier-admission.json`. Per client — both
`secrets-engine-approval` and `approval-engine-operator` —
`live_jwks_signature_verified: true`, `exact_claims_verified: true`,
`excess_scope_denied: true`, `wrong_secret_denied: true`,
`lifetime_seconds: 900`, `maximum_future_iat_seconds: 30`,
`pinned_artifact_verifier_passed: true`, `verifier_location: "attended owner
process"`. `credential_values_emitted: false` and
`client_side_read_admitted: false` throughout, so it was run the admitted way.
The claim contract is established and T02's `status: done` is correct; it does
not revert to `wait`.
The reasoning error is worth naming because it is the third of its kind here in
two days: "not admitted **for me**" was read as "not done **by anyone**", without
checking `docs/evidence/`. The preceding paragraph's read-only observations are
sound and its restraint about not reading the secret is exactly right — only the
final sentence was wrong, and it is retained above rather than deleted so the
correction has something to point at.
What the receipt itself declines to claim, which is the honest residual and
matches this task's earlier record: `real_predecessor_rotation_tested: false`,
`observed_wall_clock_expiry: false`, and under `limits`,
`actual_predecessor_rotation_observed: false` and
`wall_clock_jwt_expiry_observed: false`. Natural expiry and a real predecessor
rotation were not exercised by initial provisioning and are not claimed by
anyone. Predecessor rejection is implemented and unit-tested in
`keycape verify-client`; it has not been run against a real rotation, which is
KEY-WP-0014-T04's territory and still has no admitted execution authority.
**Independently confirmed by the custody owner, 2026-09-09** (railiance-platform
`ae6ff4bc`, `570e6746`). They reached the same reading from their own side and
add one datum this record did not carry: `human_client_consume_denied: true` for
both clients, alongside the signature, exact-claim, lifetime, excess-scope and
wrong-secret results. They confirm the verifier ran twice — once during
activation and again after rollout — at deployment generation 38, single ready
replica, signing key unchanged, no credential values emitted, and state that T02
can be closed against that receipt rather than against a run KeyCape cannot make.
They also correct one thing in our framing, and they are right: the packet says
*client-side retrieval* is unadmitted, which remains true, but the attended
operator path is not a client-side read and never required one. The two are
distinct and this task had been treating them as the same constraint.
**Do not cite this receipt as rotation evidence.** Both owners now state the same
two gaps independently: `real_predecessor_rotation_tested: false` and
`observed_wall_clock_expiry: false`. Neither is a T02 acceptance criterion, and
neither is closed by anything recorded here — see KEY-WP-0014-T04.
**Client-side retrieval is prepared but still unadmitted.** CCR-2026-0019
(secrets-engine reader) and CCR-2026-0020 (approval-engine-operator reader) are
`in_flight` under RPF-WP-0035-T06. 0020 has no named presenting actor in any
owner source, and railiance-platform left it undetermined rather than guessing —
correctly. KeyCape's view, asked for as the issuer of the identity and recorded
in `docs/approval-engine-provisioning-request.yaml`, is that the registration as
issued carries both `approval:create` and `approval:approve`, so a single
presenter can create and then approve the same entry. That is a
separation-of-duties question about who may hold the credential, not a defect in
the token: KeyCape issues the grants approval-engine requested. The decision is
approval-engine's and the doctrine question is gate-house's.
**Answered 2026-09-09, and the answer withdrew the client.** approval-engine
checked and the presenter is nobody (`056aef0d`). Nothing in their repository
obtains an OAuth token — the engine only verifies them, there is no
client-credentials grant in the package, and the one outbound credential is a
mounted audit-token, not an OAuth client. Scope by scope the bundle never
described a single actor: `approval:approve` now belongs to the informed-decision
human client, `approval:emit` is redundant because the running server emits its
own heartbeat with no token, and no requester identity was ever settled for
`approval:create`. It was a convenience bundle written when a lifecycle operator
was assumed to exist.
They asked railiance-platform to cancel the client-side reader CCR-2026-0020 as
an owner decision. Verifier custody CCR-2026-0018 stands, so the client remains
authenticable if a presenter appears, and they asked for it to be kept on that
basis rather than removed. Annotated as withdrawn in
`config/service-clients.example.yaml` and the provisioning packet; not deleted.
The separation-of-duties observation above is retained because it constrains the
re-request: when a presenter is named the registration comes back **narrowed**,
and `approval:approve` must not travel with the operational scopes.
**One thing "do not provision" cannot undo, raised rather than filed.** This
client is already live — provisioned in the 2026-09-09 attended rollout and
verified in `docs/evidence/2026-09-09-keycape-verifier-admission.json`. So the
estate now holds a confidential credential that can create *and* approve
approvals, that nobody presents, and that no consumer is waiting on. That is
standing capability with no counterparty, which is a worse resting state than
either provisioning it for a named presenter or disabling it. KeyCape owns client
disablement, so the action is ours to take and not ours to decide alone; raised
with approval-engine and railiance-platform rather than exercised, since removing
a live registration is a deployment change. Recorded here so it is not lost:
**an unpresented create+approve credential should not be left standing
indefinitely.**
## Assign and register the human approver browser client
```task
id: KEY-WP-0013-T05
status: done
priority: high
assignee: the-custodian
state_hub_task_id: "9a782909-91db-59fa-aae7-83766f4fbb0d"
```
Own the residual human registration separately from service-client T02. Reuse
an existing accepted UI if available; do not invent a callback on approval-engine.
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.
**Tenant blocker resolved in code, 2026-09-09.** On review the choice did not
have to be made to unblock: the two resolutions differ in where a human's tenant
*comes from*, and an implementation exists that is correct under either. A client
registration may now declare a `tenant`, and `humanTenant()` resolves it by four
rules — no declaration keeps the directory answer unchanged; a declared zone
applies when the directory has placed the user nowhere; agreement passes; and a
declared zone that **conflicts** with a directory assignment refuses issuance
(`403`, `error_type: tenant_binding`) rather than relabelling the user.
That refusal is the whole design. A registration can bind a zone for unplaced
users and can never move a placed one, so option (2) is available now without
writing a general cross-tenant override into the issuer, and if the owners prefer
option (1) the same code stops supplying the zone and starts enforcing agreement
with it — no second migration and no window where a stale registration silently
wins. It is safe only because registrations are static and deployment-owned;
`docs/tenant-claim-contract.md` records that this rule must be revisited if
dynamic client registration is ever admitted.
Evidence: `src/internal/server/oidc/human_tenant_test.go`, four cases including
the relabel refusal. Verified with teeth — neutering the conflict check makes
`TestHumanTenantRefusesToRelabelAPlacedUser` fail rather than pass silently.
`docs/tenant-claim-contract.md` carries the resolution table,
`docs/approval-engine-auth-contract.md` states the requirement on the approver
client, and the provisioning packet now declares `tenant: tenant:platform`.
**Cleared and registered 2026-09-10.** informed-decision submitted the two
strings with the origin already live:
client_id informed-decision-approver
redirect_uri https://decisions.coulomb.social/auth/callback
The host is `decisions.coulomb.social`, not the `decide.coulomb.social` of an
earlier draft. They verified the origin rather than reporting it: `/` and
`/auth/callback` both return 200 from 92.205.62.239 on a Let's Encrypt
certificate CN=decisions.coulomb.social valid 2026-09-10 to 2026-12-09. The path
serves a placeholder while their surface is gated on APPROVAL-WP-0002-T01, which
does not affect the registration — the redirect is matched as an exact string at
`/authorize` and never fetched.
Published in `config/service-clients.example.yaml`: public `authorization_code`
with S256 PKCE, `audience: approval-engine`, scopes
`[openid, approval:read, approval:approve]`, `mfaRequired: true`, declared
`tenant: tenant:platform`. No `secretRef` — PKCE is the whole proof — and no
`serviceSubject` or `roles`, which validation rejects on a browser client.
`TestApproverRegistrationShapeIsExact` pins every field, so widening a scope or
relaxing MFA fails the build rather than reading as an edit, and asserts the
registration passes startup validation — which proves the KEY-WP-0028 tenant
exemption holds for the registration that depends on it, not only in a synthetic
case.
Two existing guards fired on the way in, both correctly, and neither was loosened:
- `TestServiceRegistrationTenantsAreExactPerDecision` refused an unreviewed client
carrying a tenant, which is exactly its purpose. The approver was added to its
reviewed set as a deliberate act.
- `TestServiceRegistrationAudienceAndScopeIsolation` panicked on
`secretRef[4:]`, an assumption that was safe while the fixture held only
confidential clients. The approver is the first public one; the loop now guards
on the `env:` prefix.
The tenant is declared, and the token says so: `tenant_source` is `registration`,
never `directory`. Per gate-house's correction that is adequate for
approval-engine's store-isolation gate and **never** for doctrine turning on this
person's membership of the zone.
Not live: this is a published registration fragment. Deployment is owner-side and
nothing has been issued to a real approver.
## Make negative rollout evidence discriminate actual issuer refusal
```task
id: KEY-WP-0013-T06
status: done
priority: high
assignee: the-custodian
state_hub_task_id: "65132e1d-10db-5915-a664-83beaf30365a"
```
Critical-path review found `mustFail` accepted any Exchange error: timeout,
HTTP 5xx, invalid token or JWKS failure could falsely prove excess-scope denial
or predecessor rejection after a successful positive exchange. Preserve a bounded
typed provider refusal, require the exact token endpoint/status/error/feature,
and prove the checker rejects unrelated failures without exposing provider bodies.
Publish the verified image candidate and return its immutable digest to T02.
Platform's 2026-09-08 return is RPF-WP-0035-T05 and proposed CCR-2026-0017/0018.
The custody field is `CLIENT_SECRET`; Kubernetes key `client-secret` and both env
names remain as requested. These records cover verifier-side delivery only.
Owner approval, client-side retrieval and the actual upstream ID-token issuer
proof remain distinct gates. Public discovery currently advertises
`https://auth.coulomb.social`; that alone is not the signed-token observation.
T06 completion: full Go suite and vet passed; published code `dcebd46` and pulled the image by digest `sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611`. Container `keycape verify-client --help` confirms the command is present. `docs/approval-clients-rollout.md` and its proposed deployment patch record configuration/issuer, first-provision, single-instance rollout, acceptance and rollback. This closes verifier preparation only; T02 and T05 retain the explicit live dependencies.
## Prepare a bounded upstream issuer observation before client rollout
```task
id: KEY-WP-0013-T07
status: done
priority: high
assignee: the-custodian
state_hub_task_id: "62bc6d8e-dbb2-57b6-a30b-472ffd06b497"
```
2026-09-08: T02's actual upstream signed-token issuer gate had no executable
observation path. A downstream OpenBao/KeyCape login cannot return the upstream
Authelia token; it remains inside the adapter. Prepare `probe-upstream-issuer`
and an exact-state temporary route so one attended flow can be verified before
changing the normal KeyCape Deployment or its configuration.
Acceptance: signed issuer/audience/time/nonce proof without token or user-claim
output; browser/state binding and one-shot refusal; finite issuer allowlist;
ten-minute bound; no downstream credential; current Traefik route isolation;
immutable image; reviewed temporary Job/config projection, network policies and
owner-reference cleanup. Normal callbacks and the production issuer stay on
the existing Deployment. The probe mounts only `config.yaml`, not `key.pem`,
and has no Kubernetes API token. That file contains existing credential data;
its use by the temporary diagnostic needs deployment-owner admission.
Source tests, image and deployment packet close this preparation task. The
attended live receipt and issuer pin remain in T02, alongside the named CCR
reviews and custody/rollout acceptance. Preparing the diagnostic does not
approve CCR-2026-0017/0018 or close any live factory gate.
T07 preparation complete: source `6f33abd`, immutable image
`sha256:0c85ed377cae7ae6ca5b5c56b1a52930e706b3cb78009747e42f551e869a22e4`.
Complete Go race suite and vet passed; five renderer/collector checks, seven
real Traefik 3.6.7 route cases and Railiance server dry-run passed. The packaged
command ran as UID 65534 with no network and a read-only filesystem and produced
the expected bounded idle-timeout receipt. See `docs/upstream-issuer-proof.md`
and `docs/evidence/upstream-issuer-probe.json`. T02 retains the live proof and
configuration-owner admission; no production resource or custody value changed.
## Status 2026-09-30 (CUST-WP-0074)
All tasks T01-T07 are `done`; nothing in this plan is waiting, so it is
`finished` rather than `blocked`. Residual items live in other plans
(RPF-WP-0035-T05/T06, AUDIT-WP-0009-T09, KEY-WP-0014-T04). The one open
question recorded above, disabling the unpresented create+approve client, is a
KeyCape deployment decision to be tracked in a new plan if taken up.
**2026-10-10:** KeyCape proposed the joint disablement sequence that
railiance-platform asked for on 2026-09-15, to railiance-platform, approval-engine
and net-kingdom: remove the registration (attended), verify `invalid_client`,
unbind the env, retire the ESO delivery, then retire custody. The proposal executes
nothing. Each owner confirms their step and the founder schedules the attended window.
**2026-10-10, amended by the owners.** net-kingdom: step 1 (`keycape-client.py
apply --operation remove`, case acceptance CCR-2026-0018) only patches
`sso/keycape-config` and does not restart. The step-3 env removal triggers the one
rollout, and the new pod starts with neither the client nor the env. Step 2's
`invalid_client` check follows that rollout. Rollback runs in reverse: env first,
then the client. railiance-platform (RPF-WP-0035-T10): step 4 also retires the
ClusterSecretStore, the Kubernetes-auth role and the reader policies, through an
attended prune sync run strictly after step 3. Step 5 is a metadata delete, not an
archive, run only after the step-2 receipt. Steps 1-5 can share one attended
window. Still open: approval-engine's reconfirmation that there is no presenter,
and the founder scheduling the window. Before that rollout, run
`keycape check-config` on the patched configuration.

View file

@ -0,0 +1,362 @@
---
id: KEY-WP-0014
type: workplan
title: "Review native login and client credential lane handoffs"
domain: infotech
repo: key-cape
status: finished
owner: codex
topic_slug: native-credential-lane-handoff
created: "2026-09-05"
updated: "2026-09-10"
state_hub_workstream_id: "0d003df3-f7d3-5063-8ca0-e1e33f7df74a"
---
Source: ops-warden inbox message 0dd9c7bd-0ecd-42d1-806f-7cc4ba9730ed.
The native service exchange and public PKCE login commands are now implemented.
Cross-owner rotation and consumer-specific route cutover remain outstanding.
## Design owner command and custody boundaries
```task
id: KEY-WP-0014-T01
status: done
priority: medium
state_hub_task_id: "0c0a0b61-c19e-5631-9cda-8b2dc0f47d8f"
```
Review ops-warden's existing key-cape-oidc-login proxy and
rapp-qonto-keycape-client route contracts. Specify the native interactive login
and bounded exchange commands, token delivery, renewal and custody-mediated
rotation before implementation. Keep secret custody with OpenBao and avoid
retiring the proxy until replacement commands have equivalent verification.
## Verify handoff delivery evidence
```task
id: KEY-WP-0014-T02
status: done
priority: low
state_hub_task_id: "d9a5de97-b7d5-5599-98c2-eaab32f51495"
```
Ops-warden reports KEY-WP-0009-T04's claimed reply did not arrive. Verify prior
receipts for all four named recipients before claiming successful notification.
No outbound coordination messages were sent during the 2026-09-05 repo review.
## Implement and verify native caller commands
```task
id: KEY-WP-0014-T03
status: done
priority: high
state_hub_task_id: "4e46f474-a15e-594f-ae07-ad37a9667d89"
```
Implemented keycape service-token and login with HTTPS discovery, RS256/JWKS
verification, exact audience bindings, PKCE/state/nonce, private out-of-Git file
delivery and redacted errors. Service Basic authentication now form-decodes
credentials. Integration tests exercise the real token/JWKS handlers and browser
callback, including forged state, tampering, excess scope and unsafe output.
See docs/native-authentication.md for invocation and ownership boundaries.
2026-09-10. The authority question is answered and the blocker is narrowed to a
schedulable act. railiance-platform named the executor (platform operator,
attended founder session; explicitly not ops-warden, not secrets-engine
autonomously, and no unattended agent), the transport (the governed
`openbao-platform-admin-login` lane via `warden access ... --exec`), and the
authority (founder_required attended OIDC, role=platform-admin). What is missing
is a rotation CCR, which they will prepare *if KeyCape asks* — a two-custodian
CAS rotation with a service restart being a distinct version-guarded operation
rather than an implementation detail of an existing lane.
**Not asked, deliberately.** Requesting a production credential rotation with a
restart window is the operator's decision, not this repository's, so it was put
to him rather than answered here (ops-warden msg 10fcc8af).
Established before recommending anything either way: **this rotation has no
incident driver.** KEY-WP-0011 recovered a real exposure on 2026-08-23, and the
coordinated rotation that followed replaced the RS256 signing key, the LLDAP bind
credential, the Authelia client credential and the privacyIDEA admin token. The
`rapp-qonto` client secret was not in that payload — it is an `env:` secretRef
resolved from a separate Kubernetes Secret, never inline in `config.yaml`, which
config validation enforces. So this is lifecycle hygiene, not remediation, and
nobody should be carrying it as leftover incident work. Hygiene can wait for a
chosen window; remediation could not.
Also recorded: `warden route`/`warden plan` previously returned a generic match
that read as authorization, and offered read transports against a write need.
This repository declined to act on it; railiance-platform independently called it
a defect; fixed under WARDEN-WP-0038.
## Admit rotation and verify consumer handoff
```task
id: KEY-WP-0014-T04
status: done
priority: high
state_hub_task_id: "e7632c71-9676-5837-a925-7f905e3015c5"
```
Qonto rotation requires admitted custody/provider transaction and rollback
semantics; see the exact review sequence in docs/native-authentication.md.
The current login proxy yields an OpenBao token, so it cannot be replaced by a
JWT-writing command without adapting its consumer contract. Verify that split
with the platform owner and ops-warden before route changes. No route was changed.
2026-09-05 receipt audit: GET /messages/?from_agent=key-cape returned one
2026-03 image-publication message and no KEY-WP-0009 handoff receipts for
NetKingdom, secrets-engine, ops-warden or railiance-platform. Corrected the
unsupported statement in KEY-WP-0009-T04. This proves only that no receipts
are present in the current Hub response, not that no message ever existed.
The 2026-09-05 audit's finding stands as recorded; the replacement outbound
messages were sent on 2026-09-08 (below).
2026-09-08: `0a6c9d88-cf50-45d8-acdc-99a40747683e` -> ops-warden, readable back
via `GET /messages/?from_agent=key-cape`. It puts both open questions to the
route and custody owner in one place:
1. The `key-cape-oidc-login` proxy yields an OpenBao token and `keycape login`
yields an issuer JWT — different credential types, different verifiers,
different enforcement owners, so not interchangeable. Two admissible options
are offered: keep the proxy and run `keycape login` alongside it for JWT
consumers, or adapt the route's consumer contract to a JWT and then cut over.
Option (a) is recommended until every consumer of the route is enumerated.
2. Qonto rotation for `rapp-qonto-keycape-client`, quoting the five-step reviewed
sequence from `docs/native-authentication.md` and asking who executes the
successor generation and CAS update, in which transport, under what authority.
It records that `warden route`/`warden plan` returned no exact admitted lane
and that a generic match is not being treated as authorization.
The KEY-WP-0009 handoff ops-warden reported missing has also now been delivered
(`5bc58847-eed5-4164-bb0f-a0d8455e75b8`), closing the KEY-WP-0014-T02 correction.
Task remains `wait` — no route was changed and no rotation command ships until
ops-warden answers (1) and an execution/rollback authority is admitted for (2).
2026-09-08, second pass — read ops-warden's own catalog rather than waiting for a
reply, and it changes the shape of both questions.
`registry/routing/catalog.yaml`, lane `key-cape-oidc-login`: `intended_owner` was
already corrected to key-cape, and `verified: asked-and-waiting` since
2026-08-28. **ops-warden is waiting on us, not the other way round.** The lane is
also `warden_executes: false` with a `wiki_ref` into `CredentialRouting.md` — a
pointer, per their ADR-0001, not a code path with programmatic consumers. So the
consumer-contract risk behind question (1) is smaller than assumed: adding
`keycape login` alongside retires nothing, and the acceptance we owe them is a
statement of ownership, not a cutover.
Lane `rapp-qonto-keycape-client`: `rotation.owner: key-cape`,
`automatable: true`, and `blocked_on` reads "still no key-cape-native
exchange/rotation command", `verified: source-read` 2026-08-28. **That blocker is
stale.** `keycape service-token` (T03) shipped 2026-09-05 and is exactly the
native `client_secret_basic` exchange they record as absent. It does not clear
the lane, and should be narrowed rather than closed.
Shipped here: `keycape verify-client`, which is rotation step 4 and the denial
checks step 5 rests on — new exchange verified against deployed JWKS, predecessor
refused, excess scope refused, no value printed or written. See
`docs/native-authentication.md`, "Verifying a live registration". The identical
predecessor case is treated as a failure, because an unchanged secret is a
rotation that did not happen.
Steps 1-3 remain custody's and are deliberately not automated. Task stays `wait`
on exactly two answers: which option ops-warden takes for the login proxy, and
who executes the successor generation and CAS update under what authority. No
route changed.
Both catalog corrections were sent, receipts readable via
`GET /messages/?from_agent=key-cape`:
- `12f1bdfa-b240-414b-aa49-128a4d2acf20` — accepts ownership of lane
`key-cape-oidc-login`, answering the WARDEN-WP-0033-T04 follow-up they have
carried as `asked-and-waiting` since 2026-08-28. States the boundary the lane's
`fetch_command` crosses: key-cape owns browser authentication and the identity
token; the `netkingdom` auth mount, `role=<domain>` mapping, token store and
enforcement of the resulting OpenBao token are not ours. Supplies the source
paths they need to move the lane to `verified: source-read`, and records our
revised, lower estimate of the consumer risk — a pointer lane under ADR-0001
retires nothing when `keycape login` runs alongside it — while asking them to
correct us if the catalog understates its consumers.
- `08d42f47-8e19-4976-98f5-dd259e7b1225` — reports lane
`rapp-qonto-keycape-client`'s blocker as stale against its 2026-08-28
source-read, since `keycape service-token` is the native `client_secret_basic`
exchange it records as absent and `keycape verify-client` is its rotation step 3
verbatim. Asks them to **narrow** it to steps 1-2 rather than clear it, with
proposed replacement text, and notes `rotation.automatable: true` is now
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.
2026-09-09, guarding against a misreading that is now easy to make. The approval
clients went live and `docs/evidence/2026-09-09-keycape-verifier-admission.json`
records a passing verifier run, so there is now a committed receipt in this repo
that looks like rotation evidence and is not. It records
`real_predecessor_rotation_tested: false` and `observed_wall_clock_expiry: false`,
and under `limits`, `actual_predecessor_rotation_observed: false` and
`wall_clock_jwt_expiry_observed: false`. railiance-platform stated the same two
gaps unprompted from their side (`ae6ff4bc`): "do not cite this receipt as
rotation evidence."
So `keycape verify-client`'s predecessor rejection has been exercised only
against unit tests and a first provisioning where predecessor and successor were
never distinct. Rotation step 4 is implemented and unproven, which is a different
state from either "missing" or "done", and T04 is where that distinction lives.
**2026-09-10: the authority is answered. What remains is an artifact and a
decision.** railiance-platform answered the routed question (`7c7228ac`, relayed
by ops-warden `b14655b3`):
- **Who:** the platform operator, attended, as a founder-attended session. Not
ops-warden, not secrets-engine autonomously, and no unattended agent —
railiance-platform named themselves in that exclusion too.
- **Transport:** the governed `openbao-platform-admin-login` lane, invoked only
through `warden access openbao-platform-admin-login --exec -- <command>` with a
unique metadata receipt path. The owner command is never run directly.
- **Authority:** `founder_required` attended OIDC via netkingdom
`role=platform-admin`. Nothing else in that repo carries write authority
against `platform/workloads/`.
**Missing: a rotation CCR.** They will not raise one speculatively, and their
reason is sound — a two-custodian CAS rotation with a service restart is a
distinct version-guarded operation, not an implementation detail of an existing
lane. It must name the CAS precondition and expected version on *both*
custodians, sibling-field preservation on the KV write, the restart window,
bounded predecessor retention for rollback and denial checks, and the
reconcile-to-same-version step on failure. That last is step 5 of the sequence in
`docs/native-authentication.md`, so the reviewed sequence is what the CCR encodes.
**Blocked on a decision that is not KeyCape's to take unilaterally.** Their offer:
"if key-cape wants the rotation scheduled, say so and I will prepare it under
RPF-WP-0035 with both custodians in one window." ops-warden deliberately did not
ask on our behalf — correctly, per their ADR-0003, since implying an owner request
from a routing errand is quiet ownership. So T04 now waits on the repository owner
deciding whether the Qonto rotation should happen, and when. Nothing indicates
compromise; this is hygiene plus the first real exercise of rotation step 4, which
is why it is a judgement call rather than an obvious yes.
**Precedent offered, and it is the right one.** The identical two-custodian shape
was exercised 2026-09-09 for the approval clients: attended founder session,
OpenBao KV custody plus a Kubernetes-delivered consumer copy, compatible pinned
rollout, protected recovery retained, and rollback that restored configuration and
image and detached delivery before a clean resume. Their
`scripts/keycape_approval_custody.py` (activate / resume-activate / verify) is the
shape a rapp-qonto rotation should follow rather than a bare `bao kv patch` — for
exactly the provider/consumer consistency reason this task gave when it declined
to ship a wrapper around a raw KV write. They endorsed that refusal unprompted.
**And the `warden plan` defect is fixed** (WARDEN-WP-0038, 2026-09-10). The Qonto
need now returns `founder_required` naming the write owner, and read transports
are no longer offered against a write need. ops-warden's closing line is worth
keeping: "Your caution was load-bearing and it should not have had to be."
If the rotation is scheduled, it is also the first opportunity to close the
`real_predecessor_rotation_tested: false` gap recorded above — `verify-client`
with `-previous-secret-env` is exactly step 4, and the predecessor would finally
be genuinely distinct.
**Decision, 2026-09-10: not yet.** The repository owner declined to schedule the
rotation now. Recorded with the reasoning so it is revisited on evidence rather
than re-litigated from scratch: nothing indicates compromise, the offer stands
open, and the estate already has one founder-attended window pending for the two
fail-closed startup changes. Rotating for hygiene alone, in a second attended
session, against a credential with no incident attached, is work the owner chose
not to spend now.
This is a deferral, not a refusal, and the thing that should reopen it is a
change in evidence rather than the passage of time: an actual exposure or
suspected one (see KEY-WP-0011), the Qonto secret's age becoming a stated concern,
a consumer requiring proof of rotation, or a decision to prove rotation step 4
before relying on it elsewhere. Any of those makes the answer different; a
calendar date does not.
**Closed 2026-09-10.** The paragraph above left T04 `wait` with nothing
outstanding, which is a contradiction: `wait` means waiting or blocked, and this
was neither. Re-reading the task against its own title settles it — *admit
rotation and verify consumer handoff* — and both halves are delivered:
- **Rotation admitted.** The semantics were reviewed and published
(`docs/native-authentication.md`), the executor and authority are named
(platform operator, attended founder session, governed
`openbao-platform-admin-login` lane, `founder_required` attended OIDC via
`role=platform-admin`), the transport is admitted, and the CCR will be prepared
on request. Step 4 shipped here as `keycape verify-client`, treating an
identical predecessor as a failure because an unchanged secret is a rotation
that did not happen. Steps 1–3 are custody's and are deliberately not
automated by this repository.
- **Consumer handoff verified.** ops-warden took option (a) for the reason we
gave rather than out of caution — the proxy yields an OpenBao token and
`keycape login` an issuer JWT, so they were never one thing to cut over
between. Lane `key-cape-oidc-login` is `owner-confirmed` with our boundary
written in verbatim, and `rapp-qonto-keycape-client` was narrowed to steps 1–2
rather than cleared. No route was changed.
*Executing* a rotation was never this task's deliverable, and treating the
owner's deferral as an open item would have kept a task open against work it was
never scoped to do. The deferral stands on its own above, revisited on evidence.
Two residuals are recorded rather than carried here, because they belong to
whoever next opens a window: `real_predecessor_rotation_tested: false` and
`observed_wall_clock_expiry: false`. `verify-client -previous-secret-env` is
ready to close the first the moment a genuinely distinct predecessor exists.
Also standing, from ops-warden and not to be lost: until WARDEN-WP-0038 lands,
**treat a `warden plan` verdict on any need containing a write, rotate or
provision act as unreliable** — it inherits the verdict of the lane that reads
the same path, and `autonomous` is documented as the signal to proceed without
the founder. That applies to this repository whenever it plans a rotation.

View file

@ -0,0 +1,29 @@
---
id: KEY-WP-0015
type: workplan
title: "Reconcile repository scope with implementation and intent"
domain: infotech
repo: key-cape
status: finished
owner: codex
topic_slug: scope-intent-assessment
created: "2026-09-05"
updated: "2026-09-05"
state_hub_workstream_id: "14a7312b-ec92-5640-8b26-cbaa4753c18e"
---
## Inventory implementation and document scope gaps
```task
id: KEY-WP-0015-T01
status: done
priority: medium
state_hub_task_id: "99be1489-d9e5-53b1-9c82-75dc961131f7"
```
Reviewed source revision b989de4, INTENT, SCOPE, runtime composition, authentication
commands, adapters, migration/validator code, tests, packaging and open workplans.
Updated SCOPE.md to describe implemented surfaces and their actual limits.
Recorded evidence, ten prioritized gaps and closure criteria in
history/2026-09-05-011726-scope-intent-assessment.md. INTENT and implementation are
unchanged. Findings are assessment results, not implementation completion claims.

View file

@ -0,0 +1,123 @@
---
id: KEY-WP-0016
type: workplan
title: "Authorization-code protocol hardening and token verification bindings"
domain: infotech
repo: key-cape
status: finished
owner: claude
topic_slug: authorization-code-protocol-hardening
created: "2026-09-06"
updated: "2026-09-06"
state_hub_workstream_id: "22698171-f283-59a9-9443-183faaeb6287"
---
Closes gap G01 of `history/2026-09-05-011726-scope-intent-assessment.md`, the
assessment's first recommended item. The browser grant validates PKCE, client id
and scopes but does not bind the redirect URI, authenticate confidential clients,
enforce grant-type eligibility, or consume the authorization code atomically.
UserInfo verification is weaker than the caller CLI's contract.
Scope is the local protocol surface only. Upstream provider-token verification
(the Authelia adapter's deliberate no-signature-check trust assumption) is a
separate trust-contract decision and is not addressed here.
## Consume authorization codes atomically
```task
id: KEY-WP-0016-T01
status: done
priority: high
state_hub_task_id: "a5171df0-4905-518d-a52f-7152b980af79"
```
`SessionStore.Get` reads a code and `Delete` removes it in separate lock
acquisitions, with signing in between, so two simultaneous exchanges can both
observe the same session before either deletes it. Replace the token path's
lookup with a single-operation consume that removes the session under the same
lock that reads it, so exactly one concurrent exchange can succeed. A failed
exchange must not leave a replayable code. Cover with a concurrent-reuse test
that asserts exactly one success across parallel requests.
Added `SessionStore.Consume`, which reads and deletes under one lock, and moved
the token path onto it. Verified the defect was real before claiming the fix:
with `Get`/`Delete` restored, 9 of 16 concurrent exchanges succeeded and a
PKCE-failed exchange left the code replayable. Both now fail closed —
`TestConcurrentCodeExchangeSucceedsExactlyOnce`, `TestFailedExchangeConsumesTheCode`.
## Bind redirect URI, grant type and confidential client authentication
```task
id: KEY-WP-0016-T02
status: done
priority: high
state_hub_task_id: "1d0fb108-273f-5c9a-a7c7-7640f19bad9d"
```
On the authorization-code path: require `redirect_uri` and compare it for exact
equality with the value stored at authorization time; reject a client whose
registration does not permit `authorization_code` (an empty `grantTypes` stays an
implicit authorization-code client, matching config validation, but a
`client_credentials`-only client must be refused); and authenticate confidential
clients with a constant-time secret comparison, accepting the same
`client_secret_basic` and form-encoded credentials as the service grant. Public
clients continue to present no secret and must not be able to supply one to
impersonate a confidential registration. Negative tests per condition.
Implemented all three in `token.go`, with `basicClientCredentials` and
`secretsEqual` shared with the service grant (digest-first comparison, so a
wrong-length secret is indistinguishable from a wrong value). Tests:
`TestCodeExchangeRejectsRedirectURIMismatchAndOmission` (omitted, different,
traversal-shaped and trailing-slash variants),
`TestCodeExchangeRejectsServiceOnlyClient`,
`TestCodeExchangeAllowsImplicitAuthorizationCodeClient`,
`TestConfidentialCodeClientRequiresItsSecret` and
`TestPublicClientSecretIsIgnoredAndEmptyConfidentialSecretIsRejected`. The
`keycape login` CLI already sent `redirect_uri`, so no caller change was needed;
the authclient test stub was recording no redirect and is now faithful to real
`/authorize`.
## Harden UserInfo token verification
```task
id: KEY-WP-0016-T03
status: done
priority: high
state_hub_task_id: "336db63a-e4fd-522d-86b6-0bf54a31af1c"
```
UserInfo verifies an RSA signature and expiry but ignores its own `Issuer` field
and does not check the JOSE header algorithm, the issuer claim, or the token
purpose, so an ID token or a foreign-issuer token of the right shape is accepted
where an access token is required. Enforce `alg=RS256` from the header, reject
`none` and any other algorithm, require the configured issuer, and reject tokens
that are not access tokens. Negative tests per condition.
`validateJWT` became `validateAccessToken`, checking the JOSE header before
trusting the signature, requiring the configured issuer (an empty configured
issuer is a misconfiguration, not a skipped check), and requiring the `scope`
claim the token endpoint sets only on access tokens. Purpose is decided
verification-side deliberately: adding a `token_use` claim would change the
issued-token contract that consumers pin exactly. Tests:
`TestUserinfoRejectsUnverifiedTokenShapes` (foreign issuer, missing issuer, ID
token presented as an access token) and `TestUserinfoRejectsNonRS256Algorithms`,
which restates the header algorithm over a genuine RS256 signature so it
isolates the header check.
## Reconcile scope and assessment records
```task
id: KEY-WP-0016-T04
status: done
priority: medium
state_hub_task_id: "90efa6ef-9495-5716-bee2-49d01c9ce05d"
```
Update `SCOPE.md`'s protocol-hardening limit and G01's closure status in the
assessment to state exactly which bindings are now enforced and which remain
open, naming the upstream-trust item as still outstanding. Do not claim complete
profile conformance: G01 closure covers these bindings, not the whole profile.
Updated `SCOPE.md`'s protocol-hardening limit and recorded a partial-closure
status under G01 in the assessment, naming the Authelia upstream-trust item as
still open. G01 is explicitly not fully closed.

View file

@ -0,0 +1,118 @@
---
id: KEY-WP-0017
type: workplan
title: "Reconcile the canonical model and discovery metadata with the runtime"
domain: infotech
repo: key-cape
status: finished
owner: claude
topic_slug: canonical-model-and-discovery-conformance
created: "2026-09-07"
updated: "2026-09-07"
state_hub_workstream_id: "0904caad-ad8b-573f-9cdb-b6da09262a18"
---
Closes gap G02 of `history/2026-09-05-011726-scope-intent-assessment.md`.
`spec/canonical-model.yaml` and `src/internal/domain/model.go` both claim to be
the source of truth for the client registration shape, and they disagree: the
spec restricts grants to `authorization_code`, requires redirect URIs for every
client, and omits the service-subject, tenant, audience, lifetime, role and
MFA/handoff fields the runtime actually reads. Discovery advertises a fixed basic
scope and claim list that omits core profile claims and every configured resource
scope.
The point of the work is the executable link, not the edit: reconciling the two
files once leaves them free to drift again the same afternoon.
## Reconcile the client entity with the runtime model
```task
id: KEY-WP-0017-T01
status: done
priority: high
state_hub_task_id: "996f6895-f36b-585e-844d-3e55d02eefda"
```
Bring the `Client` entity in `spec/canonical-model.yaml` in line with
`domain.Client`: add `audience`, `serviceSubject`, `tenant`, `roles`,
`mfaRequired`, `registrationUrl` and `enrollmentUrl`; extend the `grantTypes`
enum to include `client_credentials`; and make `redirectUris` required only for
clients that use the authorization-code grant, matching what config validation
already enforces. Record which fields are deliberately absent from the canonical
model rather than silently omitted — `clientSecret` and `tokenLifetime` are
runtime policy, not identity data, and both are `yaml:"-"` in the Go model.
Added all seven fields, extended the grant enum, and made `redirectUris`
optional at the schema level with the authorization-code condition stated in its
description. `tokenProfile` and `environments` were declared in the spec but read
by nothing; they are kept and marked `runtime: false` rather than deleted, since
removing them would discard intent the check can now hold honest.
## Add an executable conformance check
```task
id: KEY-WP-0017-T02
status: done
priority: high
state_hub_task_id: "77df5043-2b23-56cd-947e-317f7117ac18"
```
Add a test that compares the canonical model's `Client` field set against
`domain.Client`'s YAML tags by reflection and fails on divergence in either
direction: a Go field with no spec entry, or a spec field the runtime does not
read. Fields intentionally excluded must be named in one list the test reads, so
excluding a field is a deliberate, reviewable act rather than an omission. This
is the generation/conformance link the assessment asks for.
`src/internal/domain/conformance_test.go` checks `Client`, `User`, `Group` and
`Membership` by reflection over YAML tags. It earned its place on the first run
by finding drift outside the assessment's list: `User.tenant` was read by the
runtime and undeclared in the spec. `yaml:"-"` fields are excluded by
construction, which is what keeps `clientSecret` and `tokenLifetime` out without
a hand-maintained exception list.
## Reconcile discovery metadata with the profile surface
```task
id: KEY-WP-0017-T03
status: done
priority: medium
state_hub_task_id: "5eb36f9b-a8e0-541e-8f81-310db49b77a0"
```
`claims_supported` omits `tenant`, `principal_type`, `assurance`, `scope` and
`nonce`, which the token endpoint emits on every token, and `scopes_supported` is
a fixed list that omits every configured resource scope. Advertise the core
profile claims and derive the scope list from the registered clients. Keep the
distinction the assessment asks for: an omitted core claim is a contract defect,
while optional discovery metadata is not, so document which is which rather than
treating every omission alike.
`claims_supported` now lists the core profile claims (`tenant`, `principal_type`,
`assurance`, `scope`, `nonce`) alongside the scope-gated human claims and the
optional cached `tenant_roles`. `scopes_supported` is derived from the registered
clients — baseline OIDC scopes first, configured resource scopes sorted after, so
the document is stable regardless of map iteration order — with the baseline
still advertised when no clients are configured.
`TestDiscoveryAdvertisesEveryCoreProfileClaim` states the distinction the
assessment asked for: a missing core claim fails, optional metadata does not.
## State a single source of truth
```task
id: KEY-WP-0017-T04
status: done
priority: medium
state_hub_task_id: "63290e2a-bae1-51c3-9cb9-d49994d804b6"
```
Two files currently claim to be authoritative. State in both that the Go model is
the runtime authority and the canonical model is the reviewed contract the
conformance check holds it to, so the next reader knows which one to edit first.
Update `SCOPE.md` and G02's status in the assessment to say what is now enforced
and what remains — this closes the client-registration and discovery drift, not
schema enforcement in general (G06 is a separate gap).
Both files now state the split: the Go model is the runtime authority, the YAML
the reviewed contract, and the conformance test the link. SCOPE.md and G02's
status say what is enforced and that G06 remains open.

View file

@ -0,0 +1,104 @@
---
id: KEY-WP-0018
type: workplan
title: "Make the LLDAP export report its own completeness"
domain: infotech
repo: key-cape
status: finished
owner: claude
topic_slug: export-completeness-evidence
created: "2026-09-07"
updated: "2026-09-07"
state_hub_workstream_id: "82ff99a1-65cc-5e1f-93fc-5bc8911ce77a"
---
Closes gap G05 of `history/2026-09-05-011726-scope-intent-assessment.md`.
`src/internal/migration/lldapexport/exporter.go` discovers groups by walking each
user's memberships, so a group nobody belongs to is absent from the snapshot and
nothing says so. A `LookupGroups` failure is skipped by a `continue` under a
comment claiming it is recorded in the incompatibility report — it is not. The
run then emits `result: "success"` and the CLI reports a clean export. Group
order comes from map iteration, so two exports of an unchanged directory differ.
A second defect falls out of reading the adapter: `LDAPAdapter.LookupGroups`
never populates `Group.Members`, and the exporter builds every membership from
that field. Against a real LLDAP directory the `memberships` block is therefore
always empty, while the fixture-based tests pass because the mock fills it in.
The unit of work is the evidence, not the enumeration: an export that cannot
prove it saw the whole directory must say which one it is.
## Enumerate groups independently of membership
```task
id: KEY-WP-0018-T01
status: done
priority: medium
state_hub_task_id: "abb11f82-06e9-58ee-8be2-6d20491e9524"
```
Add an optional `domain.GroupLister` capability (`ListGroups`) and implement it
on `LDAPAdapter` with a direct group-subtree search that reads `member` and
`uniqueMember`, so `Group.Members` is populated from the directory rather than
left empty. Keep it optional rather than widening `UserRepository`: the OIDC
layer has no use for it, and every existing implementation and test double would
otherwise have to grow a method it never calls.
Added `domain.GroupLister` and `LDAPAdapter.ListGroups`, searching the group
subtree for `groupOfNames`/`groupOfUniqueNames` and reading `member` with
`uniqueMember` as the fallback attribute. Compile-time assertions on the adapter
now state both contracts it satisfies. The adapter tests assert the filter is
*not* a membership filter and that a member-less group survives the mapping,
which is the property the whole task exists for.
## Report or fail on incomplete reads
```task
id: KEY-WP-0018-T02
status: done
priority: medium
state_hub_task_id: "d08f62dd-42e8-5c22-ba50-15b53f7473fd"
```
Record on the result which enumeration actually ran — a complete directory
enumeration or the membership-derived fallback — and treat a failed group
enumeration as fatal rather than as a silent gap. In the fallback path, record
each skipped user in the incompatibility report instead of discarding the error,
and derive memberships from the discovered user/group pairs. Emit `partial`
telemetry, and have the CLI say which enumeration produced the snapshot, so a
degraded export is legible without reading the YAML.
`ExportResult` gained `groupEnumeration` — written unconditionally, so nobody has
to infer completeness from an absent field — and a `Complete()` predicate that
requires both a directory enumeration and an empty report. The fallback path
records its own incompleteness as a report entry before it starts, so the
snapshot says so even when every user lookup succeeds.
The fallback also had to stop deriving memberships from `Group.Members`: the
LLDAP adapter never populated that field, so against a real directory the
`memberships` block was always empty while the fixture-backed tests passed. It
now derives each membership from the user/group pair actually observed.
## Define ordering and test the failure modes
```task
id: KEY-WP-0018-T03
status: done
priority: medium
state_hub_task_id: "3cd47458-9469-5cd9-a0b1-f2894402e1f8"
```
Sort users, groups and memberships on stable keys so an unchanged directory
exports byte-identically. Cover the cases the assessment names and the current
suite does not: an empty group, a group enumeration failure, a per-user lookup
failure in the fallback path, and repeat-run determinism.
Sorted users and groups by ID and memberships by group then user. Four exporter
tests and two adapter tests cover the named cases; the determinism test runs the
export three times over deliberately unsorted input and compares the full
ordering, rather than asserting on a single sorted field.
Recorded in `SCOPE.md` and in G05's status what this does and does not establish:
completeness evidence for the user/group/membership surface, not for credential
migration, which stays under G03.

View file

@ -0,0 +1,155 @@
---
id: KEY-WP-0019
type: workplan
title: "Verify upstream Authelia ID tokens"
domain: infotech
repo: key-cape
status: finished
owner: claude
topic_slug: upstream-provider-token-verification
created: "2026-09-07"
updated: "2026-09-07"
state_hub_workstream_id: "87ea18ea-dc60-52ce-baf4-476b46aa06f9"
---
Closes the remaining half of gap G01 in
`history/2026-09-05-011726-scope-intent-assessment.md`. The Authelia adapter
decodes upstream ID-token claims without verifying the signature, issuer,
audience or expiry, justified in a comment by a server-to-server TLS boundary.
**Operator decision (2026-09-07, Bernd):** verify the token itself. The
KeyCape→Authelia hop is to be HTTPS as defence in depth, but KeyCape will not
monitor, check or gate on that — transport enforcement is friction without
protection value here, and a check that must be configured correctly to help is
itself a failure mode. Verification is chosen precisely because it does not
depend on the transport being what we believe it is. No HTTPS validation, opt-in
flag or transport telemetry is to be added.
## Extract a shared RS256/JWKS verifier
```task
id: KEY-WP-0019-T01
status: done
priority: high
state_hub_task_id: "68c1b099-96b4-5065-a4be-8592438a0ca9"
```
`internal/authclient` already contains a strict RS256 verifier — kid required,
no `crit`, RSA-only keys of at least 2048 bits with an odd exponent. The upstream
verification needs the same rules. Duplicating security-critical verification is
how two copies drift and one silently misses a fix, so extract the reusable half
into `internal/jose`: strict JWK-set parsing and signature verification returning
claims. Claim policy stays with each caller, whose audience, nonce and issuer
rules genuinely differ.
Added `internal/jose`: strict JWK-set parsing (RSA signing keys only, at least
2048 bits, odd exponent 3 or greater, no duplicate key ids) and RS256 signature
verification returning claims. All failures collapse to one `ErrVerification`
so a caller cannot leak which check failed back to whoever supplied the token.
`authclient` is not yet migrated onto it — see T05.
## Verify the Authelia ID token
```task
id: KEY-WP-0019-T02
status: done
priority: high
state_hub_task_id: "8105030b-c91f-5a40-a221-4a38c8531e1c"
```
Before trusting any claim from the upstream ID token: verify the RS256 signature
against Authelia's published keys, require the issuer Authelia advertises, require
KeyCape's own client ID in the audience, and reject expired or not-yet-valid
tokens. Fail closed — an unavailable or unparseable key set denies the login
rather than falling back to unverified claims. Resolve Authelia's metadata and
key set from the server-side token base URL so a split-horizon deployment, where
the advertised public URL is not reachable from KeyCape, still verifies; allow
explicit issuer and JWKS overrides for deployments where that inference is wrong.
Refresh the key set once on an unknown key id so provider key rotation does not
require a KeyCape restart. Replace the comment claiming the TLS boundary as
justification.
`idTokenVerifier` in the adapter resolves the issuer and JWKS path from the
provider's discovery document, rebasing the advertised `jwks_uri` path onto the
server-side token base URL so split-horizon deployments resolve, with
`issuer`/`jwksUrl` config overrides where that inference is wrong. Key sets are
cached and refetched once on an unknown key id. `parseIDTokenClaims` remains for
diagnostics but is documented as outside the authentication path.
## Prove the rejections
```task
id: KEY-WP-0019-T03
status: done
priority: high
state_hub_task_id: "163f64dd-aa87-5ce3-b3be-d134fa993eb6"
```
Negative tests per condition: forged signature, `alg: none` and other algorithms,
unknown key id, wrong issuer, missing and wrong audience, expired token, future
`iat`/`nbf`, malformed key set, and an unreachable JWKS endpoint. A positive case
must confirm a genuine token still authenticates, and a rotation case that a new
key id is picked up after one refresh.
Thirteen rejection cases in `verify_test.go` plus the algorithm and rotation
cases. Confirmed they catch the original defect rather than merely passing: with
the unverified parse restored, all fifteen fail. The existing adapter tests now
build genuinely signed tokens against a fixture provider, so they exercise the
real verification path instead of being routed around it.
## Reconcile the records
```task
id: KEY-WP-0019-T04
status: done
priority: medium
state_hub_task_id: "8a208092-7e85-5ad9-a8c4-0dddc66aaaee"
```
Update `SCOPE.md` and G01's status in the assessment to state that upstream
provider tokens are now verified independently of transport, and record the
operator decision and its reasoning so the absence of transport enforcement reads
as a choice rather than an oversight. G01 closes with this; complete profile
conformance is still not claimed.
SCOPE.md and G01's status record the verification and the operator decision,
including why no transport enforcement exists, so its absence reads as a choice.
## Consolidate the caller verifier
```task
id: KEY-WP-0019-T05
status: done
priority: medium
state_hub_task_id: "0dc909fe-1772-5816-9e11-dbe7664a4e1f"
```
Move `internal/authclient`'s inline verification onto `internal/jose` so one
implementation serves both paths. Kept separate from T01/T02 deliberately: the
caller verifier is a tested security path, and destabilising it in the same
change that introduces upstream verification would confuse the evidence for both.
`Client.Verify` now fetches the raw key set and delegates parsing and signature
checking to `internal/jose`, keeping its own claim policy — audience and nonce
bindings are the caller's and cannot live in a shared verifier. Its existing
tests pass unchanged, which is the point: the migration preserved behaviour.
One deliberate strictness increase: a key set containing any malformed RSA
signing key is now refused outright, where the previous code would have used a
good key alongside a bad one. KeyCape's own `/jwks` publishes a single key, so
this affects no current deployment.
Also added direct tests for `internal/jose` (17 cases). It is now the single
verifier behind both paths, so testing it only through its callers would leave
its edges — duplicate key ids, `crit`, even exponents, undersized moduli — to be
covered by accident.
Follow-on evidence for the same task: "existing tests pass unchanged" shows the
move preserved behaviour, not that the behaviour is checked, so the check was
made explicit. Disabling the RSA comparison in `jose.Verify` fails both callers'
suites, which establishes the shared verifier is load-bearing on each path.
Added two caller-side cases the existing tamper case cannot reach, since it
alters signature bytes for a key that is legitimately published: a structurally
valid token signed by an unpublished key under a published kid, which fails only
if key selection is bound to the key set, and an undersized modulus in the
published set, which must deny rather than fall through to the claims.

View file

@ -0,0 +1,107 @@
---
id: KEY-WP-0020
type: workplan
title: "Make the Keycloak transform preserve or name every policy field"
domain: infotech
repo: key-cape
status: finished
owner: claude
topic_slug: migration-contract-preservation
created: "2026-09-07"
updated: "2026-09-07"
state_hub_workstream_id: "803845de-98ad-5a5b-89ec-11bd9e0fafec"
---
Closes the first half of gap G03 of
`history/2026-09-05-011726-scope-intent-assessment.md` — semantic preservation.
The second half, proof against a running Keycloak, is G04 and stays open.
`keycape-to-keycloak` calls `Transform`, which passes no clients, so the realm it
writes has an empty `clients` array and nothing says the service-identity
contract was not migrated. `TransformWithClients` exists but no caller reaches
it. Where clients are supplied, `mapClient` always enables the standard flow
regardless of grant types, and drops `Audience`, `ServiceSubject`, `Tenant`,
`Roles`, `TokenLifetime`, `MFARequired`, `SecretRef` and the handoff URLs.
`mapUser` drops the canonical ID, tenant and roles. Realm roles and client scopes
are written as empty containers with a comment saying they "can be extended".
The failure mode is not the missing mapping — it is that a dropped field and an
inapplicable one look identical in the output. An operator diffing the realm JSON
against the KeyCape config has no way to tell what this tool decided not to carry
across. Preserve what Keycloak can express, and name the rest.
## Give the CLI the client registrations
```task
id: KEY-WP-0020-T01
status: done
priority: high
state_hub_task_id: "6f6bcfd7-16c5-5311-9568-ad9aa9c3b60a"
```
Add a `-clients` flag reading the KeyCape config's client registrations and pass
them through `TransformWithClients`. When no client file is given, say so on
stderr and in the report rather than emitting an empty `clients` array that reads
like a realm with no clients.
Added `-clients`, reading registrations through a new `config.Registrations()`
that converts without resolving secrets — migration tooling needs the contract,
never the material, so `ClientSecret` is left empty by construction rather than
by remembering not to use it. An unparseable `tokenLifetime` is an error there,
since silently dropping a per-client lifetime is the defect class this closes.
Run against `config/dev-config.yaml`, all five registrations now reach the realm.
## Preserve what Keycloak can express
```task
id: KEY-WP-0020-T02
status: done
priority: high
state_hub_task_id: "88c0126c-fd73-5178-af1f-a018708ff8fb"
```
Derive `standardFlowEnabled` and `serviceAccountsEnabled` from the declared grant
types instead of hardcoding the standard flow on. Carry the audience, tenant,
service subject, roles and principal type as protocol mappers, per-client
lifetime as the Keycloak client attribute that expresses it, and the handoff URLs
and secret reference as attributes — the reference, never a secret value. Derive
realm roles and client scopes from the users and clients actually present rather
than emitting empty containers. Carry the user's canonical ID, tenant and roles.
Flows now follow the grants: the old mapping set `standardFlowEnabled` on every
client, so migrating a `client_credentials` service registration silently gave it
the browser flow. Audience, tenant, service subject and roles ride as protocol
mappers, since Keycloak has no native concept for the profile's claims and would
otherwise issue tokens the profile rejects. Lifetime, handoff URLs and the secret
*reference* are attributes; a test marshals the realm and fails if a resolved
secret value appears, because a realm import file is not a custody boundary.
Realm roles and client scopes are derived and sorted.
## Name everything not preserved
```task
id: KEY-WP-0020-T03
status: done
priority: high
state_hub_task_id: "5b8127b2-c01c-5d3c-afbf-adbb368941e6"
```
Report each field the transform cannot carry, at the point it is dropped, so the
output distinguishes a deliberate omission from a gap: passwords and MFA
credentials, MFA policy enforcement, and subject continuity — LLDAP's canonical
ID is a DN, and Keycloak will mint its own `sub`, so tokens after a migration
will not carry the same subject unless something downstream maps it. Fail the
realm-level validation when a supplied client's contract is not fully
represented. Test that each unpreserved field appears in the report.
`UnpreservedReport()` is separate from `ValidationReport()`. Folding them
together was the first attempt and it broke an existing test asserting a clean
export reports nothing — correctly so: consistency with the snapshot and
completeness of the migration are different questions, and one list cannot answer
both. Keeping them apart preserves the existing contract and makes the
distinction the point rather than a side effect.
Six tests cover the flows, the mappers and lifetime, the secret-value exclusion,
the derived roles and scopes, every named limit, and the separation of the two
reports. Mutation-checked: restoring the hardcoded `standardFlowEnabled` fails
the flow test. An incomplete transform now emits `partial` telemetry.

View file

@ -0,0 +1,101 @@
---
id: KEY-WP-0021
type: workplan
title: "Make snapshot attribute validation enforce a real rule"
domain: infotech
repo: key-cape
status: finished
owner: claude
topic_slug: snapshot-attribute-validation
created: "2026-09-07"
updated: "2026-09-07"
state_hub_workstream_id: "733ad1a5-c4d8-5c2f-8608-c51bf1d5b17e"
---
Closes gap G06 of `history/2026-09-05-011726-scope-intent-assessment.md`:
`checkNoUnknownAttributes` is named for a rule it does not implement. It rejects
blank keys, which is not an allow-list, so a snapshot carrying arbitrary or
malformed LDAP attribute names passes a check whose name says otherwise. A rule
that passes for the wrong reason is worse than an absent one, because a report
listing `no_unknown_attributes: passed` is read as evidence.
One part of the assessment's description needs correcting rather than
implementing: it says group membership validation checks nonempty member IDs
rather than complete referenced identity existence. `checkValidGroupMemberships`
does only check emptiness, but the semantic rule `checkReferencedUsersExist`
already resolves every member against the user set and fails on an unknown one.
The reference constraint exists; it lives in a different rule than the one the
assessment looked at.
## Enforce real attribute constraints
```task
id: KEY-WP-0021-T01
status: done
priority: medium
state_hub_task_id: "e2921983-853c-501b-8dfc-158751dea093"
```
`ldapAttributes` is defined by the canonical model as raw attributes *not covered
by* the model, so an allow-list of permitted names cannot be derived from the
schema — the field exists precisely to carry what the schema does not name. Two
constraints are derivable and worth enforcing:
- the attribute name must be a syntactically valid LDAP attribute descriptor
(RFC 4512: a letter followed by letters, digits or hyphens, or a numeric OID),
since a name that cannot be written to a directory cannot round-trip; and
- it must not shadow a canonical field's documented LDAP mapping (`uid`, `cn`,
`mail`), because two sources for one value is how an export and a directory
come to disagree, and the model already owns those.
Attribute descriptors are case-insensitive in LDAP, so keys differing only by
case are a collision, not two attributes. Keep the blank-key check. Rename the
rule to describe what it actually enforces rather than leaving a name that
overpromises.
Implemented as `raw_attributes_well_formed`, replacing `no_unknown_attributes`
in both `validator.go` and `spec/ldap-schema.yaml`, with the schema entry stating
plainly that this is not an allow-list and why one cannot be derived.
## Prove each rule with an invalid snapshot
```task
id: KEY-WP-0021-T02
status: done
priority: medium
state_hub_task_id: "de4caf0a-39c6-5148-a986-db18a1308505"
```
The assessment asks for invalid-snapshot cases proving each stated rule. Add one
per constraint — malformed descriptor, shadowed canonical mapping, case-only
duplicate, blank key — plus passing cases for legitimate raw attributes and a
valid numeric OID, so the rule is shown to be selective rather than merely
strict.
Eleven malformed-descriptor cases, six canonical-mapping cases across casings, a
case-only duplicate, a message-content case, and five accepted-attribute cases.
Confirmed they test the rule rather than merely passing: making the check return
early fails four of the five tests.
## Reconcile the records
```task
id: KEY-WP-0021-T03
status: done
priority: medium
state_hub_task_id: "8a48c6cc-1e7e-5fd4-abfa-2b893c3d400d"
```
Update `SCOPE.md` and G06's status. State what the rule now enforces and, just as
importantly, what it still does not: this is not schema-derived validation of
every attribute against a directory schema, and the canonical model cannot
provide one for a field defined as everything the model does not cover. Correct
the assessment's reference-constraint premise rather than restating it.
`history/…-scope-intent-assessment.md` was being edited by a concurrent session
when this workplan was written; make this edit only against a clean tree.
Done against a clean tree after the other session committed. G06's status records
both corrections: no allow-list is derivable for a field defined as what the
model does not cover, and the reference constraint was already implemented in
`checkReferencedUsersExist` rather than missing.

View file

@ -0,0 +1,136 @@
---
id: KEY-WP-0022
type: workplan
title: "Make the replacement harness runnable and target conformance externally"
domain: infotech
repo: key-cape
status: finished
owner: claude
topic_slug: replacement-harness-and-external-conformance
created: "2026-09-07"
updated: "2026-09-07"
state_hub_workstream_id: "94d74b17-1b2f-57dd-b6c3-50f10a7dccd9"
---
Closes the runnable half of gap G04 of
`history/2026-09-05-011726-scope-intent-assessment.md`. The Scenario B and C
scripts cannot execute: they reference compose files that do not exist, call
binaries at `src/bin/` where the Makefile builds to root `bin/`, pass `--base-dn`
to `lldap-to-ldap` whose flag is `--basedn`, and invoke a hardcoded workstation Go
path from outside the Go module.
The deeper problem is that repairing the shell would not prove anything. Both
scripts set `KEYCAPE_TEST_ISSUER`, but the profile suite builds its own `httptest`
server and never reads it, so the tests pass identically whether or not the
external provider is running. A harness that cannot fail for the reason it exists
is worse than a missing one.
## Repair the harness prerequisites
```task
id: KEY-WP-0022-T01
status: done
priority: high
state_hub_task_id: "1d552328-e75b-57fd-83bc-e469ae46b397"
```
Fix the binary paths, the `--basedn` flag, and the Go invocation so the scripts
run from a clean checkout with `make build`: use the module directory rather than
a workstation-specific Go path, and fail with a clear message when a prerequisite
binary or a required environment value is absent instead of part-way through.
Scenario B rewritten: root `bin/`, `go -C src` so the module root is right,
prerequisite checks before anything starts, and a cleanup trap so a failure does
not leave a stack running. Scenario C repaired in place — `bin/` paths, `--basedn`
for `lldap-to-ldap` (`lldap-export` correctly takes `--base-dn`; the two flags
genuinely differ), and the same module-relative test invocation.
## Add the missing stacks
```task
id: KEY-WP-0022-T02
status: done
priority: high
state_hub_task_id: "06f34e8b-f3d9-53ed-95c5-02e5063774a0"
```
Provide `docker-compose.scenario-b.yml` (Keycloak over the migrated realm) and
`docker-compose.scenario-c.yml` (OpenLDAP seeded from generated LDIF, plus
Keycloak). Validate them with `docker compose config` rather than assuming they
parse. Whether a full scenario passes end to end is a separate claim from whether
the stack definition is valid, and only the second is established here.
Both files added and validated with `docker compose config`. Scenario B was also
started for real; Scenario C's stack has not been run.
## Target conformance at an external issuer
```task
id: KEY-WP-0022-T03
status: done
priority: high
state_hub_task_id: "d837730d-e9d0-5adb-9dda-ffc47661fca3"
```
Add a suite that reads `KEYCAPE_TEST_ISSUER` and exercises that issuer over HTTP:
discovery metadata, the JWKS parsing under the same strict rules the runtime
applies, and — when client credentials are supplied — a real token exchange whose
claims are verified against the published keys. It must skip cleanly when the
variable is unset so ordinary `make test` is unaffected, and it must fail when
pointed at an issuer that does not meet the profile, which is the property the
current harness lacks.
`src/tests/conformance` checks that the issuer advertises itself, the profile
authorization surface (code, authorization_code, S256, RS256), that published
keys parse under `internal/jose` — the same rules the runtime applies, so a
provider publishing keys KeyCape would refuse fails here rather than at first
login — that excluded grants are absent, and, with credentials, that an issued
token verifies against the published keys and carries a matching issuer.
## Prove the suite against a real provider
```task
id: KEY-WP-0022-T04
status: done
priority: high
state_hub_task_id: "cb4d8af7-76a4-50b8-bee5-eb1c52c2f894"
```
Run the new suite against an actual Keycloak instance, not only against KeyCape,
since the point of the scenario is a replacement provider. Record what genuinely
passed and what remains unproven. Do not describe a partially exercised scenario
as a demonstrated live swap.
Run against Keycloak 26.0. Discovery, authorization surface and published-key
checks passed. A `client_credentials` exchange against a service client created
through the admin API returned a token that verified against Keycloak's JWKS via
`internal/jose`, with a matching issuer claim — the end-to-end property a relying
party depends on, proved against a replacement provider rather than against
KeyCape's own handlers.
The suite also failed, correctly. Stock Keycloak advertises `implicit` and
`password` in its discovery document. In Keycloak these are server capabilities,
not per-client toggles, so no realm the transformer emits can remove them: a
migrated Keycloak presents a wider grant surface than KeyCape. Scenario B
therefore reports a real failure today, which is the harness working. KeyCape
itself was not targeted in this run — the dev stack needs key material absent
from the checkout — so the suite is proved against the replacement provider, not
yet against both sides of the swap.
## Reconcile the records
```task
id: KEY-WP-0022-T05
status: done
priority: medium
state_hub_task_id: "2cb63801-8182-5c71-ab8d-9a1c377f418c"
```
Update `SCOPE.md` and G04's status with what the harness now does and what
replacement claims remain unproven — in particular that directory migration, MFA
and unchanged relying-party behaviour against a live Keycloak are not established
by this work. G04 does not fully close here.
SCOPE.md and G04's status record both the working harness and the grant-surface
finding, which substantiates with evidence what SCOPE previously asserted without
it. Scenario C has still never been run end to end.

View file

@ -0,0 +1,136 @@
---
id: KEY-WP-0023
type: workplan
title: "Prove the migration against live directories and fix what that surfaced"
domain: infotech
repo: key-cape
status: finished
owner: claude
topic_slug: live-migration-proof
created: "2026-09-08"
updated: "2026-09-08"
state_hub_workstream_id: "9f19bfa0-78d1-5b5c-9b64-e251fa4838d6"
---
Finishes the unproven half of gap G04. KEY-WP-0022 made the harness runnable and
targeted conformance at a live issuer; what remained was directory migration
against a real directory, both sides of the swap measured, and the explicit
limits recorded.
Running it found four defects that no amount of reading would have. That is the
result worth keeping: every one of them passed the full unit suite.
## Run the export against a live LLDAP
```task
id: KEY-WP-0023-T01
status: done
priority: high
state_hub_task_id: "b7b2ea88-8457-5fae-a500-a2d0f75754de"
```
**Defect: every LLDAP user search pointed at a branch that does not exist.**
`Config.userOU()` defaulted to `ou=users`; LLDAP stores users under `ou=people`,
and nothing in this repository set `UserOU` to correct it. `LookupUser`,
`ListUsers` and `ValidatePassword` all derive their search base from it, so all
three silently found nothing against a stock LLDAP — human login included, not
just the export.
Symptom that exposed it: exporting from a live LLDAP returned **zero users while
still emitting a membership referencing `uid=admin`**, a snapshot the repository's
own validator rejects with `referenced_users_exist`. Default corrected to
`ou=people` and pinned by a test; the re-run exports one user and validates
clean.
## Correct the rule I broke in KEY-WP-0021
```task
id: KEY-WP-0023-T02
status: done
priority: high
state_hub_task_id: "2f988626-ed08-52ce-919a-cd45d48e712d"
```
**Defect of my own making.** The LLDAP adapter records `_validation_warning`
inside `LDAPAttributes`. The `raw_attributes_well_formed` rule added in
KEY-WP-0021 rejects underscores as invalid attribute descriptors, so the
exporter's own annotation made its own output fail validation — a false positive
shipped one workplan earlier and caught only by validating a real export.
Keys prefixed with `_` are now recognised as tooling annotations and skipped,
documented as such. A test proves the exemption does not weaken the rule: a
malformed real attribute alongside an annotation still fails.
## Prove the LDIF loads into a real OpenLDAP
```task
id: KEY-WP-0023-T03
status: done
priority: high
state_hub_task_id: "0f503342-0ca8-55ad-b7e4-e2316d4d7bad"
```
**Defect: migrated group memberships were dangling.** `resolveMemberDN` returned
a member that already looked like a DN unchanged, so members kept their *source*
DNs (`uid=admin,ou=people,…`) while `writeUser` wrote entries to the *target*
branch (`ou=users`). Groups named entries the migrated directory does not
contain. Only the naming value is carried across now; branch and naming
attribute come from the target.
**Defect: empty groups could not be loaded at all.** `groupOfNames` makes
`member` a MUST, so OpenLDAP rejected every memberless group with an object class
violation and aborted the load. Since KEY-WP-0018 the export deliberately
preserves empty groups, so this was reachable by any real migration. Empty groups
now reference a placeholder entry the same LDIF creates — an `organizationalRole`,
not a person, so it cannot be mistaken for a migrated user — emitted only when
some group needs it.
**Defect: the compose file could not start.** `bitnami/openldap:2.6` does not
exist in the registry. It passed `docker compose config` in KEY-WP-0022, which is
exactly the gap between a file that parses and a stack that runs. Pinned to
`osixia/openldap:1.5.0`, the image the scenario was actually proved against.
Proof: LLDAP → export → validate → LDIF → `ldapadd` into OpenLDAP 1.5.0, all
seven entries added with no errors, and every `member` value in the loaded
directory resolves to an entry that exists.
## Measure both sides of the swap
```task
id: KEY-WP-0023-T04
status: done
priority: high
state_hub_task_id: "8a7fe95e-d463-5e74-81fa-5fa60a879774"
```
KeyCape was run locally against a generated key and targeted with the
conformance suite: all five checks pass, including the excluded-grant check
Keycloak fails. The Keycloak side was exercised twice — the stock realm in
KEY-WP-0022, and here the *migrated* realm produced from the live export, which
imported cleanly and served discovery. The migrated user arrived carrying
`keycape.canonicalId`.
The divergence is now measured rather than asserted: KeyCape passes 5/5, a
migrated Keycloak passes 4/5 and fails on advertising the excluded `implicit` and
`password` grants.
## Record what is proved and what is not
```task
id: KEY-WP-0023-T05
status: done
priority: medium
state_hub_task_id: "acef6ceb-6dea-56e1-94bc-e5d589ef3b6f"
```
G04 is substantially closed: stacks are reproducible and were run, conformance is
externally targeted and was run against three issuers, and directory migration is
proved end to end into a real directory.
Not closed, and stated as such: no browser login was performed against the
migrated realm, so unchanged relying-party behaviour is not established, and MFA
was not exercised. Credential and MFA migration is a **scope exclusion** rather
than a test gap — SCOPE says it is not supplied, so no harness can demonstrate
it. `keycape-to-keycloak` already reports that subject continuity is not
preserved: the canonical ID survives as an attribute while Keycloak mints its own
`sub`, so relying parties keyed on `sub` will not recognise migrated users.

View file

@ -0,0 +1,62 @@
---
id: KEY-WP-0024
type: workplan
title: "Wire tenant_roles as explicit opt-in configuration"
domain: infotech
repo: key-cape
status: finished
owner: claude
topic_slug: tenant-roles-opt-in-wiring
created: "2026-09-08"
updated: "2026-09-08"
state_hub_workstream_id: "d75f52f2-df0d-5a56-8394-892feeec4b4d"
---
Closes gap G07. The tenant-engine client and `TokenHandler.TenantEngine` were
implemented and tested, but `main.go` supplied no client and exposed no
configuration, so the stock executable left the field nil and never emitted
`tenant_roles`. The capability existed at library level only.
Wired rather than documented away as library-only: the claim is cheap, the
adapter already fails open, and leaving a tested capability unreachable from the
binary invites the same gap being rediscovered later.
## Add opt-in configuration and wire the client
```task
id: KEY-WP-0024-T01
status: done
priority: medium
state_hub_task_id: "0e53485b-7e94-5dd8-8e02-1d18f5413a78"
```
Added a `tenantEngine` config block with `baseURL` and an optional `timeout`.
An empty `baseURL` disables the claim, which keeps the stock server's behaviour
exactly as it was — enabling it is a deliberate act.
Validation treats a configured source as one that must work: the URL must be
http/https with a host, and the timeout must parse and fall in (0, 10s], since it
sits on the synchronous token-issuance path. A `timeout` set without a `baseURL`
is rejected rather than ignored — it means someone expected the claim to be on
and it silently would not have been.
## Verify it in the built executable
```task
id: KEY-WP-0024-T02
status: done
priority: medium
state_hub_task_id: "c4279ce0-f6f8-5612-a48a-c1d2fd3da3b6"
```
G07's closure criterion is the built executable, not the wiring, so all three
behaviours were checked by running `bin/keycape` and inspecting real tokens
rather than by reading the code:
- configured against a stub tenant-engine, a `client_credentials` token carries
`tenant_roles: [capability:approve, capability:observe]`;
- with no `tenantEngine` block, the claim is absent;
- configured but with the source down, issuance still succeeds and the claim is
absent — the fail-open behaviour the adapter documents, confirmed end to end.
Nine validation cases cover the accepted and rejected configurations.

View file

@ -0,0 +1,118 @@
---
id: KEY-WP-0025
type: workplan
title: "Give the runtime real readiness, graceful shutdown and stated operational limits"
domain: infotech
repo: key-cape
status: finished
owner: claude
topic_slug: runtime-lifecycle-and-readiness
created: "2026-09-08"
updated: "2026-09-08"
state_hub_workstream_id: "70f74bbd-6748-5bbe-b261-a310b6f24e0c"
---
Closes gap G08. `/healthz` returned a constant without probing anything, the
server called `ListenAndServe` with no signal handling, and the operational
limits of in-memory state, startup-loaded keys and local-only logout were spread
across code comments rather than stated anywhere an operator would look.
G08 explicitly does not require shared storage or refresh tokens. The work is
therefore to make readiness real, make restarts survivable for in-flight
requests, and write the limits down honestly.
## Separate readiness from liveness
```task
id: KEY-WP-0025-T01
status: done
priority: medium
state_hub_task_id: "0e0d3349-efaf-546c-a58d-2a9b736278a9"
```
Added `internal/server/readiness` and a `/readyz` endpoint probing LLDAP,
Authelia and privacyIDEA. `/healthz` stays liveness and deliberately probes
nothing: wiring liveness to dependency health means an orchestrator restarts
KeyCape when a dependency blinks, and since a restart also discards every
in-flight login, the reaction is worse than the condition.
Design decisions worth keeping:
- **LLDAP is probed with a bind, not a dial.** A rotated or revoked service
password leaves the port open and every lookup failing — precisely the state
readiness exists to catch, and precisely what a TCP dial would miss.
- **The body names the failing check but never the reason.** The endpoint is
unauthenticated, and upstream error text carries hostnames and sometimes
credentials-in-URLs. Detail goes to the log. A test asserts a probe error
containing a credentialed URL does not reach the response.
- **Results are cached for 2s.** Not an optimisation: without it, anyone able to
reach an unauthenticated endpoint could drive one upstream request per
dependency per call.
- **Probes run concurrently, each bounded at 3s**, so readiness costs the slowest
probe rather than their sum, and a hung dependency makes the endpoint answer
rather than hang alongside it.
## Shut down without dropping requests
```task
id: KEY-WP-0025-T02
status: done
priority: medium
state_hub_task_id: "0bea0780-523a-587e-a157-d429df15307b"
```
`SIGTERM`/`SIGINT` now drain in-flight requests for up to 15 seconds. The grace
period sits under the 30s read/write timeouts so a stuck request cannot outlive
the window an orchestrator allows before `SIGKILL`.
In-memory login state is deliberately not preserved: a browser mid-login must
start again after a restart. That is a property of the single-replica topology,
documented rather than papered over.
## State the operational limits
```task
id: KEY-WP-0025-T03
status: done
priority: medium
state_hub_task_id: "97eba4bb-f8a0-5941-bdc8-6d59b0fa2942"
```
`docs/operations.md` states the supported topology — exactly one replica, because
authorization codes, login sessions and handoffs are process-local and no sticky
configuration makes a second replica safe — and the consequences: restarts drop
in-flight logins while issued tokens keep working, and rolling deploys have a
window where new logins fail.
It also records three limits that are easy to get wrong in operation:
- the key ID is the constant `key-1`, so rotating the key without changing it can
leave consumers caching JWKS by `kid` rejecting freshly issued tokens;
- removing a client registration does not revoke its issued tokens, since there
is no revocation or introspection endpoint — they stay valid until expiry;
- `/logout` clears the local session only, not the Authelia session or any token.
No throughput or resource figures are given: nothing here benchmarks KeyCape, so
any number would be invented.
## Verify in the running server
```task
id: KEY-WP-0025-T04
status: done
priority: medium
state_hub_task_id: "bc64fdc2-0a31-5dab-afd1-5e7b6d11d2b3"
```
Checked against the built executable rather than the handlers, following the
lesson from KEY-WP-0023:
- with every dependency down, `/readyz` returns 503 naming all three as failed
while `/healthz` still returns 200 — the split doing its job;
- starting LLDAP flips its check to `ok` while the others stay failed, so the
probes are measuring what they claim;
- 40 requests issued across a `SIGTERM` all returned 200, and the log shows the
signal handled and shutdown completed.
Seven unit tests cover the reporter, including the disclosure, caching, timeout
and concurrency properties above.

View file

@ -0,0 +1,123 @@
---
id: KEY-WP-0026
type: workplan
title: "Reconcile packaging, bootstrap and migration credential handling"
domain: infotech
repo: key-cape
status: finished
owner: claude
topic_slug: packaging-bootstrap-and-credential-handling
created: "2026-09-08"
updated: "2026-09-08"
state_hub_workstream_id: "d2b04d59-41b1-531b-8cab-a980328611b4"
---
Closes gap G09. Five separate defects, only loosely related: the dev stack could
not start from a clean checkout, the image shipped one binary of five, the
publish workflow named a different registry than the one the cluster pulls from,
the exporter took a password on argv, and every migration artifact was written
world-readable.
## Take the bind password off argv
```task
id: KEY-WP-0026-T01
status: done
priority: medium
state_hub_task_id: "510a4603-337f-5ceb-b6b7-b9126d8605f0"
```
`lldap-export` took `--bind-pw`, so the service account password was visible to
any local user through `ps`, and captured by shell history and process
accounting. It now prefers `KEYCAPE_LLDAP_BIND_PW`, accepts `--bind-pw-file`, and
keeps `--bind-pw` working with a warning — deprecated rather than removed,
because existing runbooks use it and silently breaking them would be worse than
the exposure for one more cycle.
Conflicting sources are rejected rather than silently ranked: with two supplied,
an operator cannot tell which bind was attempted. A password file has its
trailing newline trimmed, since editors and heredocs add one and binding with it
fails confusingly. Both migration scripts now pass the password by environment.
## Stop writing migration artifacts world-readable
```task
id: KEY-WP-0026-T02
status: done
priority: medium
state_hub_task_id: "679e21a1-e37b-574b-bc25-b9fd46bdfd16"
```
The canonical export, the generated LDIF and the Keycloak realm were all written
`0644`. None carries credential material, but the snapshot is a directory dump —
every username, display name, email and group membership in the estate — and it
tended to land in `/tmp`. All three are `0600` now.
## Ship every binary in the image
```task
id: KEY-WP-0026-T03
status: done
priority: medium
state_hub_task_id: "5252a13c-cd5d-5686-a7f5-04587d7d9b1d"
```
The image packaged `keycape` alone, so the validator and the three migration
binaries needed a Go toolchain on the host — which defeats shipping an image for
the cutover work they exist to support. All five are built and copied; the issuer
remains the entrypoint and the rest are reachable by overriding it. Verified by
building the image and running each binary inside it.
## Reconcile the publish target
```task
id: KEY-WP-0026-T04
status: done
priority: medium
state_hub_task_id: "45b8c386-41bd-514c-9247-5eba9663d8fb"
```
The workflow published to `92.205.130.254:32166/coulomb/key-cape` while the
cluster runs `forgejo.coulomb.social/coulomb/key-cape` (KEY-WP-0013). Whether
those are one registry behind two names or two registries is not determinable
from this repository, which is exactly the problem: a push could not be assumed
to have deployed the current source. Now defaults to the recorded Forgejo name
and stays overridable through a repository variable.
**Unverified, and needs an operator's eye:** this repository cannot test that the
runner resolves that hostname or that `REGISTRY_USER`/`REGISTRY_TOKEN` are valid
for it. If the next publish fails, set the `REGISTRY` repository variable back to
the address.
## Make the dev stack bootstrappable
```task
id: KEY-WP-0026-T05
status: done
priority: medium
state_hub_task_id: "a750ac2f-cad2-5e7f-a505-2ca6b94f1a5a"
```
`docker-compose.dev.yml` mounts `config/dev-key.pem` and `config/authelia/`,
neither in the checkout — correctly, since one is a private key and the other
carries a password hash. `scripts/bootstrap-dev.sh` generates both locally: an
RSA key created under `umask 077` rather than chmodded afterwards, so it is never
briefly world-readable, plus an Authelia configuration, its OIDC signing key and
a user fixture. Everything it writes is git-ignored, and re-running it leaves
existing material alone.
The fixture password is published in the script deliberately: a reader can see
exactly what it unlocks, which is nothing deployed.
## Reconcile the records
```task
id: KEY-WP-0026-T06
status: done
priority: medium
state_hub_task_id: "cc7c86ae-9c78-5422-8f3c-19441ceeb513"
```
`SCOPE.md` and G09's status record what is fixed and what is not: the registry
change is unverified from here, and a documented external bootstrap remains the
route for anything beyond development.

View file

@ -0,0 +1,130 @@
---
id: KEY-WP-0027
type: workplan
title: "Establish the live state and the rollout gap for G10"
domain: infotech
repo: key-cape
status: finished
owner: claude
topic_slug: rollout-readiness-and-live-state
created: "2026-09-08"
updated: "2026-09-08"
state_hub_workstream_id: "2ab60558-ee14-542c-9f19-bb487de8e668"
---
G10 cannot be closed from this repository: it waits on named custody and platform
owners. What was doable was to stop guessing about the live state, verify the
handoffs actually went out, and find out whether main is safe to roll out. The
last question turned up a defect in this repository's own recent work.
## Verify the handoff receipts independently
```task
id: KEY-WP-0027-T01
status: done
priority: medium
state_hub_task_id: "00de2bad-04da-5b58-8cde-aaa144272541"
```
KEY-WP-0009 was reopened once because delivery was claimed without receipts, so
the claim that it has now been sent deserved the same scrutiny rather than being
taken on trust. `GET /messages/?from_agent=key-cape` shows all seven present and
dated 2026-09-08T08:21Z: the four KEY-WP-0009 handoffs (netkingdom,
secrets-engine, ops-warden, railiance-platform) and the three admission requests
(railiance-platform custody, approval-engine human callback, ops-warden token
split). No replies had arrived at the time of writing, which is expected hours
after sending and is not itself a blocker to record.
## Record the live state as observed, not as remembered
```task
id: KEY-WP-0027-T02
status: done
priority: medium
state_hub_task_id: "2d513229-1dbd-500e-9100-c0173be7b4de"
```
Read-only cluster inspection, `sso` namespace, 2026-09-08:
- image `forgejo.coulomb.social/coulomb/key-cape:main-153258b`, predating every
change from KEY-WP-0016 onward;
- environment carries `KEYCAPE_RAPP_QONTO_CLIENT_SECRET` only, so the two
approval clients are still not materialized — KEY-WP-0013-T02 remains correctly
blocked;
- four registered clients: `demo-app`, `netkingdom-bootstrap-console` and
`openbao-admin` (public, authorization_code) and `rapp-qonto-client`
(confidential, client_credentials);
- no `tenantEngine` block, so KEY-WP-0024 is inert on rollout.
**Correction to an earlier claim of mine.** I reported the `ou=people` fix
(KEY-WP-0023) as a production-relevant correction to human login. The deployed
config sets `userOU: "ou=people"` explicitly, so production was never affected.
The fix matters for anyone relying on the default — the dev stack, the scenario
scripts, a fresh deployment — not for this one.
## Find out whether main is safe to roll out
```task
id: KEY-WP-0027-T03
status: done
priority: high
state_hub_task_id: "23c8d04b-c2b7-5347-80a0-6388c1c41c53"
```
**Defect found in KEY-WP-0019, before it shipped.** Upstream verification
discovers the expected issuer server-side from `authelia.tokenBaseURL`. The
deployed Authelia derives its advertised issuer from the request Host, confirmed
directly:
| Host presented | issuer advertised |
| --- | --- |
| `authelia.sso.svc.cluster.local:9091` | `http://authelia.sso.svc.cluster.local:9091` |
| `auth.coulomb.social` | `http://auth.coulomb.social` |
KeyCape fetches over the in-cluster address, so it would expect the first value
while tokens minted for the browser-facing host carry the second. Verification
fails closed, so if they differ **every human login fails after rollout**, and it
presents as a broken login rather than a configuration error.
Which value the ID token actually carries could not be settled from here without
performing a real login against production Authelia, which was not done. The risk
is therefore recorded as a rollout precondition, not as a confirmed breakage.
Two mitigations, both in this repository:
- `docs/operations.md` documents pinning `authelia.issuer` and `authelia.jwksUrl`
explicitly, with the one-line `curl` that reveals the value the provider will
advertise for a given Host. The overrides already existed; nothing said when
they were needed.
- The failure is now diagnosable. The authentication failure event carries a
specific reason — `id_token_issuer_mismatch`, distinct from
`id_token_signature`, `provider_keys_unavailable` and the rest — so an operator
can tell a misconfiguration from an attack. Five cases cover the mapping. The
browser still learns nothing.
## Record what each owner owes
```task
id: KEY-WP-0027-T04
status: done
priority: medium
state_hub_task_id: "7ba84b61-0458-5820-8ded-c6f59d820303"
```
G10's status now names the live state, the outstanding requests with their
receipt ids, and the issuer precondition that must be checked before main is
deployed. The precondition was also sent to railiance-platform as the deployment
owner (message c8b1ad10), with the exact `curl` to settle it, the config keys to
pin, and the `error_type` to look for if it bites — plus a note that the two
approval clients remain unprovisioned, confirmed by inspection rather than
asserted. G10 stays open: nothing here admits custody, registers the human
callback, or reconciles the OpenBao-token boundary, and no repo-local change can.
2026-09-08 live return: the actual signed-token uncertainty in T03 is now
resolved by KEY-WP-0013-T02's admitted probe. The issuer is exactly
`https://auth.coulomb.social`; signature/audience/time/nonce checks passed and
the Job exited 0. The temporary route, policies, service and Job/Pod were removed.
`docs/evidence/2026-09-08-upstream-issuer-live-proof.json` is the source receipt.
The configuration owner still ensures the exact pin before main rollout; no
normal config or Deployment changed during this proof.

View file

@ -0,0 +1,89 @@
---
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.

View file

@ -0,0 +1,81 @@
---
id: KEY-WP-0029
type: workplan
title: "Reassess SCOPE against the current revision"
domain: infotech
repo: key-cape
status: finished
owner: claude
topic_slug: scope-reassessment
created: "2026-09-09"
updated: "2026-09-09"
state_hub_workstream_id: "cebe226b-5418-5559-bc48-379e843bca78"
state_hub_workstream_id: "cebe226b-5418-5559-bc48-379e843bca78"
---
SCOPE.md declared itself reviewed against `b989de4`, 63 commits behind. Its
limits section had been kept current by appending to bullets, which works until
the header asserts a review date that no longer describes what was reviewed.
## Recheck every claim against source
```task
id: KEY-WP-0029-T01
status: done
priority: medium
state_hub_task_id: "b12dfe7d-f4b6-5c44-89c6-9d3c02435d4c"
```
Routes read from the mux registrations, subcommands from the dispatch in
`main.go` and `internal/authclient/cli.go`, flags from the flag sets, claim
behaviour from `token.go` and `userinfo.go`. Where the existing prose and the
code disagreed, the code won.
Three claims were false and are corrected: caller commands omitted
`verify-client`; the container was described as packaging only `keycape`, untrue
since KEY-WP-0026; and the issuer API row omitted `/readyz`. Capabilities that
had only reached SCOPE piecemeal — the authorization-code bindings, upstream
token verification, readiness and drain, the human tenant rules,
authentication-time assurance, the shared verifier — are now stated in the
capability table rather than only in the limits prose.
## Separate source behaviour from deployed behaviour
```task
id: KEY-WP-0029-T02
status: done
priority: medium
state_hub_task_id: "28ae58c0-9bb9-573f-9cf3-51230d053e3c"
```
The document now says plainly that it describes source, and that the two are
different claims with different evidence: the test suite and harness runs for
one, committed receipts in `docs/evidence/` for the other.
What is proven live is narrower and is stated exactly — the issuer pin, whose
pre-pin state did *not* match, so that risk was real rather than hypothetical;
and the two approval clients verified in an attended owner process. Both receipts
decline real predecessor rotation and observed wall-clock expiry, and SCOPE
declines them too. The deployed image predates this revision, so several
behaviours SCOPE describes are running nowhere yet, two of them fail-closed at
startup.
This is the correction that matters. Four times in two days a statement about
current state was written from a workplan paragraph, a partial `git status`, or
an inference about what was admitted, without reading the authoritative artifact.
A SCOPE that does not distinguish "the code does this" from "this is running"
invites exactly that error from the next reader.
## Record the reassessment
```task
id: KEY-WP-0029-T03
status: done
priority: low
state_hub_task_id: "7ef92d4f-eb56-5208-8b85-ed7bb2656c46"
```
`history/2026-09-09-scope-reassessment.md` records the method, what changed since
2026-09-05, the corrections, and what remains. It supersedes rather than replaces
the 2026-09-05 assessment, which stays as the record of the ten-gap backlog and
its closures; both are linked from SCOPE.

View file

@ -0,0 +1,73 @@
---
id: KEY-WP-0030
type: workplan
title: "Make the static-registration precondition a checked condition"
domain: infotech
repo: key-cape
status: finished
owner: claude
topic_slug: tenant-precondition-guard
created: "2026-09-09"
updated: "2026-09-09"
state_hub_workstream_id: "9ed8f851-080a-5d15-8642-f799c2e3295c"
---
`informed-decision` replied on KEY-WP-0013-T05 and asked for one thing that is
ours to do: the caveat under which a registration-bound human tenant is safe
should be "a condition of the capability, so a future change to registration
policy has to confront it", not reasoning left in a message.
They are right that it was only prose. `docs/tenant-claim-contract.md` states it,
and a separate test asserted `registration_endpoint` is absent — but nothing
connected the two, so a future session adding dynamic registration would see a
test about discovery metadata, not a warning about relabelling users.
## Tie the exclusion to the capability it protects
```task
id: KEY-WP-0030-T01
status: done
priority: medium
state_hub_task_id: "4729d591-39d4-5f33-bef8-db8057090913"
```
`TestRegistrationBoundTenantRequiresStaticRegistration` asserts both halves
together: that a client-declared tenant is issued, and that dynamic client
registration is not advertised. Whichever is removed first, the failure points at
the other.
The failure message carries the reasoning rather than the observation. Adding
dynamic registration while a registration may declare its users' tenant means
anyone able to register a client can relabel the users who log in through it into
a zone of their choosing; the message says that and names the two ways out, drop
the capability or gate it to statically configured registrations.
Verified in both directions rather than assumed: advertising a
`registration_endpoint` fails it with the escalation message, and neutering
`humanTenant` fails it with the message saying to remove the guard along with the
capability it protects.
This is deliberately not a vote on the tenant question. It makes the precondition
of one option checkable; it does not choose between them, and if option 1 lands
the capability and this guard are removed together.
## What stays with the owners
```task
id: KEY-WP-0030-T02
status: done
priority: medium
state_hub_task_id: "32b10327-f55d-5a0e-a4bc-be92929469dc"
```
`informed-decision` prefers option 2 and explicitly declines to treat that as the
answer, flagging it to gate-house on GH-DEC-2026-012 and naming approval-engine's
stake. KeyCape leans the same way, which is a reason for more care rather than
less: the code already implements option 2 (`329e48f`), so the doctrine question
is being decided against a working implementation. That is worth saying out loud
to everyone waiting on it, and was.
Their scope set `[openid, approval:read, approval:approve]` is confirmed and
already published. `client_id` and callback follow at their T07, deliberately not
before the tenant question resolves — registering a client that fails closed at
first use is the failure this exchange existed to avoid.

View file

@ -0,0 +1,81 @@
---
id: KEY-WP-0031
type: workplan
title: "Implement the gate-house tenant ruling"
domain: infotech
repo: key-cape
status: finished
owner: claude
topic_slug: gate-house-tenant-ruling
created: "2026-09-10"
updated: "2026-09-10"
state_hub_workstream_id: "2bc4d085-9b10-5160-bc8a-4fccc5d41ba9"
---
gate-house ruled GH-DEC-2026-013 (`gate-house@16c1d46`) on the human-tenant
question KeyCape declined to settle for itself. Directory-sourced is the terminal
state; the registration-bound shape is granted as a declared bounded gap under
three conditions, on the general property that *a transitional shape is
admissible where it fails closed on exactly the case distinguishing it from the
correct resolution*. Ours refuses issuance where registration and directory
disagree, which is that case.
Conditions (a) and (c) needed nothing from this repository — (a) is already the
implemented behaviour, and its strengthening, that a future change preferring the
**directory** is equally void because picking any winner is a silent
cross-tenant assertion, is recorded in the contract. This workplan covers the two
items that were implementation.
## Carry the tenant claim's provenance
```task
id: KEY-WP-0031-T01
status: done
priority: high
state_hub_task_id: "46d42120-eca5-5b7f-8445-2211b3b8eab0"
```
gate-house ruled on GH-DEC-2026-013 while this was in flight. Section 5 is a
finding nobody asked for and is ours to implement: `tenant` is a bare string, so
a consumer cannot tell a zone the directory asserted about the person from one a
registration supplied about the client they came through. approval-engine
exact-matches that string while its contract reads as though it relies on the
first — the check is sound and the property a reader infers from it is absent.
Every token now carries `tenant_source` beside `tenant`: `directory`,
`registration`, or `default`. It is advertised in `claims_supported` and asserted
on both grants at the token level, not only in the resolution function, because
the claim a consumer reads is the thing under obligation.
Three values where the ruling names two, and that is the substantive judgement
here. Labelling an unasserted profile default as `directory` would reproduce the
same defect one level down — a consumer reading an assertion the identity layer
never made. The ruling cites GH-DEC-2026-011 §3 on unknown versus absent for the
case it examined; the same rule applies to our own fallback, so the fallback is
named rather than folded into the strongest neighbouring value.
The agreement case resolves to `directory` deliberately: if a registration
declares the zone the directory also assigned, the directory did assert it, and
reporting the weaker source would understate what is known.
## Condition (b) corrected a message I had just shipped
```task
id: KEY-WP-0031-T02
status: done
priority: high
state_hub_task_id: "b21aeac8-8370-5572-ab00-6e9812fa48e7"
```
The guard committed in `5f516a0` told a future reader that adding dynamic
registration could be resolved by dropping the capability **or gating it to
statically configured registrations**. Condition (b) voids the second: admitting
dynamic registration voids the registration-bound shape that day, and the
directory becomes the only source whatever state the adapter is in. gate-house
strengthened our own "must be revisited" on the grounds that *revisited* implies
the answer might survive review, and it would not.
So the message offered a way out the ruling forbids, in the exact place someone
would read it while making that change. Corrected the same day. Worth recording
rather than quietly editing: the guard was written to force a confrontation, and
a guard that suggests an inadmissible resolution is worse than none.

View file

@ -0,0 +1,75 @@
---
id: KEY-WP-0032
type: workplan
title: "Pin the single route by which a principal becomes human"
domain: infotech
repo: key-cape
status: finished
owner: claude
topic_slug: principal-type-provenance-guard
created: "2026-09-10"
updated: "2026-09-10"
state_hub_workstream_id: "150b892c-f112-5fb9-b0f0-8072a52b85d5"
---
GH-DEC-2026-016 §5 applies A-16 to what makes a principal *human*: if `human` is
reachable by two routes — asserted by the identity layer about the person, or
supplied by a registration about the client they came through — the record must
say which, and a human-in-the-loop control must not be discharged on the
registration-supplied one. Refusing a service principal while accepting an
unverified assertion of humanity moves the defect rather than closing it.
gate-house said explicitly this was not a request. It is built because the
property it protects is true *today* and is the kind a plausible change erases
quietly.
## Pin the property, not the behaviour
```task
id: KEY-WP-0032-T01
status: done
priority: medium
state_hub_task_id: "2f42a72e-6292-5c02-9223-835db17b0a63"
```
`principal_type` has exactly one route to `human`: a string literal on the
authorization-code path, reached only after an upstream login resolved to a
directory user. No configuration field can assert it — verified against source
rather than assumed, and it is why the tenant provenance work has no counterpart
here and why A-16 does not yet bite.
Asserting the *behaviour* would not have protected that. A test checking "a human
token says human" passes just as happily when the value starts coming from a
registration. So the guard parses the package and requires every assignment to
`principal_type` to be a string literal, with the literal set exactly
`{human, service}` — one route each. `claims["principal_type"] =
client.PrincipalType` would look like a feature in review and fails the build
instead, with a message saying that if it is deliberate the claim must carry its
provenance the way `tenant_source` does.
Both assignment shapes are covered. The browser path assigns into a map; the
service path uses a key-value pair inside a map literal. Checking only
assignments found one of two routes and passed — a guard proving less than it
claimed, caught because the expected literal set was pinned rather than merely
counted.
Verified by mutation: replacing the literal with a function of the client
registration fails it at the exact line, and restoring it passes.
## What this deliberately does not do
```task
id: KEY-WP-0032-T02
status: done
priority: medium
state_hub_task_id: "13199970-75d1-5c38-aa8c-27d6e38dcf65"
```
No `principal_type_source` claim was added. There is one route, so a provenance
marker would encode a distinction that does not exist and invite consumers to
branch on it — the opposite of the tenant case, where two routes existed and the
claim could not say which had been taken.
The guard is the honest response to "not a request today": it makes the absence
of a second route a checked fact rather than a remembered one, and hands the
decision to whoever creates the second route, at the moment they create it.

View file

@ -0,0 +1,93 @@
---
id: KEY-WP-0033
type: workplan
title: "Preserve fresh-user authentication for the Vergabe company handoff"
domain: infotech
repo: key-cape
status: finished
flavor: implementation
owner: codex
topic_slug: netkingdom
created: "2026-09-12"
updated: "2026-09-25"
related: [VERGABE-WP-0019, NK-WP-0037, USER-WP-0025]
state_hub_workstream_id: "144dd430-1a09-51e1-9aad-e799ee86c338"
---
## Forward login freshness to the actual authentication provider
```task
id: KEY-WP-0033-T01
status: done
priority: high
state_hub_task_id: "fe05486b-f079-503b-ade1-fca65bf64b34"
```
KeyCape parsed prompt=login and max_age but dropped them before Authelia.
Forward these through the provider-neutral AuthRequest and the Authelia adapter.
Absent values retain ordinary SSO behavior. Handler and adapter regressions
cover forced login and zero/nonzero maximum age. Full Go suite passes after
correcting the existing example-count regression: the example file already
contains four service clients and the admitted human approver client.
## Publish, preflight and prove the fresh recipient boundary
```task
id: KEY-WP-0033-T02
status: done
priority: high
state_hub_task_id: "7b58f08c-6063-554a-8700-a1edbc805ca4"
```
Publish the exact source and validate current live config before replacing the
single issuer instance. The live image is dcebd46/digest 7ff54c54; current main
also contains startup validation and tenant provenance changes documented in
docs/operations.md. Preserve all unrelated client, credential and MFA policy
configuration. NK-WP-0037 adds the exact public Vergabe callback with no tenant
assertion or MFA downgrade. Confirm prompt=login reaches Authelia through the
live redirect, wrong callbacks and missing PKCE fail, and the actual recipient
uses their own identity. Provider restart invalidates pending in-memory logins;
USER-WP-0025-T03 still owns full provider sign-out coordination.
2026-09-12 release prepared: source 8d4336e, Forgejo image run 45 succeeded,
digest sha256:5f10f36a5da23ce1aaf3df9b84a8ff98d7926f34ceaa19e63bd3356adb68e01a.
The exact client-registration and deployment server dry runs pass. The runtime
is unchanged. Await the attended window required by docs/operations.md; the
complete packet is railiance-apps/docs/vergabe-demo-company-sso-rollout.md.
2026-09-12 attended rollout executed after explicit operator approval. KeyCape
and password setup are Ready on the prepared digests; exact public client
registration was CAS-applied (config resourceVersion 60123977) with unrelated
config bytes/Secret data preserved. Existing portal and product client both
pass fresh-login forwarding, wrong-callback and missing-PKCE checks (6 checks).
Vergabe Helm revision 2 is Ready; identity migration completed, both PVCs remain,
and requests remain 60m CPU/256Mi memory. Eleven live product checks pass:
company welcome, anonymous gate, no-store, secure scoped CSRF, POST/CSRF-only
login start, native issuer redirect, private company/media protection and
invalid callback/confirmation rejection. Initial readback showed zero accounts,
identity mappings and staff accounts. Native invited-user sign-in/MFA and
confirmation are now requested from the operator; no user credential was used
by the agent. Recovery and two-user acceptance remain their existing tasks.
Evidence: railiance-apps/docs/evidence/2026-09-12-demo-company-sso-live.md.
2026-09-23 regression from the attended recipient sign-in (net-kingdom message
ee4808e2, NK-WP-0037-T02): Authelia 4.38 registers the authorization request
only after the login, so forwarding prompt=login fails every real login ("auth_time
happened before the authorization request was registered"). The 2026-09-12
forwarding checks were redirect-only and never completed a login. Fix (owner
option a): KeyCape no longer sends prompt to Authelia. prompt=login and
max_age=0 travel as max_age=10, a shorter max_age is raised to 10, a longer one
passes through. KeyCape then enforces the exact requirement itself against the
verified upstream auth_time: prompt=login needs an authentication no earlier
than the KeyCape request, max_age needs one within that age, and a missing
auth_time fails closed (event `stale_upstream_authentication`). Known edge: a
user whose Authelia login is under 10s old at request time is not re-prompted
and is refused rather than admitted. Also fixed the stale example-count test left
by 651625c/1620ce2 (6 service + 1 human). Full Go suite passes.
The image was already the live issuer
(`sha256:8dc35801`, Forgejo run 69, source `11ce29a`). No second rollout.
2026-09-24 23:46 UTC the founder completed the Vergabe journey: one
`token_exchange_error`, then `auth_success` and `token_issued` for
`vergabe-demo-company`. No `stale_upstream_authentication`. Receipt:
`docs/evidence/2026-09-24-fresh-login-and-account-switch.md`.

View file

@ -0,0 +1,64 @@
---
id: KEY-WP-0034
type: workplan
title: "Browser authentication recovery and confirmed shared sign-out"
domain: infotech
repo: key-cape
status: finished
flavor: implementation
owner: codex
topic_slug: key-cape
created: "2026-09-12"
updated: "2026-09-25"
state_hub_workstream_id: "49b0cd1d-fb9d-5351-99eb-9be9143a092d"
---
The operator reports a dead-end authentication error after using an account
outside the product tenant. Recent issuer telemetry indicates token exchange
failure; tenant rejection and provider failure must not be conflated.
## Implement and validate recovery
```task
id: KEY-WP-0034-T01
status: done
priority: high
state_hub_task_id: "ba285335-4ead-5478-8fb7-89a2ec1ba695"
```
Route failed browser login to the public account recovery surface without codes,
state or unverified identity. Show verified portal identity, tenant memberships,
and recorded workload memberships; preserve operator/customer separation.
Provide CSRF-protected portal logout and confirmed shared provider sign-out with
fixed owner-configured return locations. No automatic reauthentication loops,
MFA downgrade, global JWT revocation claim or inferred workload entitlements.
## Publish and verify the recovery flow
```task
id: KEY-WP-0034-T02
status: done
priority: high
state_hub_task_id: "4c836ddf-2eb8-528a-b63f-8499f3a6cbf2"
```
Publish immutable images, update canonical runtime pins, verify anonymous
recovery and sign-out confirmation live, and record actual account switching
only after browser evidence. Existing application sessions may outlive provider
logout. Related: USER-WP-0025-T03 and VERGABE-WP-0019-T06.
Source verification: Full Go suite passed; final OIDC/config tests passed after recovery expansion. Immutable publication and live checks are in progress.
### Live recovery rollout — 2026-09-12
Recovery is deployed in KeyCape 4d8b8fe, User Engine e54b6ee and Vergabe c067993
(Helm revision 3). All three are Ready. Six provider checks, eleven product
checks and six fresh anonymous Chromium checks pass, including actual provider
logout POST and return to the portal without test overrides. Detailed receipt: railiance-apps/docs/evidence/2026-09-12-account-recovery-live.md.
2026-09-24 23:42–23:45 UTC the founder completed the account switch. The issuer
recorded one `mfa_failed` for `user-engine-portal`, then three separate
`auth_start` → `auth_success` → `token_issued` sequences. A silent reuse of
the previous portal session would not have produced those new successes.
Provider logout still does not revoke already issued application JWTs.
Receipt: `docs/evidence/2026-09-24-fresh-login-and-account-switch.md`.

View file

@ -0,0 +1,112 @@
---
id: KEY-WP-0035
type: workplan
title: "Opt-in MFA for demo-company login"
domain: infotech
repo: key-cape
status: finished
owner: codex
topic_slug: infotech
created: "2026-09-13"
updated: "2026-09-14"
state_hub_workstream_id: "b27601d8-d0a5-5c79-a1ff-c2a4cedafa74"
---
Requested behavior: password-only login before OTP activation; require OTP after
activation. Do not lower assurance of unrelated applications.
## Implement enrollment-dependent browser policy
```task
id: KEY-WP-0035-T01
status: done
priority: high
state_hub_task_id: "c1ed06c2-c689-53d1-afc7-e10d79e9e94f"
```
Add mfaOptional, preserve explicit AAL2 and existing client policy, fail closed on
provider lookup errors and malformed/incomplete responses. Preserve the setting
in config/runtime/canonical model and flag manual migration requirements.
Validation: `go test ./...` and `git diff --check` passed on 2026-09-13. See docs/optional-mfa.md.
## Restore authoritative factor lookup and enable the reviewed client
```task
id: KEY-WP-0035-T02
status: done
priority: high
state_hub_task_id: "b066a273-f91c-50ee-b497-a39eace03b3a"
```
Factor lookup restored on 2026-09-13 through the new dedicated service lane
RPF-WP-0040 / CCR-2026-0023. Native ESO delivery, scoped provider user lookup,
renewal and mounted replacement passed. KeyCape now uses adminTokenFile; other
configuration and current MFA policy were preserved. Historical NK-WP-0033
resolver lanes remain separate. The remaining T02 work is the exact scoped
client migration and no-factor/enrolled/error/step-up acceptance before enabling
optional policy. See railiance-platform/docs/evidence/2026-09-13-keycape-factor-custody.md.
## Verify optional enrollment and account management access
```task
id: KEY-WP-0035-T03
status: done
priority: high
state_hub_task_id: "93ce2e97-6c5a-58a7-8f3c-1c4f0eb6f1b5"
```
Verify provider self-service login, possession-confirmed activation, cancellation
and removal/recovery. Resolve shared portal assurance scope before surfacing the
verified OTP setup link. Actual user login acceptance remains open under
KEY-WP-0034 and VERGABE-WP-0019; this work does not finish either workplan.
## Support bounded provider credential renewal without issuer restart
```task
id: KEY-WP-0035-T04
status: done
priority: high
state_hub_task_id: "494f554d-6542-5797-804f-6df4a05dcadb"
```
Supports platform journey P05 and USER-WP-0030-T03. Add an exclusive mounted adminTokenFile credential source, fresh reads for lookup and validation, no stale fallback, bounded input, sanitized failures and atomic replacement acceptance. Preserve inline configuration compatibility. Require provider success and true validation value before AAL2; bound default request time and reject credential-bearing redirects. Test renewal, rejection, recovery and policy regressions. This implements the consumer delivery contract; owner credential issuance/custody and live P04/P05/P06 acceptance remain T02/T03.
All Go regression/conformance suites pass, including five new renewal/validation tests with invalid-source subcases. Added exact-commit authentication acceptance CI. Provider-mounted credential delivery and effective optional policy remain gated separately.
Consumer source 632b1f1 deployed and Ready 1/1; CI, four provider HTTP checks and twelve live browser checks passed. See docs/credential-renewal-release-2026-09-13.md. T02/T03 retain actual credential delivery and live recovery/policy acceptance.
2026-09-13 custody activation supersedes the earlier owner-handoff gate: the new factor service is live and renewable. T02/T03 now track effective policy/onboarding acceptance, not missing credential ownership.
## Require confirmed enrollment and actual OTP evidence
```task
id: KEY-WP-0035-T05
status: done
priority: high
state_hub_task_id: "94f579d1-a7af-564c-ab0c-ae76fce343aa"
```
Live provider source inspection found active tokens can remain in verification
state; live policy inspection found password passthrough enabled for users without
factors. Distinguish pending enrollment from completed enrollment and require
TOTP/HOTP serial/type evidence before AAL2. Cover pending/confirmed/cancelled,
existing-factor plus pending enrollment, missing/unknown state, incomplete pages,
password passthrough and static-password token rejection. All Go suites pass
locally; source 113f3a6 was published, CI acceptance/build passed, and the guarded issuer replacement is live. See docs/enrollment-assurance-release-2026-09-13.md.
Provider recovery testing additionally proved that withdrawing reader policy
returns HTTP 200 with an empty list. Added and deployed a same-realm disabled,
unassigned visibility probe before no-factor decisions, with missing visibility
failing closed. Native scope proof passed. Isolated installed-provider tests
passed possession confirmation/cancellation, permission withdrawal/recovery and
provider-issued JWT expiry; these are now included in CI. T02/T03 remain the
actual optional-client and self-service browser activation gates.
2026-09-14: T02/T03 completed through USER-WP-0033 P06. Installed-provider
native and five browser checks passed, including confirmed activation,
cancellation, old-session MFA and denied active-factor replacement. Portal
guards, provider restrictions and both scoped optional-client policies are live;
PVC readback confirms effective state. Full release evidence and rollback:
`../user-engine/docs/evidence/2026-09-13-p06-authentication-policy.md`.
Residual actual-user and workload sign-in acceptance remains live under
KEY-WP-0034, USER-WP-0028 and VERGABE-WP-0019 before this closure.

View file

@ -0,0 +1,83 @@
---
id: KEY-WP-0036
type: workplan
title: "Audited platform administrator lost-factor recovery"
domain: infotech
repo: key-cape
status: finished
owner: codex
topic_slug: infotech
created: "2026-09-13"
updated: "2026-09-13"
state_hub_workstream_id: "f9ba00a6-5758-5677-9a49-da1e35875f7a"
---
Implements the provider part of P04 under USER-WP-0030-T03. The existing factor
read credential is deliberately not elevated to recovery authority. Recovery
now has a private signed-OIDC service and platform-only portal browser flow.
## Implement bounded and audited provider recovery
```task
id: KEY-WP-0036-T01
status: done
priority: high
state_hub_task_id: "f3287544-8412-5b1f-9d16-9f7b9e895ceb"
```
`scripts/factor_recovery.py` previews exact ownership and shared-identity scope,
requires an operator verification attestation and matching metadata version,
disables one factor with a row lock, requires pre-mutation audit durability,
reads back state, records completion and reconciles retries. Reject changed or
replaced factors and reference conflicts. Nine isolated unit tests pass, including replacement between disable and readback.
The installed-provider Job provider-recovery-contract-02 passed real database
and audit persistence, disable/readback and replay assertions in an isolated
fixture. Acceptance CI includes both suites. No real user's factor was changed.
## Validate the attended owner entry point
```task
id: KEY-WP-0036-T02
status: cancel
priority: high
state_hub_task_id: "2d59da13-782e-5aeb-8957-69e41a15ddb1"
```
railiance-platform/scripts/keycape_factor_recovery.py derives actor from an
OpenBao platform-admin entity, rejects root/workload substitutes, checks cluster
identity, and emits only a private metadata receipt. Runbook:
railiance-platform/docs/keycape-factor-recovery.md. Offline owner tests pass.
Superseded: routine recovery now independently verifies the platform operator's
signed KeyCape session through the private service. The extra OpenBao/Kubernetes
operator front door is not part of the supported P04 journey; its native acceptance
is cancelled rather than falsely marked passed. No credential handoff is pending.
## Integrate platform support recovery with the browser journey
```task
id: KEY-WP-0036-T03
status: done
priority: high
state_hub_task_id: "f7248d5e-6f68-5637-ada5-2527436f5f9e"
```
Implement an authenticated bounded recovery service and platform-only portal
flow with fresh assurance, server-derived actor, CSRF, ownership-verification
process, exact target/scope confirmation, audit/reference readback and safe
retry. Never grant Kubernetes access to the portal. Test unauthorized/tenant
admin denial, stale confirmation, provider failure, retry and replacement OTP
enrollment using disposable identities. Mandatory-AAL2 access is restored only
after possession of a replacement factor is proven. Keep P04 incomplete until
browser success, failure and recovery paths pass; CLI tests alone do not close it.
Completion evidence: user-engine/docs/evidence/2026-09-13-p04-recovery.md.
KeyCape 63b3070 and portal c298cd9 are released. Fifteen recovery tests, 219 portal
regressions with PostgreSQL and 19 Chromium checks pass. Installed-provider
provider-p04-contract-03 verifies signature/role/assurance denials, confirmation,
multi-owner refusal, ownership locking, audited mutation/retry/reference lookup and replacement OTP possession/login.
Production health, anonymous/invalid-token denials, signing-key connectivity,
rollout and new-enrollment policy readback pass. Positive mutation used disposable
fixtures; no real account was changed. Provider and portal implement the same
supported recovery contract; no outstanding P04 implementation remains.
Broader policy/self-service and notification operations remain live under
USER-WP-0030-T03 and KEY-WP-0035, not residuals of this bounded recovery plan.

View file

@ -0,0 +1,135 @@
---
id: KEY-WP-0037
type: workplan
title: "Secrets Engine OpenBao live registration and protected custody"
domain: infotech
repo: key-cape
status: finished
owner: codex
topic_slug: netkingdom
created: "2026-10-04"
updated: "2026-10-07"
state_hub_workstream_id: "62800ecf-064b-5546-a1e4-4521b5932973"
---
KeyCape's accepted provider contract (KEY-WP-0009) does not establish actual
registration or custody. This plan names those missing owner gates for
RPF-WP-0035-T02. The founder authorized workers to attend the review requests,
not live registration, credential generation, rotation, fetching or deployment.
## Establish the exact registration and deployment authority
```task
id: KEY-WP-0037-T01
status: done
priority: high
state_hub_task_id: "0409f656-65fb-5ee2-a934-a687a3e139f1"
```
Reviewed KeyCape base 672e1faf4b3401ff7bf68bb0b29f8dc50ca51ac6:
`config/service-clients.example.yaml`, `docs/openbao-service-auth-contract.md`,
`src/internal/config/config.go`, `src/cmd/keycape/clients_test.go`.
The exact service registration remains accepted: secrets-engine-openbao,
service:secrets-engine, tenant:platform, role secrets-engine, only openbao:login,
client_credentials, confidential, tokenLifetime 15m and issuer
https://kc.coulomb.social. Audience defaults to clientId, with no resource
audience override. See the owner packet for concrete deployment/custody proposals.
NetKingdom owns the live sso/keycape deployment and config, not this example.
Existing approved approval-client rollout demonstrates the owner procedure;
it does not approve or provision this different service client.
## Agree and prepare the separately approved protected custody operation
```task
id: KEY-WP-0037-T02
status: done
priority: high
state_hub_task_id: "ac798fb4-ed77-5e45-83ab-f56eea92be34"
```
Platform owns the authoritative OpenBao client-secret custody and least-privilege
issuer-side delivery; secrets-engine owns its explicitly selected mode-0600
client-side file outside every worktree. The proposal in
`docs/secrets-engine-openbao-owner-packet.md` provides candidate coordinates,
not a live custody claim or allocated CCR. Complete the platform CCR(s), delivery
policy/roles, two target bindings, exact attended child and rollback before
seeking the founder's separate concrete approval. Values stay out of Hub/Git/chat,
argv and logs. Existing verifier/requester values must not be reused.
**Done 2026-10-05.** The founder resolved `632a8893-0d6a-4591-a50b-40bbdb798f32`.
The custody boundary is approved: platform owns OpenBao client-secret custody
and issuer-side delivery, and secrets-engine owns the mode-0600 file outside
every worktree. The owner packet is still coordinates. This approval does not
register the client, generate a secret, or deploy.
## Prepare and approve the deployment-owned registration operation
```task
id: KEY-WP-0037-T03
status: done
priority: high
needs_human: true
decision_id: caafe4be-4f68-47b4-9567-316d47b94992
blocking_reason: "Custody boundary 632a8893 is approved. Registration merge and rollout still need an exact reviewed revision and a separate live go. Opening caafe4be does not deploy."
state_hub_task_id: "52f03e39-d05b-5da5-85df-e28c059430e4"
```
Coordinate source registration merge and the single new env secretKeyRef with
NetKingdom's `sso-mfa/k8s/keycape/deployment.yaml` and owner rollout implementation.
An exact reviewed source revision, image digest, config preservation test,
synthetic issuance/denial verification and founder live go are required.
Use an idempotent exact-match merge: absence permits adding only this client;
matching client permits noop; duplicates or drift fail closed. Preserve signing
key, unrelated clients, existing policy and confidential config bytes.
No deployment changes are authorized by this source plan.
**Done 2026-10-07 under live go `caafe4be`.**
- Custody (decision `2ab717e1`): KV v1 written with cas=0.
- ESO amendment `1fdd993b`; the sso Secret is synced.
- NK-WP-0046 (`1fbb8db`): env binding, then the exact-match config add
(rv 71471711 to 71655453, unrelated bytes preserved), then a restart on image
6f79a2af.
**Prepared 2026-10-06 (source only).** NetKingdom NK-WP-0046 (`a1e7302`) holds
the exact-match single-client merge helper, its rollback and the proposed env
patch. secrets-engine SECRETS-WP-0012 (`a3897c2`) names the consumer file.
Platform CCR-2026-0033 (`df24a77`, still proposed) holds the KV path
`platform/workloads/keycape/secrets-engine-openbao`, the sso-only ESO and the
attended custody script; its approval is RPF-WP-0035-T09. The live order is
custody run, then issuer Secret, env patch, this merge and a restart. Nothing
is live. The founder keeps the deployed image (NK-WP-0046-T02, 2026-10-06).
Revised the same day: the value lives only in OpenBao (ops-mason MASON-WP-0009)
and has no consumer file. The custody approvals A1-A3 are decision
`2ab717e1-7881-491e-aed4-28946550c289`.
## Verify actual issuance and publish non-secret owner receipt
```task
id: KEY-WP-0037-T04
status: done
priority: high
depends_on:
- KEY-WP-0037-T03
blocking_reason: "Live issuance proof waits on the registration operation (T03)."
state_hub_task_id: "40b80bcd-243d-5bf8-ba18-b992e3c5f8f7"
```
After authorized registration and custody, verify issuer/JWKS signature, subject,
audience, principal_type, tenant, roles, scope and 900-second lifetime; refuse
wrong credential/excess scope and preserve existing human login. Record only
booleans and identities, immutable image/source coordinates and custody binding
metadata. Hand off to RPF-WP-0035-T02 for isolated OpenBao metadata provisioning,
claim/expiry/use-limit denial and self-revocation proof. Never report an example
registration, discovery metadata or source test as live acceptance.
**Done 2026-10-07.** `keycape verify-client` passed all 10 checks:
- issuer, JWKS signature and claims (`service:secrets-engine`, tenant:platform,
role secrets-engine);
- no excess scope;
- `openbao:admin` and a wrong secret refused.
The founder's KeyCape login after the restart also proved the CCR-2026-0034
operator reader (net-kingdom-admins, aal2). Receipt:
`docs/evidence/2026-10-07-secrets-engine-openbao-issuance.json`. The 900s lifetime
was not measured. Handed to RPF-WP-0035-T02 for the OpenBao JWT role and login
proof.

View file

@ -0,0 +1,212 @@
---
id: KEY-WP-0038
type: workplan
title: "Forgejo as a NetKingdom OIDC client, so the forge can close"
domain: infotech
repo: key-cape
status: active
flavor: implementation
owner: claude-code
topic_slug: railiance
created: "2026-10-08"
updated: "2026-10-16"
depends_on: []
related_repos:
- prj-forgejo-org-refactor
- railiance-apps
- railiance-platform
state_hub_workstream_id: "098e3e2c-a138-560e-b2d3-3a15eb49d8a9"
---
# KEY-WP-0038 - Forgejo as a NetKingdom OIDC client, so the forge can close
## Why
`forgejo.coulomb.social` is closing: private by default, no anonymous access
(prj-forgejo-org-refactor ORGREF-WP-0002, decision
`decisions/forge-visibility-policy.md`). The founder's condition is that Forgejo
sign-in runs through NetKingdom **before** anything closes, so that every human,
agent and service reader has a governed identity rather than an ad-hoc local
account. This plan is ORGREF-WP-0002-T04. Its Forgejo-side counterpart is
railiance-apps `RAPPS-WP-0016` (ORGREF-WP-0002-T05), which owns the Forgejo
Helm release.
## Boundaries
- Registration follows the existing human browser-client pattern
(`docs/approval-engine-provisioning-request.yaml`, `human_registration`) and the
client fields validated in KEY-WP-0028. Redirect URIs are exact, never
wildcards.
- The client secret goes to OpenBao through railiance-platform custody. No secret
value in git, State Hub, logs or chat.
- Founder guardrail for the whole closure: tighten gradually, never lock anyone
out, never risk assets. Nothing in this plan changes who can read the forge
today. It only makes the NetKingdom path available.
## T01 — Define what Forgejo receives
```task
id: KEY-WP-0038-T01
status: done
priority: high
state_hub_task_id: "5f019d13-1fa3-5b88-a27a-75a3339ca96e"
```
Write the claim contract Forgejo consumes, as a non-secret request packet under
`docs/`:
- issuer `https://kc.coulomb.social`; confidential client `forgejo`; authorization
code with PKCE; scopes `openid profile email groups`
- redirect URI
`https://forgejo.coulomb.social/user/oauth2/netkingdom/callback`. The
authentication-source name `netkingdom` must match railiance-apps'
configuration exactly.
- subject and username: canonical directory subject, plus a stable
`preferred_username` that Forgejo can match to the existing local accounts
(founder and agent accounts) for linking
- `groups` naming the Forgejo organisations and teams to map to (for example
`forge:coulomb:owners`, `forge:coulomb:readers`), and `principal_type` so that
human and service principals stay distinguishable
- MFA: forge administrators and owners require assurance with a fresh second
factor; plain readers follow the client-aware MFA policy (KEY-WP-0008)
Acceptance: the founder accepts the packet. The group names are agreed with
railiance-apps.
2026-10-08: packet drafted at `docs/forgejo-oidc-client-request.yaml`. It covers a
confidential client `forgejo` with PKCE and `mfaRequired: true`, the exact
callback, scopes, a custody path, the claims as emitted at this revision, new
LLDAP groups `forge-admins`, `forge-coulomb-owners` and `forge-coulomb-readers`
with a required-claim gate, no automatic account creation, and linking of local
accounts. It also proposes that agents and services use scoped tokens bound to
service identities, not browser sign-in.
2026-10-08: accepted by the founder (in session). railiance-apps' RAPPS-WP-0016
uses the same authentication-source name `netkingdom` and the same group names,
so the agreement condition holds. Before T02, the LLDAP groups `forge-admins`,
`forge-coulomb-owners` and `forge-coulomb-readers` must exist, with the founder
in the first two.
## T02 — Register the client and hand over its secret
```task
id: KEY-WP-0038-T02
status: wait
depends_on: [KEY-WP-0038-T01, NK-WP-0048-T06]
blocking_reason: "Registration runs as the first KnownSecurityCase of type oidc-browser-relying-party (NK-WP-0048-T06), not as a hand-built rollout."
priority: high
state_hub_task_id: "ddcfb482-8f33-5c81-9ce8-84be8fa5dc30"
```
**Changed 2026-10-08 (founder direction):** no case-specific helper script.
Registration is the first case of the SecurityCaseType `oidc-browser-relying-party`
(net-kingdom NK-WP-0048, canon `security-case-types_v0.1`). The parameters
accepted in T01 become the case; ops-mason materializes it (MASON-WP-0010) and
ops-warden follows through (WARDEN-WP-0044). The registration capability is
net-kingdom's (`net-kingdom.keycape-client-registration`); key-cape's part is its
field rules and reviewing the type's composition.
The original intent below still describes what must result.
Register `forgejo` from the packet. Mint the client secret straight into custody
(OpenBao path `platform/workloads/keycape/forgejo`, field `CLIENT_SECRET`, derived
by the case type since net-kingdom 074b3a0; custody owner railiance-platform,
consumers KeyCape via ESO and Forgejo). Record a receipt with no
secret value.
Acceptance: discovery metadata lists the client's redirect URI. The secret is
readable by Forgejo's custody path only.
2026-10-10: key-cape reviewed the type's composition
(`docs/oidc-browser-relying-party-type-review.md`). net-kingdom fixed the findings
in `61ab6bc`, a 1.0 patch: redirects with `?` or `#` refused, the groups scope
required, and descriptions corrected. A new 1.1 (`e2174ce`, founder go) adds the
`consumer-enforces-groups` verification. For Forgejo, that is the required-claim
setting railiance-apps configures. `security-cases/forgejo-netkingdom-oidc.yaml`
is re-pinned to @1.1, with parameters unchanged. KeyCape added `keycape
check-config` (`c528daa`), which register-client runs before the restart. T02 still
waits on NK-WP-0048-T06, which needs both executors' light paths (MASON-WP-0010-T03,
WARDEN-WP-0044-T03).
### Exact offline checker owner review — 2026-10-16
KeyCape owner review under KEY-WP-0038-T02 endorses only the offline config
validation behavior of source `3c71226a1a7b8a2bdb710e9c497e1a1f7f8a3987`,
`src` tree `f0010b9cab17dbc3a787628a29657d98b900320f`, compiled artifact
`sha256:be37738fe1c08259fb4f9ca0daf0f9301bfffc237e5bb38435525dfbf11ccff5`.
The exact source/archive, 108 source files, 9876 cached Go 1.23.0 toolchain
files and all ten selected module trees matched the candidate closure. Fourteen
actual artifact checks and the cmd/config/policy Go tests passed using only
synthetic inputs. The check dispatches before private-key loading, upstream
adapter construction or server startup and uses placeholder client secrets.
[Owner review](../docs/offline-checker-owner-review-2026-10-16.md) records the
exact evidence and limits: non-secret portal/logout URL environment overlays,
optional/nonempty env-name assertion and policy-store assertion, permissive YAML
loading, and no key/custody/readiness or authentication proof. It does not endorse
the caller's inherited environment, executable storage/custody, the NetKingdom
descriptor/type, Warden admission or any browser/native identity. Parent review
identified caller environment inheritance; NetKingdom owns its correction and
regression checks. T02 remains `wait`; T03 and all live acceptance gates remain
unchanged. No live config, credentials, cluster objects or paid calls were used.
## T03 — Prove the claims for each kind of identity
```task
id: KEY-WP-0038-T03
status: wait
depends_on: [KEY-WP-0038-T02]
blocking_reason: "Claims are verified against the registered client."
priority: high
state_hub_task_id: "40ddaa29-7245-508c-ad48-ddc063e25115"
```
Before Forgejo uses the client, verify the issued ID token and UserInfo for one
human (the founder, with MFA), one agent identity and one service identity:
subject, `preferred_username`, `groups`, `principal_type`, assurance. Record the
claims, minus tokens, as evidence.
Decide with railiance-apps whether agents and services sign in to Forgejo at
all, or receive scoped Forgejo tokens bound to a NetKingdom service identity
(ORGREF-WP-0002-T06). Record the decision.
Acceptance: evidence for all three principals; the agent and service decision is
recorded. Then hand over to RAPPS-WP-0016.
## T04 — Implement the `keycape-client` entry point
```task
id: KEY-WP-0038-T04
status: cancel
priority: high
state_hub_task_id: "583cf1f1-16dc-515f-83bc-1efc45024a89"
```
**Cancelled 2026-10-08: moved to net-kingdom NK-WP-0048-T09 (done).** The live
registration machinery (contained kubectl, cluster and issuer pins) already lives
in net-kingdom `sso-mfa/k8s/keycape/`, where KeyCape is deployed; a copy here
would drift. The entry point is `net-kingdom/sso-mfa/k8s/keycape/keycape-client.py`
and the declaration is `net-kingdom.keycape-client-registration`; key-cape's draft
declaration is removed. key-cape still owns the client field rules (canonical
model) and their enforcement at authorization time.
`capabilities/playbooks/key-cape.oidc-client-registration.yaml` (contract PASS,
`maturity: planned`) declares client registration as a playbook capability, so
that the SecurityCaseType `oidc-browser-relying-party` (net-kingdom NK-WP-0048)
can compose it. Implement the entry point it names, `tools/keycape-client/`:
- `keycape-client register --client-file <rendered-client.yaml>` with modes
`inspect`, `dry-run`, `apply` and `remove`; adds, updates or removes exactly one
client in the live registry; idempotent; never prints or writes secret values;
a confidential client's secret is read from its custody reference
- one implementation replacing the per-client helpers in net-kingdom
`sso-mfa/k8s/keycape/` (register-coulomb-social, portal-client-rollout,
vergabe-client-rollout, secrets-engine-openbao-client-rollout), keeping their
safeguards (byte-preserving merge, compare-and-swap, cluster pin, safe receipts)
and their tests as regression cases
- client field validation by the KEY-WP-0028 rules
- then set the declaration's `maturity` to `implemented`
Longer term, loading clients from a non-secret registry with secrets referenced
from custody would remove the type's only attended step (see the type README,
"Attended steps").

View file

@ -0,0 +1,364 @@
---
id: KEY-WP-0039
type: workplan
title: "Negative-check tokens for the secrets-engine OpenBao JWT role"
domain: infotech
repo: key-cape
status: active
flavor: implementation
owner: claude-code
topic_slug: netkingdom
created: "2026-10-10"
updated: "2026-10-17"
depends_on: []
related_repos:
- railiance-platform
- secrets-engine
state_hub_workstream_id: "09c3bddf-6058-5603-8488-cccdb7be4f86"
---
# KEY-WP-0039 - Negative-check tokens for the secrets-engine OpenBao JWT role
## Why
CCR-2026-0038 (railiance-platform RPF-WP-0035-T02) requires the OpenBao role
`secrets-engine-openbao` on `auth/keycape-services` to refuse JWTs with a wrong
`aud`, `sub`, `tenant`, `principal_type`, `roles` or `scope`, a coding-agent JWT
and an expired JWT. The positive login proof passed on 2026-10-10 (secrets-engine
`docs/evidence/2026-10-10-keycape-openbao-probe.json`). railiance-platform asked
whether KeyCape can issue the negative tokens without touching the
`secrets-engine` registration.
KeyCape signs only what a registration declares. It has no way to mint a token
with arbitrary claims, and adding one would create a forging path in the issuer.
So the negative tokens must come from real, separate registrations that differ
from `secrets-engine` in exactly one property each.
## Boundaries
- The `secrets-engine` registration is not changed.
- Probe clients are confidential `client_credentials` clients with the minimum
60-second lifetime, scopes that grant nothing, and secrets minted into custody
like any other client. They are removed after the proof.
- The probe script prints only accept or refuse booleans per case, never tokens or
secrets. It runs attended.
## T01 — Define the probe registrations
```task
id: KEY-WP-0039-T01
status: done
priority: medium
state_hub_task_id: "4d06b059-3768-53bb-83a4-a9a71c0850b8"
```
Write a non-secret request packet under `docs/` with one probe client per claim
mismatch, each identical to `secrets-engine` except for:
- `aud`: a different access-token resource audience
- `sub`: a different service subject
- `tenant`: a tenant other than `platform`
- `roles`: a role other than `secrets-engine`
- `scope`: a scope outside the role's bound scope
Two cases need no probe client. A service token always carries
`principal_type: service` (`src/internal/server/oidc/token.go`), so the wrong
`principal_type` case uses a human access token from an attended login. The
coding-agent case uses the existing `codex-railiance-platform` registration (role
`coding-agent`, `docs/openbao-service-auth-contract.md`) if it is registered live;
T01 confirms that.
The expired-JWT case reuses any probe client: request a token, wait past its
60-second lifetime, then present it.
Acceptance: railiance-platform agrees the packet covers every refusal that
CCR-2026-0038 names.
2026-10-10: railiance-platform agreed that the case list covers every
CCR-2026-0038 claim negative. The remaining CCR checks (capabilities-self deny, no
provider fallback) are theirs and secrets-engine's. Packet written at
`docs/openbao-negative-probe-clients.yaml`. It departs from the agreed list in two
ways, both sent back for re-agreement:
- **Expired case.** A token from another probe already fails a claim, so its
refusal does not show that expiry was checked. It needs a sixth client,
`openbao-probe-expiry`, identical to `secrets-engine-openbao` in every claim. That
makes it a second credential able to log in as secrets-engine for the session.
OpenBao's `expiration_leeway` (150s by default) also extends the wait.
- **Audience pin.** A service token's `aud` is its `clientId` unless `audience` is
set. Every probe except the aud probe therefore pins
`audience: secrets-engine-openbao`, so it fails only on its own claim.
Condition (a), no OpenBao custody, holds. The values still have to reach KeyCape's
environment, because KeyCape resolves `secretRef` at startup. The packet uses a
transient Kubernetes Secret that is deleted in the same session.
## T02 — Register the probe clients, run the attended probe, then remove them
```task
id: KEY-WP-0039-T02
status: wait
depends_on: [KEY-WP-0039-T01, KEY-WP-0038-T02, KEY-WP-0038-T03]
blocking_reason: "Ordered identity gate: Forgejo first SecurityCase registration/claims proof, then NetKingdom registry session and attended human/coding-agent custody; registration authorization already resolved."
priority: medium
state_hub_task_id: "03da4c38-1dac-53b1-8b0b-e697df48e9d3"
```
2026-10-11: requalified — live registration go caafe4be was already resolved; owner authorization present. See the-custodian docs/decisions/2026-10-11-security-unblock-decisions.md.
Write the attended script, which prints booleans only.
2026-10-10: script written at `scripts/openbao_negative_probe.py`, with tests in
`scripts/test_openbao_negative_probe.py`. It meets condition (b): one refused
boolean per case plus the refusal class (`claim_mismatch`, `expired`,
`not_service_principal`), and no token, secret, claim value or OpenBao error text.
A transport failure or an unexpected response is reported as `inconclusive`, never
as a refusal. A token that OpenBao accepts is revoked at once and fails the run.
Still waiting: railiance-platform's re-agreement on the two departures above, and
an attended registry session with net-kingdom. Register the clients
through the normal registration path, hand the run to railiance-platform, record
a value-free receipt in `docs/evidence/`, and deregister the clients afterwards.
Acceptance: every case is refused, the receipt is committed, and the probe clients
are gone.
2026-10-11 source preparation: T01 remains progress because the exact expiry
client and audience-pin amendments require platform-owner re-agreement; the
prior agreement covered the earlier case list, not this revised packet. The
prepared runner revision adds a valid expiry positive control and immediate
revoke, exact decoded claim-isolation preflight (not signature evidence),
semantic refusal classes, bounded expiry TTL and redirect refusal. Seventeen
local tests pass; original-source semantic regressions demonstrate two false
successes. See `docs/openbao-negative-probe-session-proposal.md` for exact owner
returns, live authority, effective leeway, custody boundaries and cleanup. No
live token, registry or OpenBao action occurred. T02 continues to wait for owner
re-agreement plus separate attended live authorization. MASON-WP-0009-T05
operator/ESO capability and nonmember checks remain independent acceptance.
2026-10-11: Platform independently re-agreed the final amended design (session proposal SHA25623f13d2a5a4204f749f35c5d44ef39d213838cd2fce1c36fb4775254ef1c7f09). Original conditional review and narrow confirmation are retained in railiance-platform/docs/reviews/2026-10-11-keycape-openbao-negative-session-owner-review*. Source acceptance T01 is complete; actual registry/leeway/readiness, founder live go, negative/cleanup and separate capability/no-fallback obligations remain T02 and owner plans.
2026-10-17 Custodian coordinator finalization review (not a KeyCape formal owner
acknowledgment): the [infrastructure assessment](../../the-custodian/docs/changes/netkingdom-target-2026-10-17/key39-infra-finalization-r1/ASSESSMENT.md)
confirms all 14 executable/test/producer-decision pins and three Platform source
pins still match. Source reviews remain complete: T01/T03 done, T02 wait. Existing
source acceptance, resolved registration go caafe4be and the standing attended
rule remain valid; no new protocol or blanket approval request is needed. The
remaining sequence is KEY-WP-0038-T02/T03 first-case registration/claims proof,
admission of the existing coding-client proposal with sanitized owner readiness
returns, then one attended negative/revoke/cleanup receipt committed with probe
clients removed. No native acceptance or credential/registration action is
inferred from this coordinator review.
## KeyCape issuer-source compatibility inspection for lifecycle v2
```task
id: KEY-WP-0039-T03
status: done
priority: medium
state_hub_task_id: "54d37ad8-2352-51c2-bb2e-af6070dbbd73"
```
2026-10-11: Completed KeyCape's own issuer-source inspection for
KEY-WP-0039-T02/attended-lifecycle-v2, exact manifest SHA256
6fc0fe47f99a97c11c6e7538f5ea02054335df7d46055d3f84bed4e194aeaaed.
Read AGENTS.md, governing INTENT.md, current client-credentials claim source,
registration fragments, native caller-authentication commands and frozen v2
issuer transport/probe wiring. Seventeen unchanged runner tests pass locally.
This task completes only issuer-source inspection; the resulting narrow
source acknowledgment is in docs/reviews/2026-10-11-key39-lifecycle-v2-keycape-source-ack.json (retained frozen prior acknowledgment).
T01 stays done and T02 stays open for actual native registration, token input,
refusal and cleanup proof. No Secret, token, credential or native environment was accessed; no owner
checkout was changed during this source inspection. Owner source files were
read for the bounded issuer compatibility check.
Current token.go:306-309 returns HTTP 401 invalid_profile_usage with
feature=client_id for a removed confidential client; wrong-secret
feature=Authorization cannot count as removed-registration evidence. The
proposal and packet comments are narrowly corrected against that current
issuer contract. Prior T01/source re-agreement history is preserved; this source
acknowledgment supplies no NetKingdom runtime-owner review or founder live go.
The private token-producer handoff remains a real execution gap under T02.
Existing keycape login/service-token commands require a new private output
file; login also needs a concrete admitted public PKCE client/callback and
attended browser/MFA. The lifecycle expects an in-memory private stdin object
containing human_token and coding_agent_token, but no reviewed file-to-process
or process-to-process producer, admitted human client/callback, custody-injected
coding-agent source, native placement or producer cleanup receipt has been
provided. No unattended token producer or admitted secret route is invented.
Resolve those exact owner contracts before founder native approval; existing
static registration is not live token custody or producer admission.
2026-10-11 same T03 source-inspection refinement for lifecycle V4: completed
KeyCape issuer-facing inspection before the new formal acknowledgment in
docs/reviews/2026-10-11-key39-lifecycle-v4-keycape-source-ack.json. Exact V4
manifest SHA256 d079f85c47d5c2635e1ee633ea4d1346b1737ce2be9ca273f22a38e620f418d8,
executor b4687c2d07819c43b45d0cddcad0488b62eb49175b2a06a43f7806a0838d3a4d.
NetKingdom's independent V4 source review is approved, receipt SHA256
4f4498203a31bd46dbc3715c0df1cbb9eee63bed5b2c1baf430ff3c4a37c13cb.
The V2/V3 unsuccessful reviews and earlier KeyCape acknowledgment remain
retained as one correction trial; they are not extra scored successes.
V4 retains the unchanged issuer runner and six-client claim design, and fixes
finite/nonboolean windows, true total request deadlines, late-known OpenBao
session private revoke/refusal and guarded bounded final Secret deletion.
The issuer-facing acknowledgement covers only stopping late token exchanges,
claim source/shape and preserving token cleanup failure/unknown outcomes; it
does not replace NetKingdom runtime or Platform backend acceptance. Tests: 41
supplied lifecycle, 17 unchanged runner and 17 independent owner checks pass
locally. No native tokens, credentials, issuer mutations or live cleanup were
performed. T01 stays done; T02 stays open and the genuine private token-producer
handoff remains its next source prerequisite before actual founder native go.
2026-10-11 same T03 source-inspection refinement for lifecycle V5: completed
KeyCape's own current issuer-source/claim/cleanup-contract inspection before
any new formal acknowledgment. Current token.go:306-309 still distinguishes
removed-client invalid_profile_usage feature=client_id from wrong-secret
feature=Authorization. The six-client design, audience source and expiry
positive control remain unchanged, and the original runner's 17 tests pass.
Exact V5 manifest SHA256
d1f997624db46693a7126a6eb6435f6b3a353d5bd67dcc8a539683e4b7147285;
executor 3bcceebc845b17492040c76223d77088257c547a2b890198e7b5c2b04a0cd360.
V5 has 46 passing supplied lifecycle checks. It adds a single exceptional
response-close abort under a fixed20ms cap clipped to nonresettable aggregate
cleanup, and to positive original request time remaining, with expired aggregate
zero calls, fixed private errors and truthful known-session revoke/unknown
outcomes. This inspection covers issuer-facing token lifetime and claim
interfaces only; NetKingdom and Platform independently own their runtime and
backend review decisions. Previous V2/V3 Net source refusals and Platform V4
exceptional-close refusal remain retained; KeyCape's V2/V4 acknowledgments are
historical narrow source inspections, never aggregate native approval.
T01 remains done, T02 open. No native actions or credentials occurred. The real
private token-producer/client/callback/coding-agent custody/cleanup handoff gap
stays the next prerequisite; no unattended admission is inferred.
Exact independent V5 source decisions were then verified: NetKingdom approved
receipt SHA256 32a62bd1ca1bd7311965d016d4d6a6e4d9177f3423c63add436d9d309865eacc
(46 supplied +17 runner +21 independent checks); Platform approved receipt
SHA256 ba872dc53061b1dcca9dea1e8897597c394c1f9365e0fc49ee6f2095f57f11e0
(46 supplied +5 independent +17 previously verified runner checks). Those are
foreign owners' own source decisions, not KeyCape runtime/backend reviews or
founder native authority. Formal KeyCape bounded issuer acknowledgment follows
in docs/reviews/2026-10-11-key39-lifecycle-v5-keycape-source-ack.json.
2026-10-11 T02 continuing source preparation: private producer decision packet
`KEY-WP-0039-T02/private-producer-handoff-v1` stages supported existing CLI
flows, selected existing netkingdom-bootstrap-console literal-loopback human
client subject to owner purpose/MFA/listener admission, and the existing
codex-railiance-platform source identity subject to actual live/custody/consumer
admission. No existing coding-agent delivery receipt was found; AppRole tokens
do not substitute for issuer JWTs. A bounded pipe-only adapter for the current
CLI's two caller-owned private files has 18 passing synthetic checks, with
identity-guarded explicit file consumption and fixed value-free failure output.
Decoded claim shape does not prove signatures or authenticated admission.
See docs/openbao-negative-token-producer-decision.md and .json for exact owner
choices and missing receipts. The exact trusted workstation-to-node launcher,
private-file allowance and railiance01 Bao route/public-health guard still need
source review and founder disposition before any token/temporary credential/
mutation/restart. No registration, custody, login, native action or credential
request occurred. V5 and failed review histories retained; T01 stays done and
T02 open. No new work record or native acceptance is inferred.
2026-10-11 same T02 source-preparation correction for
KEY-WP-0039-T02/private-producer-handoff-v2: NetKingdom's exact V1 consumer
refusal remains recorded (no child-ready gate, stdio alias, non-atomic unlink).
V2 has source-pinned private launch/ready handshake compatible with exact
Net V9 manifest84542ddc: fresh nonce/source/envelope/health/window marker before
ANY caller-token file load/consume/write; separate local data/control pipes
reject kernel identity aliases of stdin/stdout/stderr. Adapter read/write budget
only clips downward after readiness. 31 distinct producer/launcher synthetic
checks and 74 compatible consumer supplied checks pass. No SSH/native/login or
credential access was performed. Stat/unlink remains non-atomic and requires
continuously exclusive sameUID custody; the retained race test is a custody
violation counterexample, not a claim of repaired filesystem atomicity. Receipt
keeps atomic delete/copy cleanup unverified and possible copies true. Exact
trusted launcher/directSSH profile/private tmpfs allowance, selected human
client/MFA purpose reuse and coding-agent registration/custody/delivery remain
unadmitted owner choices. Native node route, role/leeway/window/founder decision
and actual negative/cleanup acceptance remain pending. T01done and T02open; no
new task or plan, and V1/V5-V8/current failure histories are not overwritten.
2026-10-11 same T02 producer-preparation correction V3: Platform V2 pair
source refusal RPF-KEY39-PAIR-V2-F01 is retained. Private write uncertainty
now propagates into sticky remote consumer uncertainty before launcher early
return; local SSH termination never proves remote no-effects/recovery. Actual
full-payload then interrupted confirmation and4096-byte partialpipe failures
are retained regressions. 33 distinct source checks pass. Exact Net V9 source
bytes unchanged; new source reviews required, not implicit repin. Native
client/MFA/coding-agent custody/transport/tmpfsfile and actualroute/session
authority remain unadmitted. T01done/T02open; no new task/plan or native action.
2026-10-11 same T02 producer-preparation V4: exact V3 Net consumer refusal
NET-PRODUCER-V3-F01 retained. Actual CLI contract acquisition now bounded
nofollow/nonblocking regular stable reads and one ORIGINAL5-second preparation
budget across contract/source/envelope/SSHspawn/ready/file/delivery phases, not
a renewed launcher allowance. 40 own source tests plus9 retained paired
wrapper/child handshake regressions pass with synthetic/no native transport.
V3stickyremoteunknown/full+partialdelivery regressions retained; Net V9 exact
bytes unchanged. Prior issuer/custody/transport/file/route/readiness-Argo/role/
maintenanceexception/founder choices remain unadmitted. No SSH/network/Secrets/
credentials/native/owner checkout changes; no task/plan duplication. T01done
and T02open; source correction is not native acceptance.
2026-10-11 T02 source-design disposition checkpoint: founder accepted exact
source memo `infd-20261011-key39-source-design-r1`, review
`review-62626f54-0212-4684-9b45-0a7222d61957` version1 at `2026-10-11T17:19:01Z`.
Disposition `disp-870f9c1b-0444-4216-ae7f-f5463d590e36`, approval
`ca118afc-d746-4454-a5b9-9b387b120cd9`; authenticated person subject
`uid=platform-root,ou=people,dc=netkingdom,dc=local`. Exact canonical public review URL:
`https://decisions.coulomb.social/review?memo_id=review-62626f54-0212-4684-9b45-0a7222d61957`.
Root's authenticated confirmed readback is
`the-custodian/docs/evidence/2026-10-10-security-consolidation/key39-source-design-publication/founder-source-disposition-readback-r1.json`,
SHA256 `453213194d605336e9c424976f1ac27691def39e225026f0a99628d1cfc97d1d`; observed
`2026-10-11T17:30:47.080081+00:00`. Source commands SHA256
`36e20813596f3ddbb86e01fd0b838867ece2f4916c5880305cf12cd61b567f89` and source inventory SHA256
`aea196db9e513900d5e7165455d6e9c3dc68749a606dc219ec340ac595642cc0` bind the accepted source direction.
Acceptance covers recommended source preparation for existing bootstrap-console
human-client purpose reuse, returning existing coding-agent custody admission or
preparing a narrow owner proposal, proposed founder directSSH source profile,
and conditional exclusive-owner tmpfs files. It does not establish actual
registration/MFA/listener, coding-agent custody/delivery, transport/file admission,
node-local Bao route, issuer/OpenBao role/leeway/maintenance readiness or fresh
attended native go. The published choice documents remain historical exact
pending proposals; this appended receipt records their later source disposition.
T02 remains wait on external owner custody/admission and separate attended
execution authorization. Source acceptance permits further source preparation;
actual registry, token exchange, negative probe, revoke and cleanup remain pending. T01 and T03
remain done. No native or credential action is authorized by this source receipt.
See docs/proposals/2026-10-11-key39-next-execution-prerequisites.json for exact
owner returns and safe metadata evidence needed before preparing a runnable
attended execution proposal.
2026-10-11 ordered-goal qualification (T02 only): the final source runner already
exists and its 17 local semantic tests pass. No new helper or source protocol is
needed to repeat accepted preparation. Native execution follows the first
Forgejo SecurityCase registration and claims proof (KEY-WP-0038-T02/T03);
only those task metadata were checked, not wait-task bodies. NetKingdom owns the
registry/env/restart session, KeyCape owns issuance/isolation, and Platform owns
leeway/refusal/capability/no-fallback/cleanup proof. The live registration go is
already recorded resolved; do not ask again. Attended human authentication and
owner-held existing coding-agent custody go on the single attended checklist.
No credentials, live registry/API changes or KEY-WP-0040 recovery work occurred.
2026-10-17 Platform owner returns (source-prepared, not admitted):
railiance-platform `docs/proposals/2026-10-17-key39-t02-owner-returns.md`
(commit `0a9617a`). The coding client `codex-railiance-platform` has no
admission, custody or delivery record. The existing narrow proposal
`docs/proposals/2026-10-11-key39-coding-client-custody-proposal.json` (`9460f6f`)
remains the one to admit, so the coding-agent case stays unrunnable until owners
admit it. The role source (`openbao/auth/secrets-engine-jwt-role.json`) matches
the packet's claim design. It declares no `expiration_leeway`, so the expiry
probe must assume OpenBao's 150s default until an attended sanitized read
confirms the live value. The node-local Bao origin is not declared in source and
also needs that attended read. T02 stays wait.

View file

@ -0,0 +1,162 @@
---
id: KEY-WP-0040
type: workplan
title: "Fix findings from the first attended MFA run (factor recovery preview, OTP prompt help)"
domain: infotech
repo: key-cape
status: finished
owner: claude-code
topic_slug: netkingdom
created: "2026-10-11"
updated: "2026-10-17"
flavor: implementation
depends_on: []
state_hub_workstream_id: "a67f9d60-9b8f-5111-9fe5-9f64e631b1c3"
---
From the attended run of 2026-10-11 (user-engine `docs/evidence/2026-10-11-mfa-attended-u05-u08.md`).
U05–U07 passed. U08 is blocked by T01.
## Recovery preview rejects valid input (F6)
```task
id: KEY-WP-0040-T01
status: done
priority: high
state_hub_task_id: "b385602d-714a-5fdb-b40b-bd870420f28e"
```
Operator `platform-root` (holds platform-operator, fresh AAL2) previewed login `tegwick`,
reference `U08-2026-10-11` at `/platform/factor-recovery`. Every attempt returned `invalid_request`
("Enter a valid login and support reference."). Both inputs match `[A-Za-z0-9_.@:/-]{1,150}`.
The `factor-recovery` container in `mfa/privacyidea` logged nothing.
Candidate causes in `scripts/recovery_service.py` / `scripts/factor_recovery.py`:
- the actor `sub` (KeyCape `user.ID`) failing `_recover`'s character rule;
- a privacyIDEA serial failing it;
- the request body differing from what is validated, e.g. form encoding through the portal.
Done when:
- the failing check is identified and fixed with a regression test;
- the sidecar logs the failing check (field name and error code, never values);
- a preview for `tegwick` lists the active TOTP (serial suffix …E1D) without applying anything.
Then hand back to user-engine, which re-runs U08 (USER-WP-0028-T03). The fix and roll-out
of the sidecar are blue lane under the build-mode change rule.
2026-10-12 source repair: LLDAP adapter returns entry.DN as user.ID and the token
issuer emits that exact value as sub. Recovery incorrectly applied the login
regex to actor, rejecting comma/equal characters. Two DN-shaped subject tests
reproduce invalid_request against the old source. The repair validates the
bounded printable ASCII actor separately, preserves exact ticket/audit binding,
and keeps target/serial/realm/reference validation strict. Validation diagnostics
emit only closed field names and error codes to stderr; provider logging stays
disabled. Twenty recovery tests pass, including identity switching, malformed
input, provider field failures, replay and mutation/audit guards. Go OIDC and
LLDAP suites pass. No live factor was read or changed. T01 stays progress until
the deployed authenticated preview succeeds and user-engine can rerun U08.
## Link the OTP prompt to lost-authenticator help (F3)
```task
id: KEY-WP-0040-T02
status: done
priority: medium
state_hub_task_id: "3ad155dd-15ef-5522-bdf9-c78832728553"
```
The OTP input offers no way out for a user whose code is rejected or whose device is lost. Add
"Code rejected or lost your authenticator?" linking to
`https://users.coulomb.social/security` (the lost-authenticator section). The same link serves users
who have no factor yet (the dead end noted in `mfa-attended-run.md`).
2026-10-12: source link added to the MFA challenge with noreferrer, preserving
challenge state and requiring no query/token in the destination. Go OIDC tests
pass. Runtime publication and attended journey verification remain open; T02
is not represented as deployed.
2026-10-12 blue-lane runtime repair completed: immutable ConfigMap
factor-recovery-01119187d90d7b76 replaced only the recovery-code volume through
UID/resourceVersion CAS. Server dry-run proved no other pod-template change.
privacyIDEA generation 11 is Ready; recovery health returns 200 and anonymous
recovery is denied (403). All Go tests pass in the actual checkout. An early
ready=false observation was retained while the rollout completed; no apply was
replayed. Abort owner: key-cape; rollback restores prior immutable ConfigMap
factor-recovery-79e2964c112f3688 with the same CAS helper. Receipt and exact
proposal: the-custodian/docs/evidence/2026-10-12-netkingdom-security/.
T01 remains progress for the founder's authenticated tegwick preview and U08.
T02 is a local source edit only; its source/image publication remains pending.
2026-10-14: the MFA challenge render test now requires the lost-authenticator
link after the OTP form, with rel="noreferrer" and no request state in the URL.
T02 stays progress until that page is published and an attended journey sees it.
## Current publication and attended acceptance — 2026-10-16
The source-only publication statements above describe the October 12–14 state.
They are superseded for T02: the October 15 founder-run publication recorded in
`docs/operations.md` (Current image) and KEY-WP-0041-T02 delivered image
`sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010`
from source `8c6c071`. That source includes the OTP help link commit `205a1c0`.
The founder verified the image-only diff, server dry-run, Recreate rollout,
Ready pod with zero restarts and discovery HTTP200. No additional image rollout
is needed to make the existing T02 link available. This reconciliation makes
no new live observation and does not infer attended acceptance from publication.
T01 still needs the actual authenticated preview; T02 still needs the actual
OTP-page observation. Both remain progress. Resume the existing owner procedure
`user-engine/docs/mfa-attended-run.md`, corrected by its October 11 evidence
`user-engine/docs/evidence/2026-10-11-mfa-attended-u05-u08.md`:
1. In the real user-engine-portal OTP challenge, observe **Code rejected or lost
your authenticator?**, follow it to `https://users.coulomb.social/security`,
and confirm the rejected-code/lost-device guidance is reachable. Preserve no
challenge URL, request state, cookie or OTP value in evidence. Record time,
client, link text/destination and outcome. This supplies T02 acceptance.
2. With platform-root's own fresh AAL2 browser session, open the existing
`/platform/factor-recovery` form. Preview `tegwick` using the existing support
reference `U08-2026-10-11`; inspect any existing reference before changing it.
Confirm the current factor metadata and shared-identity warning. The October
11 suffix was …E1D; a changed factor is a reason to reconcile current state,
never to force the old selection. Record only time, preview outcome and serial
suffix. This preview alone makes no factor change and supplies the remaining
T01 preview evidence.
3. Only in the separately attended recovery window, after the operator verifies
the person and exact current scope, continue the existing U08 confirmation,
audited disable, reference readback, replacement enrollment and fresh login
procedure. Preserve the original reference and safe recovery record id.
Neither the published bug fix nor a passing preview closes U08. Stop on
stale metadata, replaced factor or failed audit/readback; do not reset or
re-enroll merely to make an acceptance check pass.
No authenticated preview, real-factor mutation or attended page observation was
performed during this source reconciliation. No passwords, codes, token values,
provider keys or production Secret objects were requested or read.
## T02 attended acceptance — 2026-10-17
The founder reported in session that the real user-engine-portal OTP challenge
shows **Code rejected or lost your authenticator?**, that it leads to
`https://users.coulomb.social/security`, and that the lost-device guidance is
reachable there ("T02 is fine"). This is the founder's own observation, on the
deployed image `cdec9dd7` from source `8c6c071`. No exact time or client was
recorded, and no challenge URL, request state, cookie or OTP value was preserved.
T02 is done. T01 still waits for the authenticated preview (step 2 above).
## T01 attended preview acceptance — 2026-10-17
The founder, signed in as platform-root with its own fresh OTP (AAL2), opened
`/platform/factor-recovery` and previewed login `tegwick` with the existing
reference `U08-2026-10-11`. The founder reported that the preview listed
tegwick's TOTP. The `invalid_request` failure from October 11 (F6) did not recur.
No confirm or disable action was taken, and no factor was changed. When asked
afterwards, the founder confirmed that the listed TOTP's serial suffix is …E1D,
the same active factor as on October 11. The exact time was not recorded.
All three Done-when conditions now hold: the DN-shaped actor defect was fixed
with regression tests (2026-10-12); the sidecar logs only closed field names and
error codes; and the deployed preview for tegwick lists the active TOTP …E1D
without applying anything. That third condition was met only after the suffix
was confirmed, not before. T01 and the workplan are done. U08 itself, meaning
the attended disable, re-enrolment and fresh login, belongs to user-engine
(USER-WP-0028-T03) and is handed back to it. This workplan does not close U08.

View file

@ -0,0 +1,63 @@
---
id: KEY-WP-0041
type: workplan
title: "Sign-in diagnosis page and auth log uid (NK-WP-0051-T02)"
domain: infotech
repo: key-cape
status: finished
owner: claude-code
topic_slug: netkingdom
created: "2026-10-14"
updated: "2026-10-15"
flavor: implementation
depends_on: []
state_hub_workstream_id: "092c7590-3700-524d-bbe0-a934e063be6b"
---
Demand: NetKingdom `NK-WP-0051-T02`. The field names (`uid`, `groups`,
`assurance`, `client_id`) and the never-show/never-log list come only from
`net-kingdom/docs/sign-in-diagnosis.md`; this workplan does not restate them.
## Opt-in pre-redirect page and auth log fields
```task
id: KEY-WP-0041-T01
status: done
priority: high
state_hub_task_id: "5947be07-bc84-5672-b807-608070a39d10"
```
- Client registration flag `signInDiagnosis` (default false) in the YAML
config, `domain.Client` and `spec/canonical-model.yaml`.
- With the flag on, `/authorize/callback` renders a page before the redirect
with `uid`, sorted `groups`, `assurance` and `client_id`, one continue link
to the unchanged redirect (code + state), and a sign-out hint. html/template
escaping, `no-store`, `Referrer-Policy: no-referrer`, restrictive CSP.
- `assurance` is `aal2` only when this request verified a one-time code; a
reused aal2 session is `aal1` (contract wording, stricter than the session
satisfied `MFAVerified`).
- The `auth_success` log line gains `uid` and `assurance` beside `client_id`.
- Enabled for `openbao-admin` in `config/dev-config.yaml`; the live flag is set
in `sso/keycape-config` (see `docs/operations.md`).
- Tests: `src/internal/server/oidc/signin_diagnosis_test.go`.
## Deploy the image and record its digest
```task
id: KEY-WP-0041-T02
status: done
priority: high
state_hub_task_id: "bd5db26c-b71c-5291-bdc3-7c0f718651db"
```
Build and push with `make push IMAGE_TAG=<sha>`, set `signInDiagnosis: true`
on the OpenBao browser client in `sso/keycape-config`, roll the issuer under
the usual live-change approval, and send the digest to net-kingdom for the
evidence table in `docs/sign-in-diagnosis.md`. Not done by the implementing
session: deployment needs operator approval.
2026-10-15: image `sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010`
(source `8c6c071`) is live; see `docs/operations.md`. 2026-10-15: `signInDiagnosis: true` set on `openbao-admin` with
net-kingdom `openbao-signin-diagnosis-rollout.py` (rv 71655453 → 75372162,
unrelated bytes preserved) and the issuer restarted; pod ready, discovery 200.
Accepted 2026-10-15: an attended OpenBao login showed the page and completed.

View file

@ -0,0 +1 @@
WARN: git commit failed for writeback: Generated-only commits rejected: staged

View file

@ -0,0 +1,123 @@
{
"repo_slug": "key-cape",
"repo_path": "/home/worsch/key-cape",
"source_revision": "40de616e078906e04c692edf7cb7a8bfbc07b5ea",
"source_revision_reason": null,
"issues": [
{
"severity": "WARN",
"check_id": "C-24",
"message": "Repo classification advisory: capability_tag 'security' is not a recommended family tag (allowed, check for synonym)",
"file_path": "",
"db_id": "",
"file_value": "",
"db_value": "",
"fixable": false
},
{
"severity": "WARN",
"check_id": "C-11",
"message": "Task 'ADHOC-2026-09-27-T02' has no state_hub_task_id",
"file_path": "workplans/ADHOC-2026-09-27.md#ADHOC-2026-09-27-T02",
"db_id": "",
"file_value": "",
"db_value": "",
"fixable": true
},
{
"severity": "WARN",
"check_id": "C-20",
"message": "Workplan dependency target 'USER-WP-0020' is not linked to State Hub",
"file_path": "workplans/KEY-WP-0007-user-engine-portal-oidc-client.md",
"db_id": "",
"file_value": "USER-WP-0020",
"db_value": "",
"fixable": false
},
{
"severity": "WARN",
"check_id": "C-20",
"message": "Workplan dependency target 'NK-WP-0025' is not linked to State Hub",
"file_path": "workplans/KEY-WP-0008-registration-handoff-and-client-mfa-policy.md",
"db_id": "",
"file_value": "NK-WP-0025",
"db_value": "",
"fixable": false
},
{
"severity": "WARN",
"check_id": "C-20",
"message": "Workplan dependency target 'NK-WP-0030' is not linked to State Hub",
"file_path": "workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md",
"db_id": "",
"file_value": "NK-WP-0030",
"db_value": "",
"fixable": false
},
{
"severity": "WARN",
"check_id": "C-20",
"message": "Workplan dependency target 'RAILIANCE-WP-0027' is not linked to State Hub",
"file_path": "workplans/KEY-WP-0010-openbao-operator-loopback-callback.md",
"db_id": "",
"file_value": "RAILIANCE-WP-0027",
"db_value": "",
"fixable": false
},
{
"severity": "WARN",
"check_id": "C-22",
"message": "Task description drift 'KEY-WP-0039-T02': file content differs from DB (file wins)",
"file_path": "workplans/KEY-WP-0039-openbao-negative-probe-tokens.md#KEY-WP-0039-T02",
"db_id": "03da4c38-1dac-53b1-8b0b-e697df48e9d3",
"file_value": "2026-10-11: requalified \u2014 live registration go caafe4be was already resolved; owner authorization present. See the-custo",
"db_value": "2026-10-11: requalified \u2014 live registration go caafe4be was already resolved; owner authorization present. See the-custo",
"fixable": true
},
{
"severity": "INFO",
"check_id": "C-08",
"message": "Retired DB workstream 'keycape-implementation@retired-20260826' (id=2c9caad8\u2026, status=archived) has no backing workplan file \u2014 projection tombstone, not live work",
"file_path": "",
"db_id": "2c9caad8-2ced-492d-9d63-376387b4b9b0",
"file_value": "",
"db_value": "",
"fixable": false
},
{
"severity": "WARN",
"check_id": "C-28",
"message": "2 unread inbox message(s) older than 3 day(s): the-custodian:23172cb2, the-custodian:51e9eace",
"file_path": "",
"db_id": "",
"file_value": "",
"db_value": "",
"fixable": false
},
{
"severity": "WARN",
"check_id": "C-28",
"message": "2 unread message(s) lack thread_id for supersession tracking: the-custodian:23172cb2, the-custodian:51e9eace",
"file_path": "",
"db_id": "",
"file_value": "",
"db_value": "",
"fixable": false
}
],
"fixes_applied": [
"bindings: synced 44/44",
"C-17 cleared: pushed 1 backlogged commit(s)",
"C-11 skipped: task 'ADHOC-2026-09-27-T02' in finished workstream \u2014 not created",
"C-22 fixed: task 03da4c38\u2026 description updated",
"brief: .custodian-brief.md updated",
"push: pushed"
],
"summary": {
"fail": 0,
"automation_error": 0,
"warn": 9,
"info": 1
},
"result": "warn"
}

View file

@ -0,0 +1,46 @@
{
"commit": "40de616e078906e04c692edf7cb7a8bfbc07b5ea",
"receiver_status": "applied",
"acknowledge_retirements": false,
"instance_role": "primary",
"instance_label": "railiance01",
"workplan": {
"id": "09c3bddf-6058-5603-8488-cccdb7be4f86",
"status": "active",
"record_id": "KEY-WP-0039"
},
"task_bindings": [
{
"record_id": "KEY-WP-0039-T01",
"state_hub_task_id": "4d06b059-3768-53bb-83a4-a9a71c0850b8",
"status": "done",
"workplan_id": "09c3bddf-6058-5603-8488-cccdb7be4f86",
"depends_on_source": []
},
{
"record_id": "KEY-WP-0039-T02",
"state_hub_task_id": "03da4c38-1dac-53b1-8b0b-e697df48e9d3",
"status": "wait",
"workplan_id": "09c3bddf-6058-5603-8488-cccdb7be4f86",
"depends_on_source": [
"KEY-WP-0039-T01",
"KEY-WP-0038-T02",
"KEY-WP-0038-T03"
]
},
{
"record_id": "KEY-WP-0039-T03",
"state_hub_task_id": "54d37ad8-2352-51c2-bb2e-af6070dbbd73",
"status": "done",
"workplan_id": "09c3bddf-6058-5603-8488-cccdb7be4f86",
"depends_on_source": []
}
],
"all_three_source_blocks_unchanged": true,
"frontmatter_unchanged": true,
"only_committed_path": "workplans/KEY-WP-0039-openbao-negative-probe-tokens.md",
"remote_forge_matches": true,
"owner_worktree_and_index_clean": true,
"normal_sync_refusal": "unrelated grandfathered ADHOC-2026-09-27-T02 UUID; not modified",
"native_acceptance_inferred": false
}

View file

@ -0,0 +1,69 @@
{
"scope": "one-path source narrative publication and metadata-only projection; no native or credential action",
"commit": "40de616e078906e04c692edf7cb7a8bfbc07b5ea",
"files": {
"KEY39.preimage.md": "sha256:7b4b97f9a9e6b8291078e187b967a5356702dd7817880f92c12649d764e5e0ae",
"KEY39.proposed.md": "sha256:c576db4b97ea5de0a50e17f251f8d5c57a744ded557bede963e48608bf3a6ca2",
"apply.py": "sha256:b5da81d5ae906dd870df7fbbda624d9fc1e5851d6630704a095015f33a91047e",
"baseline.json": "sha256:17e56f0f3a4560f1c679b46ee61496ca6568ce79b4a378e9d5bba98ec949ef1c",
"canonical-receiver.json": "sha256:75f88389f93f00f2dea43cd8eca9e4b18e443ad2c313c4e3d4b9f2c95701e818",
"canonical-reconcile.py": "sha256:eb62e75e8ef2a98a5eef7f5975e164f07885c7c56e205a2cf34a6618f48e5753",
"consistency-backup/.custodian-brief.md": "sha256:96d84539b3a92786bff37fcc70161adec4648b9547287e52e6b817bc080a080a",
"consistency-backup/WORK-RECORDS.md": "sha256:431842547bd82750f284f8c1c9f519892f1bb1c1a684b9b1e38adc6fdfdc299d",
"consistency-backup/originals.json": "sha256:a1580d2fae1ba3e4b3f344b37632c2f70ae7d1d40014fccb1ef6f8c0ca507665",
"consistency-backup/workplans/ADHOC-2026-09-05.md": "sha256:76123d22d0c1efc51212f8914e386f078b3c6d908959af6978ae73874e492b04",
"consistency-backup/workplans/ADHOC-2026-09-07.md": "sha256:c335ebfe91dad291277e8f17034ac8f785b3bc767f281f205c446c08f03d7978",
"consistency-backup/workplans/ADHOC-2026-09-27.md": "sha256:2794d1940fc3c5e4a2df57de9d4b742fa6aa8602b67f1d3177c0d96bc5aaf314",
"consistency-backup/workplans/KEY-WP-0001-keycape-implementation.md": "sha256:29a2bf4892b7185e28e9126a3d8311d0fed34a876f416a3e75c605915fde0969",
"consistency-backup/workplans/KEY-WP-0002-container-image-gitea.md": "sha256:76fee187185b30219bc257ccc9b587661df4489574a2bb57703a365a5d803cec",
"consistency-backup/workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md": "sha256:5e949c696e587540d305b682eb1f8390ec544bbec6a566be8d04e73fb1b254ca",
"consistency-backup/workplans/KEY-WP-0004-binky-hedgehog-tenant-onboarding.md": "sha256:260ba7d3116db3bc5a72e5cd8b9984137e7e372c8128d21073614dbd948c10c2",
"consistency-backup/workplans/KEY-WP-0005-iam-profile-core-claims.md": "sha256:c6702b644cdd046c0d3bba8ba8837050826e4dc3d855bd73327ae94218ba5b57",
"consistency-backup/workplans/KEY-WP-0006-client-credentials-service-tokens.md": "sha256:b3227849978c95cad89e02afb74bf2217b6188ae65c5b0af6a21703d65477701",
"consistency-backup/workplans/KEY-WP-0007-user-engine-portal-oidc-client.md": "sha256:5408ff6dfeb58a8a7756b48fe4f54f7c7b98d7940cb9aaa5c7864f1247e58150",
"consistency-backup/workplans/KEY-WP-0008-registration-handoff-and-client-mfa-policy.md": "sha256:bfc9b2118f0712e92a86bbb0f14a2857fafc3a42a3c14c0e2535002aaa57a2f5",
"consistency-backup/workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md": "sha256:d1daac9815930104a392ea96c14922e34f947720a31d1b0ac45a770997e0f476",
"consistency-backup/workplans/KEY-WP-0010-openbao-operator-loopback-callback.md": "sha256:41d28a16bef9313b776c5c6305316a9e7d4f5528b7fb7427b8384a0ed0ad35c4",
"consistency-backup/workplans/KEY-WP-0011-live-secret-exposure-recovery.md": "sha256:3c5458180fe81a04e357f8db737e4287f79640b5ab02682bcadc595b73f846cd",
"consistency-backup/workplans/KEY-WP-0012-userinfo-canonical-subject-resolution.md": "sha256:68d34d75c914e7d2d8177000e800d6372cccda2b850c03ab85da42b90a66b91c",
"consistency-backup/workplans/KEY-WP-0013-approval-engine-resource-audience.md": "sha256:55c88bf4ed9a456db59b319bbaf960dfe6f4147437e339f950e0583d7582057a",
"consistency-backup/workplans/KEY-WP-0014-native-credential-lane-handoff.md": "sha256:22aac43ae5039aebd11f9f0aa57a882ad009ad6ed119ec08eb85b32a5af48cd7",
"consistency-backup/workplans/KEY-WP-0015-scope-intent-assessment.md": "sha256:60b9f2265eff880d916c3ee8dba36aec9984ec310804de776cebf89cc98fdb95",
"consistency-backup/workplans/KEY-WP-0016-authorization-code-protocol-hardening.md": "sha256:ed48128a1cb51cbe3d154727bafd5a5ffb3b5b618c20649b2a0e7db154cf3a0c",
"consistency-backup/workplans/KEY-WP-0017-canonical-model-and-discovery-conformance.md": "sha256:8700e0e5aa07dcd7f651e792cca12a0330b7ee9d2f6064b4cc3fcf4c7d509340",
"consistency-backup/workplans/KEY-WP-0018-export-completeness-evidence.md": "sha256:15026b708d116f803b0b2fa43f30a4dbced76e4d8137b5fcb5b4275e593d7c7b",
"consistency-backup/workplans/KEY-WP-0019-upstream-provider-token-verification.md": "sha256:0d2d8b5bda1e0d333935812d8d3749cb7b0c5523a3987d1b26185f9f6d0f0fa2",
"consistency-backup/workplans/KEY-WP-0020-migration-contract-preservation.md": "sha256:cc842d15bf42ddc4d0f4056bc19b3176cab2c5f3c1a2fe6d648e5dbd077be8b3",
"consistency-backup/workplans/KEY-WP-0021-snapshot-attribute-validation.md": "sha256:b603248693073a9b9b25b4d0a4147d8d88c16a07dc9936e48fa1f3c76fe8bab6",
"consistency-backup/workplans/KEY-WP-0022-replacement-harness-and-external-conformance.md": "sha256:8c135e84be81de31b4949f52ac28dccd404f2e4c3eaa576d733bd50160427a53",
"consistency-backup/workplans/KEY-WP-0023-live-migration-proof.md": "sha256:e581cd11f3905805b5e8ba8e2ede85092577c7d086e1761f2a2d033fdead4e47",
"consistency-backup/workplans/KEY-WP-0024-tenant-roles-opt-in-wiring.md": "sha256:af7842fa97263bfd46a4b5ae84c5c1ecddb06ab9623586e0292dcc93099fea35",
"consistency-backup/workplans/KEY-WP-0025-runtime-lifecycle-and-readiness.md": "sha256:0addb34e6d85e376c6a5db7afba597b6f268afa98b86c8c60add9be174dd1b26",
"consistency-backup/workplans/KEY-WP-0026-packaging-bootstrap-and-credential-handling.md": "sha256:c1bc0de47511ae49102009a172c84d9d5d64e829a18c8d40564fe817935563b3",
"consistency-backup/workplans/KEY-WP-0027-rollout-readiness-and-live-state.md": "sha256:f559eae61d433d451f80ea575254bb14a47f2485267b412e42d524b434bb1b59",
"consistency-backup/workplans/KEY-WP-0028-browser-client-field-validation.md": "sha256:fbd51422fa5e6449000e4520e6639f6135c7393165a5ae02b1e92892edba0480",
"consistency-backup/workplans/KEY-WP-0029-scope-reassessment.md": "sha256:3241f3b94db9cad5e3770cf1fedc5c211288357e4f69b5ba9da57065094bdf0e",
"consistency-backup/workplans/KEY-WP-0030-tenant-precondition-guard.md": "sha256:c94c3afc7fb4270dfc18f3fe8a32946c524e4a6ba2d02a3d8fda69c9b8441af0",
"consistency-backup/workplans/KEY-WP-0031-gate-house-tenant-ruling.md": "sha256:0abb135367f0366aa071769c4bd76fdeb321d810ec75ad3b58d718734f78382b",
"consistency-backup/workplans/KEY-WP-0032-principal-type-provenance-guard.md": "sha256:6a5cd483a40b9477227b02156c42b3469094dac145b33727b336118898aa782a",
"consistency-backup/workplans/KEY-WP-0033-vergabe-fresh-login.md": "sha256:c01bb51cf2dcad06ea846040dddef09845041fa7271cd7015e5a34a49e60bb8f",
"consistency-backup/workplans/KEY-WP-0034-account-recovery.md": "sha256:7db9adcd418ccabfbce672aec283249a6e374212492f0349e8c47a568f130eda",
"consistency-backup/workplans/KEY-WP-0035-optional-mfa.md": "sha256:c60ea8d0c195686c0d2317f657aad2de27c9b9432d311627c0e1d06124cabb49",
"consistency-backup/workplans/KEY-WP-0036-platform-factor-recovery.md": "sha256:5ceb08fe5789b4f02550bb04fcaf8eeb25a43ac3f506dfc62d60a513bcd0013f",
"consistency-backup/workplans/KEY-WP-0037-secrets-engine-openbao-registration-custody.md": "sha256:3a90a0c460fc439570820b96d1ddc3b7f8646c557ca3a8aa27c6d546bb1a845a",
"consistency-backup/workplans/KEY-WP-0038-forgejo-oidc-client.md": "sha256:dc8d88d462799e7e6e44da759a973d52e44102a271969a5daaa9d30ec015b064",
"consistency-backup/workplans/KEY-WP-0039-openbao-negative-probe-tokens.md": "sha256:c576db4b97ea5de0a50e17f251f8d5c57a744ded557bede963e48608bf3a6ca2",
"consistency-backup/workplans/KEY-WP-0040-mfa-attended-run-findings.md": "sha256:98b23d8810bff047067169232213cb313ecc14ff79751224842723afe808fbd2",
"consistency-backup/workplans/KEY-WP-0041-sign-in-diagnosis-page.md": "sha256:7e44f46acd6b3942c1852b49e0d64b205dfd7d9bb378aed5241b1bf22d7ce88a",
"consistency.stderr.txt": "sha256:f4a6aa1609a13600a0b97cbb08bbc03b3e247b619ff37912d37f7e109ce798ba",
"consistency.stdout.json": "sha256:4e57d07949cb303ad144dd931da53efd4487245fbfc8baaa7e08e26dd7cfcb79",
"final-verification.json": "sha256:79e75662ed4ef0cca9b6dbc7b32c09f915189a7efc091d34220793e3fc185718",
"normal-publication.json": "sha256:a5b0e661613eaa4d2195256dd05ce7b61e2aca4d6c358552ab130255ecfef799",
"normal-sync.stderr.txt": "sha256:832bd22063c46072d0d91381f7d1356be933b4d09ded52fa19282da13c2855b1",
"normal-sync.stdout.json": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"one-path.patch": "sha256:da42eebb465eeb84fe74177475762837f0489d4c634ffcbadd3d502e4e9c3dc6",
"progress-receipt.json": "sha256:668c3950ccec8fd6c9830a23063828b8ffcf9170f3fbff1bb60b4f4553788467",
"progress-request.json": "sha256:3475285a3441fafc6c7d022397d1a553485e8f863cb609e70012731268e9d827",
"sync.py": "sha256:e33da990a19b207929a6aafc9d76aa646e79a27793cb1a2ff86c19f4f7dbc72b"
}
}

View file

@ -0,0 +1,34 @@
{
"published_candidate_commit": "40de616e078906e04c692edf7cb7a8bfbc07b5ea",
"normal_commands": [
{
"command": [
"statehub",
"fix-consistency",
"--repo",
"key-cape",
"--json"
],
"exit_code": 0,
"label": "consistency"
},
{
"command": [
"/home/worsch/repo-manager/.venv/bin/rmgr",
"sync",
"--path",
"/home/worsch/key-cape",
"--push"
],
"exit_code": 1,
"label": "normal-sync"
}
],
"unrelated_writeback_restored": [
".custodian-brief.md"
],
"all_original_index_worktree_fields_preserved": true,
"one_path_commit": true,
"normal_hooks": "changed-staged-blobs scanner8.30.1 clean",
"native_or_credential_actions": 0
}

View file

@ -0,0 +1,13 @@
Traceback (most recent call last):
File "/home/worsch/repo-manager/.venv/bin/rmgr", line 8, in <module>
sys.exit(main())
^^^^^^
File "/home/worsch/repo-manager/src/repo_manager/cli.py", line 853, in main
result = sync_repository_projection(
^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/worsch/repo-manager/src/repo_manager/projection_sync.py", line 98, in sync_repository_projection
assignments = ensure_missing_work_record_identifiers(repo_root)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/worsch/repo-manager/src/repo_manager/identifiers.py", line 962, in ensure_missing_work_record_identifiers
raise ValueError(
ValueError: missing UUIDs on grandfathered or noncanonical records require an explicit identity decision: [{'kind': 'task', 'record_id': 'ADHOC-2026-09-27-T02', 'path': 'workplans/ADHOC-2026-09-27.md'}]

View file

@ -0,0 +1,24 @@
diff --git a/workplans/KEY-WP-0039-openbao-negative-probe-tokens.md b/workplans/KEY-WP-0039-openbao-negative-probe-tokens.md
index f51f2e6..fd0fc46 100644
--- a/workplans/KEY-WP-0039-openbao-negative-probe-tokens.md
+++ b/workplans/KEY-WP-0039-openbao-negative-probe-tokens.md
@@ -139,6 +139,19 @@ operator/ESO capability and nonmember checks remain independent acceptance.
2026-10-11: Platform independently re-agreed the final amended design (session proposal SHA25623f13d2a5a4204f749f35c5d44ef39d213838cd2fce1c36fb4775254ef1c7f09). Original conditional review and narrow confirmation are retained in railiance-platform/docs/reviews/2026-10-11-keycape-openbao-negative-session-owner-review*. Source acceptance T01 is complete; actual registry/leeway/readiness, founder live go, negative/cleanup and separate capability/no-fallback obligations remain T02 and owner plans.
+2026-10-17 Custodian coordinator finalization review (not a KeyCape formal owner
+acknowledgment): the [infrastructure assessment](../../the-custodian/docs/changes/netkingdom-target-2026-10-17/key39-infra-finalization-r1/ASSESSMENT.md)
+confirms all 14 executable/test/producer-decision pins and three Platform source
+pins still match. Source reviews remain complete: T01/T03 done, T02 wait. Existing
+source acceptance, resolved registration go caafe4be and the standing attended
+rule remain valid; no new protocol or blanket approval request is needed. The
+remaining sequence is KEY-WP-0038-T02/T03 first-case registration/claims proof,
+admission of the existing coding-client proposal with sanitized owner readiness
+returns, then one attended negative/revoke/cleanup receipt committed with probe
+clients removed. No native acceptance or credential/registration action is
+inferred from this coordinator review.
+
+
## KeyCape issuer-source compatibility inspection for lifecycle v2
```task