Commit graph

201 commits

Author SHA1 Message Date
ed67780da8 Record the rotation authority answer and why it was not scheduled
The Qonto rotation blocker is narrowed from an open question to a schedulable
act: railiance-platform named the executor, transport and authority, and will
prepare the CCR if KeyCape asks. Not asked -- requesting a production credential
rotation with a restart window is the operator's decision, so it was put to him.

Established first that this rotation has no incident driver. The 2026-08-23
exposure covered credentials embedded in config.yaml and the signing key; the
rapp-qonto secret is an env: secretRef in a separate Secret and was not in that
payload. Lifecycle hygiene, not remediation -- which changes what a reasonable
window looks like, and means nobody should be carrying it as leftover incident
work.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-10 08:10:34 +02:00
204bb229d6 chore(consistency): register KEY-WP-0031 [auto]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-10 08:07:09 +02:00
f2c9aa45a8 Split the gate-house ruling work into its own workplan
fix-consistency skipped C-11 for two tasks I had appended to KEY-WP-0030 after
marking it finished, so they were never registered in the hub. The skip was
right: appending tasks to a finished workplan is the wrong shape, and the fix is
structural rather than flipping a status to get them registered.

The provenance implementation and the condition (b) correction are responses to
GH-DEC-2026-013, not to informed-decision's request for an enforced precondition.
They are now KEY-WP-0031, which also records that conditions (a) and (c) needed
nothing from this repository and why.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-10 08:05:49 +02:00
7f6fdd00e6 Record the answered rotation authority; T04 now waits on a decision
railiance-platform answered the question ops-warden routed for us: rotation steps
1-2 are the platform operator's, attended, through the governed
openbao-platform-admin-login lane under founder_required OIDC. No unattended
agent, and they named themselves in that exclusion.

What remains is an artifact and a decision, not an open question. There is no
rotation CCR and they will not raise one speculatively, rightly, since a
two-custodian CAS rotation with a service restart is a distinct version-guarded
operation. Their offer is to prepare it if KeyCape asks, and ops-warden
deliberately did not ask on our behalf. So the task waits on the repository owner
deciding whether the rotation should happen and when -- nothing indicates
compromise, so it is hygiene plus the first real exercise of rotation step 4,
which is a judgement call rather than an obvious yes.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1182213@bnt-lap001
Assistant-Session: 966597b9-ae61-46a4-8b9e-1594ab3ec4ad
2026-09-10 08:05:15 +02:00
63d646b594 Carry the tenant claim's provenance, and correct a guard the ruling voided
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 44s
GH-DEC-2026-013 §5 is a finding nobody asked for and 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. gate-house named the property and left the mechanism to us.

Every token now carries tenant_source beside tenant: directory, registration or
default. Advertised in claims_supported, and asserted at the token level on both
grants rather than only in the resolution function, since the claim a consumer
reads is the thing under obligation.

Three values where the ruling names two, which is the 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. 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.

Also corrects the guard shipped in 5f516a0. Its failure message offered two ways
out of adding dynamic registration, and condition (b) voids the second: admitting
dynamic registration voids the registration-bound shape that day, whatever state
the adapter is in. The message named an inadmissible resolution in the exact
place someone would read it while making that change.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-10 07:58:29 +02:00
d29032d6c4 Record the operator withdrawal and the gate-house tenant conditions
approval-engine answered the presenting-actor question we put to them and the
answer withdrew the client: nobody presents approval-engine-operator. Nothing in
their repository obtains an OAuth token, and scope by scope the bundle never
described a single actor -- approve belongs to the human client, emit is
redundant, and no requester identity was ever settled for create. Annotated as
withdrawn in the example config and the packet rather than deleted, because they
asked for it to stay authenticable under CCR-2026-0018 if a presenter appears.
The separation-of-duties observation is kept because it constrains the
re-request: approve must not travel with the operational scopes.

Raised rather than filed: "do not provision" cannot undo a provision. The client
went live in the 2026-09-09 attended rollout, so the estate now holds a
confidential credential that can create and approve approvals, that nobody
presents, and that no consumer awaits. Standing capability with no counterparty
is a worse resting state than either provisioning it for a named presenter or
disabling it. Disablement is KeyCape's to execute and not KeyCape's to decide
alone, so it goes to the owners rather than into a commit.

Also records GH-DEC-2026-013 conditions (a) and (b) in the tenant contract, both
of which strengthen what we had written about ourselves. Row four is normative,
and in the direction we would not have guessed: a future change preferring the
DIRECTORY is also void, because "the directory won" is still a winner and picking
any winner turns a refusal into a silent cross-tenant assertion. And lifting the
dynamic-registration exclusion now VOIDS the registration-bound shape that day
rather than reopening it for review -- our "must be revisited" was too weak.
The shape is recorded as a declared bounded gap, not the terminal state.

Verification note: the Go suite could not be run green for this commit because a
peer session is mid-edit on token.go and human_tenant_test.go for the §5
provenance work. Every file in this commit is YAML or Markdown; both YAML files
were parsed and the example config's scope set and tenant were asserted unchanged.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1182213@bnt-lap001
Assistant-Session: 966597b9-ae61-46a4-8b9e-1594ab3ec4ad
2026-09-09 23:30:09 +02:00
5f516a0fbb Make the static-registration precondition a checked condition
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 44s
informed-decision asked for the caveat under which a registration-bound human
tenant is safe to be a condition of the capability rather than reasoning in a
message, so a future change to registration policy has to confront it. They were
right that it was only prose: the contract stated it, a separate test asserted
registration_endpoint is absent, and nothing connected the two -- so a session
adding dynamic registration would have seen a test about discovery metadata, not
a warning about relabelling users.

The test asserts both halves together: that a client-declared tenant is issued,
and that dynamic registration is not advertised. Whichever is removed first, the
failure points at the other. The message carries the reasoning rather than the
observation -- anyone able to register a client could relabel the users who log in
through it -- and names the two ways out.

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 not a vote on the tenant question. It makes one option's precondition
checkable; if option 1 lands, the capability and this guard are removed together.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-09 23:23:48 +02:00
a4e2751ab5 Make the registration-bound tenant caveat a condition, not a paragraph
informed-decision replied on KEY-WP-0013-T05 asking for one thing that is ours:
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, rather than reasoning left in a message. They will hold the approver
registration, so they are the party that inherits the risk.

The contract now states it as a condition rather than a caveat, in a form that
says removing the paragraph is not the decision, and points at the test that
enforces it.

I also wrote a guard test for this and then deleted it: tests/profile has
asserted registration_endpoint's absence all along, and a peer session was
concurrently writing a better guard that asserts both halves together -- the
capability still issuing, and the endpoint still absent -- so whichever is
removed first the failure points at the other. That connection is the whole
value, and a third assertion of the same fact would have been the duplication
this repository keeps correcting. The contract cites theirs.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1182213@bnt-lap001
Assistant-Session: 966597b9-ae61-46a4-8b9e-1594ab3ec4ad
2026-09-09 23:23:41 +02:00
f12d24e079 Name the baseline the reassessment actually covers
The header cited 5ae80d3, but 7a73352 landed 58 seconds earlier, so a reader
applying the convention would ask what changed since 5ae80d3, find that commit
and be unable to tell its substance was already accounted for. Honest about what
was read and misleading about what is covered, which is the failure the
convention exists to prevent.

7a73352 changes documentation and workplans only, no source, and its substance
reached this document as a peer report before being confirmed by reading the
commit. The baseline now names it and records that distinction rather than
smoothing it over: read and told are different, which is the same distinction the
third state draws and the one behind four corrections this week.

Also records why the unreleased fail-closed changes stay a pointer to
docs/operations.md rather than being restated here. That section is built from a
deployed digest, a config resource version and a dated re-check note -- facts
with a short shelf life. SCOPE is a durable claims document, and putting
perishable facts in a durable one is how it drifted 63 commits to begin with.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-09 20:11:56 +02:00
66ca76cb9c Name the third state SCOPE had no vocabulary for
Folds in two points from the custody owner and a peer session that arrived after
the reassessment landed.

Implemented and unproven is a distinct state from both missing and done, and
SCOPE had no words for it. verify-client's predecessor rejection is written and
unit-tested and has never run against a genuinely distinct predecessor, so this
repository holds a receipt that reads like rotation evidence and is not. Both
owners state that limit independently, which is why it belongs in SCOPE rather
than only in a workplan. "There is a test" and "it has been exercised against the
real thing" now read as separate claims wherever SCOPE makes one.

Also records that the two approval clients' live verification was independently
confirmed by the custody owner, and corrects a conflation this repository made:
client-side retrieval of those secrets is unadmitted and stays so, but the
attended operator path is not a client-side read and never required one. That
conflation is what left T02 recorded as waiting on a run that had already
happened.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-09 20:09:01 +02:00
06f39906e9 chore(consistency): register KEY-WP-0029 [auto]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-09 20:07:24 +02:00
d6ecb2b7aa Reassess SCOPE against the current revision
SCOPE.md declared itself reviewed against b989de4, 63 commits behind, with its
limits kept current by appending to bullets. That works until the header asserts
a review date that no longer describes what was reviewed -- the drift this file
exists to prevent, in the document whose only job is to be accurate.

Every claim was rechecked against source rather than carried forward: routes from
the mux registrations, subcommands from the dispatch, flags from the flag sets,
claim behaviour from token.go and userinfo.go. Three were false. Caller commands
omitted verify-client, the container was said to package only keycape (untrue
since KEY-WP-0026), and the issuer API row omitted /readyz. Capabilities that had
reached SCOPE only piecemeal are now in the capability table: the
authorization-code bindings, upstream token verification, readiness and drain, the
human tenant rules, authentication-time assurance, the shared verifier.

The substantive addition is a distinction SCOPE did not draw: source behaviour
and deployed behaviour 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 now stated exactly, including that the
issuer pin's pre-pin state did not match, so that risk was real rather than
hypothetical, and that the deployed image predates this revision so several
described behaviours are running nowhere yet.

That distinction is the fix for a repeated error: four times in two days a claim
about current state was written from a workplan paragraph or a partial view
instead of the authoritative artifact. A SCOPE that does not separate "the code
does this" from "this is running" invites the same mistake from the next reader.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-09 20:06:38 +02:00
7a73352368 Record the custody owner's confirmations and guard a receipt against misreading
railiance-platform replied on all three open threads. Recording what they add
rather than what we already knew.

T02: they reached the same reading of the verifier receipt independently and add
human_client_consume_denied: true for both clients, confirm the verifier ran
twice (activation and post-rollout) at generation 38 with the signing key
unchanged, and state T02 can close against that receipt. They also correct our
framing, rightly: the packet says client-side retrieval is unadmitted, which
stays true, but the attended operator path is not a client-side read and never
required one. This task had been treating those as the same constraint.

T04: the live approval clients mean this repo now holds a committed receipt that
looks like rotation evidence and is not. Both owners independently state the same
two gaps -- no real predecessor rotation, no observed wall-clock expiry. Recorded
in T04 rather than only in T02, because verify-client's predecessor rejection is
now implemented and unproven, which is a different state from missing or done,
and T04 is where that distinction belongs.

Answered their open question on CCR-2026-0020, which has no named presenting
actor. As issuer: the registration carries both approval:create and
approval:approve, so one presenter can create an entry and approve it. That is a
separation-of-duties property of the holder, not a defect in the token -- KeyCape
issues the grants approval-engine asked for. Recorded both shapes KeyCape can
support, and that who holds it is approval-engine's decision and the doctrine
question gate-house's, not ours.

Also narrowed the operations note: the deployed config has not been written since
the activation, so the inspected state is the state that will boot.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1182213@bnt-lap001
Assistant-Session: 966597b9-ae61-46a4-8b9e-1594ab3ec4ad
2026-09-09 20:05:40 +02:00
5ae80d341f Record the two unreleased fail-closed startup changes for the rollout
Both changes on main that postdate the deployed image affect startup or issuance
and both fail closed, so a mistake in either presents as a refusal rather than a
warning. That fact currently lives only in a State Hub message to the deployment
owner; messages age out of attention, and the repo is what the next session
reads, so operations.md is where it belongs.

Records what was actually checked rather than what is assumed: the deployed image
digest and the commit it was built from, that the live config's three browser
clients carry neither newly-rejected field, and that no deployed client declares
a tenant on a browser grant so the refusal is not yet reachable. Also states
plainly that neither change has been exercised against a running issuer, and that
the proof belongs to the attended window rather than to a session running against
production on its own.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1182213@bnt-lap001
Assistant-Session: 966597b9-ae61-46a4-8b9e-1594ab3ec4ad
2026-09-09 16:41:38 +02:00
04931387f5 Record the stale-claim correction alongside the others
A message to railiance-platform listed the upstream issuer pin as outstanding
when the committed receipt showed it done ten hours earlier. Corrected to them
directly; recorded here with the other two instances because the pattern is the
point, not the individual slip. docs/evidence/ is the authoritative artifact for
live proofs in this repository, and reading it is cheaper than the corrections
it prevents.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-09 16:41:10 +02:00
cb25ec2a8d Correct the T02 claim-contract record: the attended run already happened
A peer session recorded that T02's claim contract is unproven and waits on an
attended keycape verify-client run. The restraint behind that was right -- it
declined to read a client secret, which the provisioning packet does not admit --
but the conclusion was not: the attended run had already happened at
2026-09-09T00:10Z and its receipt is committed in this repo.

docs/evidence/2026-09-09-keycape-verifier-admission.json records, per client:
live_jwks_signature_verified, exact_claims_verified, excess_scope_denied and
wrong_secret_denied all true, lifetime 900s, run by a pinned verifier in an
attended owner process, with credential_values_emitted and
client_side_read_admitted both false. So it was run, and run the admitted way.
T02's status: done is correct and does not revert to wait.

The peer's paragraph is kept rather than deleted, with the correction appended
after it, so the record shows what was concluded and why it was wrong. The error
was reading "not admitted for me" as "not done by anyone" without checking
docs/evidence/ -- the third instance in two days of inferring repository state
from a partial view instead of reading it.

Also records what the receipt itself declines to claim, which nobody should
overstate later: real predecessor rotation and wall-clock expiry were not
exercised. Predecessor rejection is implemented and unit-tested in verify-client
but has never run against a real rotation, which remains KEY-WP-0014-T04 and
still has no admitted execution authority.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1182213@bnt-lap001
Assistant-Session: 966597b9-ae61-46a4-8b9e-1594ab3ec4ad
2026-09-09 16:38:20 +02:00
b9a251b16b chore(consistency): refresh work records [auto]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-09 16:38:13 +02:00
1dc11b8448 Record live custody admission and re-verify the new rule against it
Read-only inspection shows KEY-WP-0013-T02's custody gate has moved since
yesterday: both approval clients are registered in sso/keycape-config, both
secrets exist in the namespace and are wired into the pod, and the image is
pinned to the digest T06 published. The live issuer passes every conformance
check that needs no credential.

That does not establish the claim contract. Subject, tenant, roles, scopes,
lifetime and excess-scope denial are what keycape verify-client proves, and it
needs the client secret in the environment. Reading either secret is client-side
retrieval, which the provisioning packet records as not admitted, so that run is
an attended operator action. T02 now waits on the run, not on custody.

Also re-verified the KEY-WP-0028 rule against the deployed config, after a peer
noted it fails closed at startup with a rollout imminent. The three browser
clients there carry neither serviceSubject nor roles; the three service clients
carry both legitimately. Kept as errors rather than warnings on that evidence.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-09 14:44:38 +02:00
74b35b6107 Reject service-identity fields a browser client silently ignores
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 34s
serviceSubject and roles are read only on the client_credentials path. On a
browser client they are accepted and then ignored, since subject and roles come
from the directory user -- so a registration that looks effective fails later as
a downstream rejection rather than as a registration defect. tokenLifetime was
already rejected this way, so the rule existed and was incomplete.

Nothing in dev-config, the example fixture or the live deployment sets either
field on a browser client, checked against all three rather than assumed, so this
breaks no existing configuration.

tenant is deliberately excluded, and a test pins that: a browser client may
declare one, and humanTenant resolves it against the directory, refusing issuance
when they disagree (KEY-WP-0013-T05). An earlier version of this change rejected
tenant too and would have made that feature unusable. It started from T05's
blocker paragraph, which was accurate when written and already fixed by the time
this task began -- blocker prose ages faster than the code it describes.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-09 14:42:26 +02:00
329e48f64a Let a human token carry the zone it is issued into, without relabelling anyone
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 46s
KEY-WP-0013-T05's tenant blocker did not need the decision it was waiting on. The
two proposed resolutions differ in where a human's tenant comes from -- the
directory record, or the client registration -- and an implementation exists that
is correct under either, so the choice can be made later without another
migration.

A client registration may now declare a tenant. humanTenant() resolves it by four
rules: no declaration keeps the directory answer unchanged; a declared zone
applies where the directory has placed the user nowhere; agreement passes; and a
declared zone conflicting with a directory assignment refuses issuance rather
than relabelling the user.

The refusal is the design, not an edge case. A registration can bind a zone for
unplaced users and can never move a placed one, so this gets the approval chain
its tenant:platform without writing a general cross-tenant override into the
issuer. It fails closed rather than picking a winner, because either answer would
be a silent cross-tenant assertion, and it reports 403 with
error_type: tenant_binding so an operator can tell a misconfigured registration
from a rejected login. If the owners later populate directory tenants, the same
code stops supplying the zone and starts enforcing agreement with it.

Safe only because client registrations are static and deployment-owned. The
tenant contract records that this rule must be revisited if dynamic client
registration is ever admitted.

Tests cover all four rules; neutering the conflict check fails the relabel test
rather than passing silently.

T05 now waits on one thing only: the client_id and callback URI from
informed-decision once it has a deployed origin.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1182213@bnt-lap001
Assistant-Session: 966597b9-ae61-46a4-8b9e-1594ab3ec4ad
2026-09-09 14:40:36 +02:00
a73da29093 Answer the approver-client questions, and fix what checking them turned up
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s
informed-decision and approval-engine both asked to hear problems with the human
approver registration now rather than at handover. Checking their requested shape
against the source rather than agreeing it on paper turned up three things.

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

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

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

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

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1182213@bnt-lap001
Assistant-Session: 966597b9-ae61-46a4-8b9e-1594ab3ec4ad
2026-09-09 14:25:38 +02:00
0f5535eed9 docs: refresh work record index after verifier acceptance
Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-09 02:28:33 +02:00
custodian-sync
9a78f7e7f0 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-09-09:
  - update .custodian-brief.md for key-cape

Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-09 02:20:39 +02:00
3a4fd38c00 docs: close live approval service registration gate
Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-09 02:18:12 +02:00
cf23469ead Consume verified owner configuration pin before custody rollout
Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-09 00:40:23 +02:00
e30ba7b3c0 docs(records): attach issuer proof to the receiving admission task
Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-09 00:07:01 +02:00
41f6916a5d docs(identity): record verified live upstream issuer and completed cleanup
Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-08 23:50:54 +02:00
7ecc78f410 chore(records): retain issuer probe task identity and index
Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-08 23:26:46 +02:00
custodian-sync
c3983facd2 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-09-08:
  - update .custodian-brief.md for key-cape

Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-08 23:25:28 +02:00
1b9793ffed docs(oidc): pin tested issuer probe and bounded owner execution packet
Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-08 23:18:11 +02:00
6f33abddcf feat(oidc): prepare one-shot upstream issuer proof without token disclosure
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s
Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-08 23:14:46 +02:00
5c7db26b7c chore(records): index verifier completion and human-client residual
Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-08 20:35:53 +02:00
custodian-sync
bb507e993e chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-09-08:
  - update .custodian-brief.md for key-cape

Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-08 20:24:00 +02:00
repo-manager
e147364721 repo.work.assign_missing_identifiers
source: repo-manager
reason: deterministic projection registration

Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-08 17:04:19 +02:00
e534e1d5e8 docs(keycape): prepare pinned approval-client rollout and recovery
Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-08 17:03:59 +02:00
dcebd46fa6 Require typed issuer refusals in live registration verification
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 40s
Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-08 16:46:02 +02:00
471465df22 Record the two ops-warden catalog corrections
Accepted ownership of lane key-cape-oidc-login, which their catalog has carried
as asked-and-waiting since 2026-08-28 -- they were blocked on us. Reported the
rapp-qonto-keycape-client blocker as stale against its own source-read date, and
asked for it to be narrowed to the two custody steps rather than cleared, since
the exchange and the step-3 verification now exist but successor generation and
the CAS write deliberately do not.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1182213@bnt-lap001
Assistant-Session: 966597b9-ae61-46a4-8b9e-1594ab3ec4ad
2026-09-08 14:49:01 +02:00
2a8735173b Ship the live-registration check both blocked tasks depend on
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 45s
KEY-WP-0013-T02 and KEY-WP-0014-T04 stay blocked on custody and on ops-warden,
but each contains a KeyCape-owned piece that had been left as prose. T02 requires
proving "live JWKS verification and denied excess scopes without logging values";
T04 step 4 requires verifying a rotated secret, refusing its predecessor and
refusing excess scope. Both were describable and neither was runnable, so the
proof would have been improvised by hand against production at the moment custody
lands -- the worst possible time for it.

keycape verify-client does it in one command. Per registration it checks
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. That last
check is a real gap: the caller commands prove every requested scope was granted,
never that nothing extra came back. -previous-secret-env additionally requires
the predecessor to be refused, and treats an unchanged secret as a rotation that
did not happen.

Nothing is written to disk and no value is printed. Failures name the claim, not
the observed value, so running this against production cannot turn a verification
into a disclosure. Every check runs before it reports, so one failure does not
hide the rest.

The exact invocation for each approval client is recorded in the verification
block of the provisioning packet, so custody admission hands back a command
rather than a description.

Tests cover the passing case, an over-broad registration, a live predecessor, an
identical rotation and output non-disclosure; neutering mustFail makes the suite
fail, so the checks have teeth.

Also recorded in KEY-WP-0014: read from ops-warden's catalog rather than waiting
for a reply, key-cape-oidc-login is asked-and-waiting on us since 2026-08-28 and
is a pointer lane with no programmatic consumers, and the rapp-qonto-keycape-client
blocker citing an absent native exchange command went stale when service-token
shipped on 2026-09-05.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1182213@bnt-lap001
Assistant-Session: 966597b9-ae61-46a4-8b9e-1594ab3ec4ad
2026-09-08 14:30:43 +02:00
54b7687903 Record the rollout-precondition handoff to railiance-platform
Sends the issuer-pinning precondition to the deployment owner with the exact
check, the config keys to pin and the error_type to watch for, and confirms by
inspection that the two approval clients are still unprovisioned.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 14:02:37 +02:00
044830da79 chore(consistency): register KEY-WP-0027 [auto]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 14:00:51 +02:00
7dda967c27 Establish the live state and find a rollout precondition for G10
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 45s
G10 waits on custody and platform owners and cannot close from here. What was
doable: verify the handoffs actually went out, replace a remembered live state
with an observed one, and find out whether main is safe to deploy. The last
question found a defect in this repository's own recent work.

Handoffs verified independently rather than trusted: all seven messages are in
the hub with receipt ids. This gap was reopened once for claimed-but-unsent
delivery, so the claim deserved the same scrutiny.

Live state read from the cluster read-only: image main-153258b, only the Qonto
secret materialized so the approval clients remain unprovisioned, four registered
clients, no tenantEngine block. That also corrects an earlier claim of mine --
the deployed config sets userOU explicitly, so the KEY-WP-0023 default fix was
never a production issue.

The precondition: KEY-WP-0019 discovers the expected issuer from
authelia.tokenBaseURL, and the deployed Authelia derives its advertised issuer
from the request Host, advertising the in-cluster address to KeyCape and the
browser-facing one to browsers. Verification fails closed, so a mismatch breaks
every human login and looks like a broken login rather than a misconfiguration.
Which value the token carries needs a real login against production to settle and
was not determined here.

Two mitigations: docs/operations.md documents pinning authelia.issuer and
jwksUrl, with the curl that reveals what the provider advertises for a given
Host; and the authentication failure event now carries a specific reason, so
id_token_issuer_mismatch is distinguishable from a signature failure or an
unreachable key set. The browser still learns nothing.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 11:41:42 +02:00
custodian-sync
64942639ad chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-09-08:
  - update .custodian-brief.md for key-cape

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1182213@bnt-lap001
Assistant-Session: 966597b9-ae61-46a4-8b9e-1594ab3ec4ad
2026-09-08 10:23:26 +02:00
7c9ed852ff Close the KEY-WP-0009 handoff gap and deliver the two blocked admissions
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 39s
KEY-WP-0009-T04 is done. Re-ran verification on main with an explicit Go 1.23.0
toolchain and a writable build cache: contract validator PASS, build, vet and the
full test suite pass, git diff --check clean. gofmt had drifted on
internal/server/telemetry/events.go and tests/migration/scenario_c_test.go
(comment alignment only, no semantic change); both reformatted so the task's
formatting claim holds. The four handoffs to netkingdom, secrets-engine,
ops-warden and railiance-platform were then actually sent -- the 2026-09-05 audit
had shown the earlier claimed delivery had no receipts -- and each receipt id is
recorded in the workplan and readable back from the hub.

KEY-WP-0013-T02 and KEY-WP-0014-T04 stay in wait. Their remaining work is not
ours to do, so what was owed was the request, and it is now delivered: custody
admission for the two approval clients to railiance-platform, the exact human
client id and callback URI to approval-engine, and the OpenBao-token vs
issuer-JWT split plus Qonto rotation execution authority to ops-warden. Each
message quotes the existing non-secret packet or reviewed sequence and states
what is explicitly not being treated as authorization.

No secret was read, no production resource changed, no route retired.
SCOPE.md updated to match.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1182213@bnt-lap001
Assistant-Session: 966597b9-ae61-46a4-8b9e-1594ab3ec4ad
2026-09-08 10:22:52 +02:00
2ab70924a1 chore(consistency): register KEY-WP-0026 [auto]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 09:52:42 +02:00
3b72834bca Reconcile packaging, bootstrap and migration credential handling
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 43s
Closes gap G09: five loosely related defects.

lldap-export took the service account password on argv, where ps exposes it to
any local user and shell history and process accounting capture it. It now
prefers KEYCAPE_LLDAP_BIND_PW or --bind-pw-file; --bind-pw still works but warns,
deprecated rather than removed because existing runbooks use it and breaking them
silently would be worse than one more cycle of exposure. Conflicting sources are
rejected instead of silently ranked, since an operator otherwise cannot tell
which bind was attempted. Both migration scripts pass the password by
environment now.

The canonical export, generated LDIF and Keycloak realm were written 0644. None
carries credential material, but the snapshot is every username, display name,
email and group membership in the estate, and it tends to land in /tmp. All three
are 0600.

The image packaged keycape alone, so the validator and migration binaries needed
a Go toolchain on the host -- which defeats shipping an image for the cutover
work they exist to support. All five ship; the issuer stays the entrypoint.
Verified by building the image and running each binary inside it.

The publish workflow named 92.205.130.254:32166 while the cluster runs
forgejo.coulomb.social/coulomb/key-cape. It now defaults to the recorded name and
stays overridable by a repository variable. This repository cannot verify that
the runner resolves that hostname or that the registry credentials are valid for
it; if the next publish fails, set the REGISTRY variable back to the address.

docker-compose.dev.yml mounts a private key and Authelia material that are
correctly absent from the checkout. scripts/bootstrap-dev.sh generates them
locally under a restrictive umask rather than chmodding afterwards, so the key is
never briefly world-readable. Everything it writes is git-ignored.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 09:51:59 +02:00
dd8d5781e3 chore(consistency): register KEY-WP-0025 [auto]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 09:44:33 +02:00
d568b79223 Give the runtime real readiness, graceful shutdown and stated limits
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s
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 lived in code comments
rather than anywhere an operator would look.

/readyz probes LLDAP, Authelia and privacyIDEA; /healthz stays liveness and
probes nothing. Keeping them distinct matters: wiring liveness to dependency
health means an orchestrator restarts KeyCape when a dependency blinks, and a
restart also discards every in-flight login, so the reaction is worse than the
condition it reacts to.

LLDAP is probed with a bind rather than a dial, since a rotated or revoked
service password leaves the port open and every lookup failing -- exactly what
readiness should catch and exactly what a dial would miss. The response names the
failing check but never the reason: the endpoint is unauthenticated and upstream
error text carries hostnames and sometimes credentials-in-URLs. Results are
cached for 2s so an unauthenticated endpoint cannot be used to drive unbounded
upstream traffic, and probes run concurrently under a 3s bound so a hung
dependency makes the endpoint answer rather than hang with it.

SIGTERM and SIGINT now drain in-flight requests for 15s, under the 30s read/write
timeouts so a stuck request cannot outlive the window before SIGKILL.

docs/operations.md states the single-replica topology and why, and three limits
easy to get wrong: the constant key-1 key ID makes same-kid rotation a trap for
consumers caching JWKS, removing a client does not revoke its issued tokens, and
/logout is local only. No throughput figures are given, since nothing here
benchmarks KeyCape. Shared storage and refresh tokens stay excluded, as G08
allows.

Verified in the running executable: 503 naming all three checks failed while
/healthz returned 200, the LLDAP check flipping to ok once started, and 40/40
requests succeeding across a SIGTERM.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 09:43:49 +02:00
a9296fdf84 chore(consistency): register KEY-WP-0024 [auto]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 09:00:39 +02:00
a0f3cac122 Wire tenant_roles as explicit opt-in configuration
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 48s
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 the capability
existed at library level only.

A tenantEngine block with baseURL and an optional timeout now wires it. An empty
baseURL leaves the stock server's behaviour exactly as it was, so enabling the
claim is a deliberate act. Validation treats a configured source as one that must
work: http/https with a host, and a timeout 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.

Verified in the built executable, which is what G07 asks for, rather than at the
wiring: against a stub source a real token carries tenant_roles; with no block
the claim is absent; with the source configured but down, issuance succeeds
without it, confirming the documented fail-open path end to end.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 08:59:40 +02:00
01551d9b0a chore(consistency): register KEY-WP-0023 [auto]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 08:55:52 +02:00