Skip to content

feat(release): 0.2.0 — six specifications, no upgrade path from 0.1.x - #17

Merged
nofuturekid merged 64 commits into
mainfrom
feat/0.2.0
Aug 10, 2026
Merged

nofuturekid merged 64 commits into
mainfrom
feat/0.2.0

Conversation

@nofuturekid

Copy link
Copy Markdown
Owner

Six specifications, 0017–0022, and the 0.2.0 release commit. This is the whole cycle in one branch rather than one PR per spec — the specs build on each other tightly enough (0018 depends on 0017's issuer rows, 0019 closes a hole 0018 knowingly left, 0021 and 0020 both change chain assembly, 0022 adds an eighth issuance path that 0018 had to be corrected for) that splitting them would have produced PRs that could not be reviewed independently anyway. The deviation from AGENTS.md's one-spec-one-PR rule is deliberate and worth knowing before you open the diff.

CHANGELOG.md's 0.2.0 section is written for an operator deciding whether and how to upgrade. This description is for the reviewer, and does not repeat it.

What landed

  • Spec 0017 (multi-ca) — a hierarchy stops being a singleton: several run side by side, create_intermediate_under makes rotation ordinary operation, retire and renew_in_place exist, every leaf records its issuer and every CRL is per issuer.
  • Spec 0018 (issuer-permissions) — who may issue from which CA is a grant held by users and by API tokens, enforced through a required keyword-only principal parameter on the domain functions rather than a FastAPI dependency.
  • Spec 0019 (acme-per-issuer) — an ACME account belongs to one issuer for life; the URL selects it and external account binding authorises it. The shared /acme/directory is gone with no alias.
  • Spec 0020 (name-constraints) — an intermediate can be restricted to the names it may sign, and cabin checks its own constraints before signing rather than writing the extension and trusting the client.
  • Spec 0021 (cross-signing) — a root can sign another root, or a cross certificate produced elsewhere can be imported; both paths are served, and an expired one drops out of the chain where the chain is assembled rather than on some operator's next visit to a page.
  • Spec 0022 (https) — cabin terminates TLS itself, opt-in and off by default, with its own key sealed at rest and materialised only into an anonymous memfd, and a second plaintext listener for the CRL and CA-certificate routes.

Where to look hardest

The schema rewrite makes 0.1.x databases unusable, and nothing detects it. Migrations 0003, 0004, 0005, 0008 and 0009 were rewritten in place rather than superseded; 0003 was then edited again by 0021. Alembic tracks a revision id and nothing about the schema behind it, so against a 0.1.0 database it applies only the genuinely new revision (0010), reports the database current, and leaves five revisions describing tables that no longer look like that. Startup succeeds; the first query fails. The justification is that 0.1.0 was never deployed and there is no instance to carry forward — that is the entire justification, and it is not available again. If you disagree with it, this is the commit to disagree with (feat(store,ca): phase 0 of spec 0017), because every later commit sits on top of it.

Permissions do not bind ACME unless external account binding is required. With acme_require_eab off, an administrator holding no grant still obtains a certificate over ACME: anyone who can reach the port registers at any issuer's directory and orders from it. What remains is that an account is confined to one hierarchy, which is worth having and is not access control. 0018 states this at the top of its own spec and 0019 narrows it rather than removing it — there is still no cabin identity behind a finalize. Worth checking that you agree the boundary is drawn where the specs say it is, because "cabin has per-issuer permissions" is the sentence that will get quoted without the qualifier.

Cross-signing is unusable on any root created with the default path length. The cross path is one certificate longer, so the signing root needs pathLenConstraint ≥ 2; cabin's default is 1, and renewal carries BasicConstraints over unchanged, so no operation cabin has repairs it. It has to be planned a root generation ahead. The attempt is refused with an error naming the value the root actually carries rather than producing a certificate no validator would build a path through, and the field hint on root creation says so — but a feature that most existing roots can never use is a design decision, not an implementation detail, and it is the one most likely to be judged differently on review.

Seven environment variables, not five. CABIN_TLS and CABIN_HTTP_PORT are the first values since spec 0014 to live in the environment rather than the settings table, which breaks a rule this project set for itself. docs/adr/0002-tls-environment-variables.md records it rather than making it quietly, and rests the decision on recoverability: a setting that can make the interface unreachable has to be changeable from somewhere that does not depend on that interface. The startup-ordering argument is explicitly supporting evidence only. If the ADR's test is the wrong one, every future request for an eighth variable will be measured against it.

Two smaller things that are easy to miss in a diff this size: the issuing and revoking grant lookups are deliberately different (revoking is blind to issuer status, so retiring a compromised intermediate does not cost you the ability to revoke what it signed — confusing the two would have been the sharpest bug this cycle could have shipped), and the dashboard's "install this root" link deliberately does not follow 0021's new default chain, which is the one chain-assembly site out of six that must not agree with the others.

How it was verified

928 tests pass. ruff, ruff format and mypy are clean. Every spec was written before its tests and its tests before its implementation, with test authors and implementers kept separate.

Beyond the suite, a mutation harness introduces 57 deliberately wrong implementations and checks the suite catches each one, plus one self-check mutation that must not be caught (so a harness that reports everything caught fails). It currently stands at 57 of 57.

What that harness found that review had not, which is the most useful thing to know about the confidence level here:

  • A requirement reported as implemented that never was. Spec 0022 FR-17 requires that retiring the issuer signing cabin's own TLS certificate is refused. The lane that reported on it extracted the helper and never wired up the refusal, and the report was accepted without checking. The harness found it by trying to build a mutation for the guard and discovering there was nothing to mutate — a failure a green suite cannot produce, because absent code has no failing test. Confirmed by hand before fixing: the retire returned 303 and the row moved.
  • A defect in a commit that had already been accepted. ca/x509.py:renew_certificate took a key parameter that was silently ignored, so renew_in_place generating a fresh key instead of reusing the stored one was caught by nothing. The reason it was invisible: openssl verify -CAfile does not check the trust anchor's own signature, so a renewed root with a cryptographically invalid self-signature passed the test written to prove renewal without rekey is safe. Renewal tests now compare public keys byte for byte and verify the signature directly.
  • Tests that could not fail. test_sigterm_stops_both_listeners passed with cabin's own signal cross-wiring deleted outright, because uvicorn's capture_signals() re-raises on shutdown and the cascade happens either way. Two more of the same shape: load_cross's public-key comparison was unreachable by any input, because import_cross refuses one layer out; and the ACME test client's issuer_id could be given a default with nothing noticing, which would have made five of 0019's acceptance criteria pass on a single-hierarchy instance where the two behaviours are indistinguishable. Each replacement test was confirmed to bite by breaking the code, watching it fail, and restoring the file.

Not verified: certbot and acme.sh interoperability. 0019 changes every ACME URL a client is configured with, and AGENTS.md's own rule is that protocol behaviour is verified against real clients before a spec is called done. That did not run as an automated gate in this cycle. It remains a manual check before release.

What was verified in the container, and what was not

The image builds and runs. First run on an empty data directory creates the database and a 0600 secret.key; a restart persists it. It runs under --user 99:100 against a chowned directory — the case Unraid creates. Capabilities are dropped and /healthz reports healthy in both the plaintext and the TLS configuration, scheme-aware. The plaintext listener serves the CRL and the CA certificate and nothing else: every other route answers 404, with no redirect, no Location and no Set-Cookie.

Port publishing was never exercised. This machine's kernel has no veth support, so every container check ran with host networking. The Unraid deployment depends on publishing host port 80 to the plaintext listener — that is where every certificate cabin issues says its CRL and CA certificate live — and that path is untested here. It is also the step in the four-step TLS sequence that fails quietly: miss it and the certificates are perfectly valid while their distribution points are dead, which surfaces whenever some relying party first enforces revocation.

One measurement worth recording, because it closes a question left open at 0.1.0: the image is 206.8 MB as an exported filesystem, while docker images reports 305 MB. Both are correct. The larger figure is per-layer accounting with no deduplication across layers, not double-counted bytes on disk. At 0.1.0 the same image was measured both ways and looked like two different images.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp

nofuturekid and others added 30 commits August 7, 2026 11:57
… hierarchies

Implement the schema foundation for supporting multiple CA hierarchies
side by side, enabling rotation, parallel hierarchies, and per-issuer
CRLs. Migrations 0003, 0004, and 0005 are rewritten in place (no upgrade
path from 0.1.x — see spec for rationale).

Schema changes:
- ca_certificates: remove unique constraint on kind, add name, parent_id,
  status columns; self-referential FK to support hierarchy nesting.
- certificates: add issuer_id (NOT NULL FK), required for chain assembly
  and per-issuer CRL generation.
- crl_state: drop id column, make issuer_id the primary key; one CRL
  per issuer rather than per instance.

Code updates:
- ORM column declarations updated in ca/service.py, ca/certs.py, ca/crl.py,
  and audit.py to match the new schema. Function bodies deliberately left
  untouched; they belong to later phases.
- New test fixtures in tests/ca_fixtures.py with shared hierarchy and
  certificate-row builders.
- New schema validation tests in tests/test_store_schema.py.
- Existing tests swept onto new fixtures.

Status: test suite deliberately failing (303 passed, 104 failed, 86 errors)
because function bodies have not yet been adapted to the new schema.
Ruff and mypy pass cleanly. See spec/0017-multi-ca.md for full requirements.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
…ltiple CA hierarchies

Add complete test suite for spec 0017 phase 1a, written before implementation
to establish the contract that the implementation must satisfy. Suite is
deliberately in a red state — several test files do not import because they
reference symbols not yet created by the implementation (public_http_origin,
Issued, IssuerRequiredError, active_issuers, create_intermediate_under,
renew_certificate).

Test authorship policy: test authors and implementers are deliberately
separate. These tests define the implementation contract; implementing agents
are not permitted to edit test files.

Changes:
- tests/test_ca_multi.py: new file, hierarchies and issuer-scoped operations
- tests/test_issuer_urls.py: new file, URL generation and routing contract
- Modified test files: swept onto new ca_fixtures, added hierarchy-aware test
  cases across test_acme_client_interop, test_acme_finalize, test_acme_revoke,
  test_api_v1, test_ca_certs_store, test_ca_leaf, test_ca_revocation,
  test_ca_service, test_ca_x509, test_mcp, test_web_acme_ui, test_web_audit,
  test_web_ca, test_web_certs, test_web_crl, test_web_dashboard (15 files).
- spec/0017-multi-ca.md: added pinned interface contract section documenting:
  • Function signatures (issue_certificate, sign_csr, issue_and_store,
    sign_csr_and_store, create_intermediate_under, renew_certificate)
  • Exception types and their raising conditions (IssuerRequiredError, etc.)
  • Complete route table including removed routes, path convertors
  • Changed return types (issue_certificate now returns clamp origin + cert)
  • Parameter deletions (issue_and_store/sign_csr_and_store lose crl_url)

Interface reconciliations recorded in spec:
- issue_certificate/sign_csr now return the clamp origin alongside certificate
- issue_and_store/sign_csr_and_store lose their crl_url parameter; issuer is
  now resolved inside them
- CRL and .cer routes take :int path convertor to prevent /crl/{issuer_id}
  matching /crl/7.pem (would answer 422 from wrong handler)

Status: test suite has 303 passed, 104 failed, 86 errors (red by design).
Ruff and ruff format are clean. mypy passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
Implement multiple CA hierarchies with issuer-scoped operations, rotation
support, and per-issuer CRLs. Test suite is fully green: 582 passed, ruff
clean, mypy clean.

Changes:
- ca/service.py: hierarchies stop being singleton. create_hierarchy and
  import_hierarchy each add another hierarchy. New create_intermediate_under
  (rotation path), retire (refuses to remove last active issuer), and
  renew_in_place (same key, same name, same row, longer validity—so
  certificates issued earlier keep verifying). Both create_intermediate_under
  and renew_in_place refuse an imported root, because cabin holds no private
  key for one.
- ca/certs.py: issuer is resolved inside issue_and_store/sign_csr_and_store,
  which consequently lose their crl_url parameter and now return
  Issued(row, capped_from).
- ca/crl.py: one CRL per issuer, filtered on the leaf's issuer and signed by it.
- ca/leaf.py: AIA caIssuers on every leaf, and public_http_origin—CDP and AIA
  URLs are forced to http, never https, because otherwise validating a cabin
  certificate would require fetching a CRL over TLS, which would require
  validating that certificate.
- ca/x509.py: path_length chosen when a root is created; renew_certificate as
  a pure helper.
- web/crl_ui.py: /crl/{issuer_id:int} and /ca/{ca_id:int}.cer routes. The :int
  convertor is deliberate—with a plain placeholder, /crl/{issuer_id} also
  matches /crl/7.pem and answers 422 from the wrong handler.
- Web UI, REST API, MCP tools and ACME finalize all carry an issuer through,
  and a validity period capped to the issuer's remaining life is now reported
  instead of silently applied.

Also fixed: a bug in create_hierarchy whose IntegrityError-to-CAExistsError
backstop assumed it knew why the constraint failed and therefore misreported
a NOT NULL violation as "a CA already exists".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
…lf-signature

A mutation harness broke ten spec 0017 behaviours and found a blind spot:
renew_in_place generating a fresh key instead of reusing the stored one
was caught by nothing.

Two causes, both fixed here:

1. openssl verify -CAfile does not check the trust anchor's own signature.
A renewed root whose self-signature was cryptographically invalid passed
the test written to prove that renewal without rekey is safe. Added a
comment to the chain-verification helper in tests/test_ca_multi.py
recording this limitation, because several tests lean on it and spec 0021
(cross-signing) will lean on it much harder. Renewal tests now additionally
compare public keys byte-for-byte and verify the signature directly via
cryptography, independently of the chain check.

2. ca/x509.py:renew_certificate took a key parameter that was silently
ignored. The public key always came from the old certificate and only
parent_key ever signed, so on the intermediate path the argument did
nothing. Removed the parameter; docstring now names which key signs in
which case. ca/service.py:renew_in_place was updated to match and no
longer unseals a key it never used.

Behaviour unchanged: 582 tests pass, ruff and mypy clean, same test count
as before—assertions strengthened, none added or removed.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
Add specification 0022 documenting native HTTPS support for cabin. Includes
16 functional requirements and 20 acceptance criteria, informed by targeted
spikes and source analysis.

Key design findings:

- TLS certificate rotation on live server requires uvicorn.Config / uvicorn.Server
  (uvicorn.run() builds SSLContext internally, preventing live swaps)
- Cabin's private key stays sealed at rest, materialized only into anonymous
  memfd for load_cert_chain() calls (verified with real TLS handshakes)
- When TLS enabled, plaintext listener serves only CRL and CA-certificate
  routes; CDP/AIA URLs stay http:// to avoid cert validation loops
- CDP and AIA URLs now displayed on CA page (prevents invisible distribution
  point failures)
- Certificate renewal requires real scheduled tasks—not lazy-on-access pattern
  used for CRL—because cert is presented during TLS handshake (below Python)
- Environment variable count grows beyond project's five-variable rule;
  specification includes ADR documenting this deviation

Implementation pending scope decision; this is design only.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
Add specification 0018 documenting per-issuer permissions enforcement.
Includes 14 functional requirements and 17 acceptance criteria, informed
by source analysis of issuing, revoking, and user lifecycle operations.

Key design findings:

- Enforcement cannot live in a FastAPI dependency. The issuer to check
  arrives in the request body; when omitted it is derivable only from
  the database and principal together. Therefore checks are required
  keyword-only `principal` parameters on domain functions (same pattern
  as spec 0017's `issuer_id`), making "every entry point is covered"
  true by construction rather than by discipline.
- Issuing and revoking require different permission lookups. Spec 0017
  requires that retired issuers can still have certificates revoked, so
  reusing the issuing grant would leave operators unable to revoke what
  a compromised, retired intermediate signed.
- ACME has no cabin user behind it, so it receives a named exemption
  constant rather than an absent principal—an absent principal would
  turn every forgotten call site into a silent bypass.
- Deleting a user must explicitly clear their grants. SQLite reuses
  primary key values; without explicit clearing, a new user could
  inherit deleted permissions.
- Visibility stays deliberately unfiltered. Inventory, dashboard, and
  audit log show everything; permissions govern issuing and revoking
  only.

Implementation pending scope decision; this is design only.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
SQLite does not enforce foreign keys unless PRAGMA foreign_keys = ON is
set per connection, and cabin never set it. Every foreign key in the
schema was decorative -- a certificate row referencing a CA that does
not exist was written without complaint. This predates the current
work but spec 0017 made it load-bearing, since multiple hierarchies
depend on certificates.issuer_id referring to a real row. The pragma
is now applied through a SQLAlchemy connect event listener so it
covers every connection the pool creates, not just the first, and it
is skipped for non-SQLite backends.

Turning it on exposed a real latent bug, which is fixed in the same
commit: acme/service.py::create_order built order, authorization and
challenge rows in a single flush with no relationship() between the
mappers. SQLAlchemy derives its cross-mapper insert ordering from
relationships, not from raw foreign key columns, so the order was
effectively unspecified and had only ever been correct by luck.
Explicit flushes now force parent-before-child. This had been latent
in every ACME order since the feature shipped.

Two new tests prove the enforcement is live, including one that takes
a second connection from the pool -- a listener attached only to the
first connection passes the naive test and fails that one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
ruff format also formats Python code fences inside markdown; this spec
was committed before that gate was run. Formatting only, inside the
fence -- no requirement text changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
…pec gaps

Spec 0022 requires an ADR for the two environment variables native TLS needs.
Cabin deliberately keeps that surface tiny -- almost everything is a setting in
the database, managed through the UI -- so the deviation is recorded rather than
made quietly. The decision rests on recoverability: a setting that can make the
UI unreachable must be changeable from somewhere that does not depend on that
UI. The startup ordering argument is supporting evidence only, since a refactor
could remove it. The TLS hostname deliberately stays out of the environment and
continues to come from the existing base_url setting.

The same commit closes two gaps in spec 0022 that the ADR's context depends on:

- Which issuer signs cabin's own certificate is now a stored binding (FR-17).
  Without it, cabin could neither issue nor renew its own certificate on any
  installation with more than one active issuer -- exactly the configuration
  spec 0017 was written to enable. Resolution yields nothing rather than
  raising: a reachable instance showing a certificate warning beats a correct
  one nobody can reach.
- Retiring the bound issuer is refused, including through the cascade from
  retiring its root, because otherwise the symptom appears months after the
  cause.
- Renewal now requires a minimum gain before it proceeds. Once the bound issuer
  has less life left than the renewal window, every certificate cabin issues
  itself is clamped to that issuer's expiry and lands straight back inside the
  window; a naive scheduler would re-issue every hour, adding thousands of
  inventory rows and sealed keys a year.
- The renewal scheduler gained tests, which the spec required but never covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
…harness

Infrastructure only: cabin can now be started as a real process with two
listeners and inspected from a test. No certificate lifecycle, no renewal and
no scheduler -- those follow in later phases, tests first.

cabin starts through uvicorn.Config plus uvicorn.Server instead of
uvicorn.run(). A spike established that uvicorn.run() builds the SSLContext
internally and never exposes it, so there would be no handle on which to swap a
certificate later. With TLS enabled a second, plaintext listener is bound for
the public PKI routes, because CDP and AIA URLs must stay http:// or validating
a cabin certificate would require fetching a CRL over TLS.

Two traps found by reading uvicorn's source rather than by hitting them:

- Two servers in one process fight over SIGTERM. Server.serve() installs its
  handlers with signal.signal and this version has no opt-out, so the second
  server to start wins the signal and sets should_exit only on itself, leaving
  the first alive until the container runtime sends SIGKILL. Both are now
  supervised explicitly; verified end to end that one SIGTERM stops both in
  under half a second with no process left behind.
- The plaintext app cannot reach the database the usual way, since crl_ui read
  app.state directly and the second app has no lifespan of its own. Those reads
  are now dependency-injected.

create_app gains an optional TLS manager, established in the lifespan next to
app.state.config. None is a supported value meaning TLS is off, so all 584
existing tests keep working untouched.

tests/live_server.py is the harness the rest of this spec is tested through.
Every existing test uses Starlette's TestClient, which never opens a socket,
and nothing in 0022 can be exercised that way. It starts a real subprocess,
polls the socket for readiness rather than sleeping, tears down hard so no
process or port leaks between tests, and can read a peer certificate off a
fresh connection or hold a keep-alive connection open across a swap.

With TLS on, the primary listener binds but cannot complete a handshake yet:
nothing loads a certificate into the context until a later phase. That is
expected at this point and is why the harness is exercised over plain HTTP here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
…the code

Four authors wrote these in parallel, each covering a module they will not
implement, so no test can be bent to fit its own implementation. They are red
by design: cabin/tls.py is still the phase 0 placeholder.

Coverage: the sealed TLS key and its in-memory materialisation, the three
certificate stages read off the wire, the stored issuer binding, the renewal
scheduler and the storm guard, the two listeners and their supervision, the
configuration surface, and the operator-facing URLs.

Two things worth recording.

The public listener test found a real defect in the phase 0 commit:
create_public_app builds a bare FastAPI(), so /docs, /redoc and /openapi.json
answer 200 on the plaintext PKI port -- the one listener that must serve
nothing but the CRL and the CA certificate. Confirmed by hand before accepting
the report. The test stays red until that is fixed.

The TLS author validated its own tests were satisfiable rather than merely
red: it wrote a throwaway implementation, ran the suite against it, found that
one test caught a genuine bug in that scratch code (a temporary file left
behind when os.replace fails), and reverted everything. Nineteen of twenty
passed against a correct implementation, which is evidence the assertions
measure something rather than just failing on a missing import.

Notable assertions, in the spirit of what this project has been bitten by
before: a watcher thread polls the TLS directory during a live renewal and
must never observe a loadable plaintext key, so an implementation that writes
then unlinks is caught; the issuer binding test picks the second of two
issuers, so a silent default to the sole or first one fails; the renewal storm
test asserts both that ten consecutive ticks add nothing when the issuer is
nearly expired and that a healthy issuer produces exactly one replacement,
because either half alone cannot tell a correct refusal from a broken loop;
and the displayed CRL and AIA URLs are compared character for character
against the extensions parsed off a real certificate, so a page that
prettifies or recomputes them drifts visibly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
…the contract

Four lanes built this in parallel against tests they did not write. 656 tests
pass; ruff, ruff format and mypy are clean.

cabin can now serve HTTPS itself. It starts self-signed when no CA exists,
issues itself a certificate from its own CA once one does and swaps it onto the
live SSLContext without a restart, and serves the same certificate after a
restart. Its private key is sealed at rest with the same store that protects
every CA key, and is materialised only into an anonymous memfd for the duration
of one load_cert_chain call -- a descriptor with no name in any filesystem. The
README says plainly that this key is no exception to the no-plaintext-keys
rule, because it is not one.

Which issuer signs cabin's own certificate is a stored binding. Without one, a
multi-issuer instance keeps serving self-signed rather than failing: reachable
with a warning beats correct and unreachable. Retiring the bound issuer is
refused, including through the cascade from retiring its root, because
otherwise the symptom would appear months after the cause.

A second, plaintext listener serves the CRL and the CA certificate, because
those URLs are baked into certificates and must stay http:// -- fetching a CRL
over TLS to validate the certificate that TLS depends on does not terminate.
Those URLs are now displayed on the CA page, character for character as
embedded, so a wrongly mapped port is visible in seconds instead of years.

Renewal runs on a clock rather than on access: a certificate is presented
during the handshake, below Python, so the only request that could trigger a
lazy renewal is one the expired certificate has already prevented. The loop
survives a tick that raises, and declines to re-issue unless the replacement
gains meaningful life -- otherwise, once the bound issuer is nearly expired,
every certificate is clamped straight back into the renewal window and an
hourly loop would add thousands of rows and sealed keys a year.

Three things found rather than assumed:

- create_public_app built a bare FastAPI(), so /docs, /redoc and /openapi.json
  answered 200 on the plaintext PKI listener. Found by a test author writing
  the contract for code someone else had shipped, which is the entire reason
  those roles are separate here. The constructor arguments that prevent it are
  now documented as load-bearing.
- ensure_current only loaded material into the live context when something had
  changed, so a restart with valid material on disk served an empty context
  forever. Found and fixed by its own implementer.
- FR-8 fell through the gap between two lanes, each believing it belonged to
  the other, and was implemented afterwards: with more than one worker the
  swap reaches one process only, leaving the rest serving an expired
  certificate silently. cabin now refuses to start in that configuration.

TLS stays opt-in and off by default; the reverse-proxy deployment is unchanged.
Two new environment variables were needed, which breaks this project's
five-variable rule; ADR 0002 records why, resting on recoverability -- a
setting that can make the UI unreachable must be changeable without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
…able to fail

Two gaps found by the mutation harness rather than by review.

FR-17 requires that retiring the issuer which signs cabin's own TLS
certificate is refused. It was never implemented. The lane that reported on it
extracted the retire_targets helper but never wired up the refusal, and that
report was accepted without checking. The harness found it while trying to
build a mutation for the guard and discovering there was nothing to mutate --
a failure mode a green suite cannot produce, because absent code has no
failing test.

Verified by hand before fixing: with TLS on, two active issuers and one bound,
POST /ca/{bound}/retire returned 303 and the row moved to retired. Cabin's own
certificate would then die at the next renewal with nothing linking the
symptom to the act that caused it, which is exactly why the spec asks for a
refusal rather than a warning.

The guard delegates to retire_targets rather than comparing against the id in
the URL, so retiring a root cascades correctly -- a check on the named row
alone passes the obvious test and leaves the obvious way around it. It is
inert when cabin is not serving its own TLS, so a reverse-proxy installation
is not obstructed by a setting that has no effect there. And it reads the
stored binding directly rather than through resolve_tls_issuer, which persists
a default as a side effect and has no business being triggered from a read
path.

The second gap: test_sigterm_stops_both_listeners passed even with cabin's own
signal cross-wiring deleted outright. uvicorn's capture_signals() restores the
previous handler and re-raises on shutdown, so the signal cascades regardless
of anything cabin does -- meaning the test proved only that both listeners
eventually stop, which stayed true either way, and the code written
specifically not to depend on that relay could have been deleted silently.

A new test drives _serve with stub servers, so uvicorn's relay is out of the
picture entirely and the mechanism itself is observed. Its author confirmed it
bites by deleting the cross-wiring, watching the test fail, and restoring the
file -- rather than assuming. The end-to-end test is kept; it is still worth
having, it was just never sufficient alone.

661 tests pass; ruff, ruff format and mypy clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
Spec 0018 was written before spec 0022 landed, and one of its factual claims
stopped being true in between.

There is an eighth issuance entry point. TlsManager._issue calls
issue_and_store so cabin can issue its own TLS certificate. FR-5 argued that a
required principal parameter turns a future eighth door into a type error
rather than a silent bypass; the future arrived early and the mechanism holds.
It gets a named system principal rather than an absent one, on the same
reasoning already applied to ACME: absence turns every forgotten call site into
a bypass, a named constant does not.

That door needed care because the surrounding code swallows every issuance
failure and returns False, so a refusal there would leave cabin on its
self-signed certificate indefinitely with one log line. The grant exceptions
are deliberately NOT added to that except clause: their unreachability is the
property worth protecting, and catching them defensively would mean that if
someone later restricts the TLS principal, cabin quietly stops renewing
instead of failing where a test can see it.

The swallow does have a defect that predates this spec, and it is now
addressed: it collapses transient failures with terminal ones. A retired or
unknown bound issuer never resolves itself on the next tick, so such an
instance sits on its self-signed certificate forever. A last-error field, a
third banner state and an audit event make it findable. The event fires only
on the transition into failure, because one per tick would write two dozen a
day and bury the one that mattered, and the banner's new state is gated on an
active issuer existing -- the self-signed text says "create or import a CA",
which is exactly the wrong instruction for an instance that has one and failed
anyway.

FR-10 claimed cabin enables no PRAGMA foreign_keys. That was true when it was
written and stopped being true in ddd42cf. The corrected failure mode is not
silent orphan inheritance but an IntegrityError that makes a granted user
undeletable. Consequently migration 0010 must not declare ondelete on the
grant tables, or the database would silently do the cleanup the acceptance
criterion exists to prove the application does. Checking this also turned up
that sessions.user_id does declare ON DELETE CASCADE, so the precedent cited
for explicit cleanup was overstated; the divergence is now recorded so nobody
harmonises it later and guts the criterion.

Finally, what 0018 does not protect is now stated at the top rather than in a
footnote. ACME keeps its exemption until 0019, so an admin with no grants can
still obtain a certificate through it. "Permissions are implemented" is
exactly the sentence someone will quote later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
Tables and models only; no behaviour changes and no enforcement yet, so all
661 tests still pass unchanged.

Two join tables, one for users and one for API tokens. Tokens carry their own
grants rather than inheriting any, because this project deliberately gives
them no owning user -- and MCP authenticates with the same tokens, so it
inherits token grants for free.

Neither foreign key declares ondelete, which looks like an omission and is
not: cabin now enforces foreign keys on SQLite, so a cascade would have the
database silently clean up grants on user deletion -- precisely what an
acceptance criterion exists to prove the application does. The migration says
so in place, including how this deliberately diverges from sessions.user_id,
which does cascade.

Shared grant fixtures follow the pattern spec 0017 established for hierarchy
fixtures, so the suite does not grow another copy of the same setup in every
file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
…he code

Two authors, neither of whom will implement what they specified here. Red by
design: the grant lookups, the principal types and the enforcement parameter
do not exist yet.

The centre of it is that a permission enforced at one door and forgotten at
another is not a permission. The doors are enumerated explicitly rather than
testing one helper and trusting it propagates, and there are eight of them --
spec 0018 predates native HTTPS, so cabin issuing its own certificate is an
eighth caller the original text did not know about.

Both exemptions are pinned as pairs in single tests: ACME issuing with zero
rows in either grant table while an ungranted admin is refused against the
same database, and the same for cabin's own certificate. Split across two
tests, either pair would pass on a build that enforces nothing anywhere. The
TLS test also strips the creating admin of every grant first, so success
cannot be "everyone in this fixture happened to be unrestricted".

One test greps the source for the two exemption constants. It is the only
check that survives an exemption re-expressed as an absent principal, which no
behavioural test can distinguish from a correct one.

Revocation is tested through a retired issuer, because spec 0017 requires that
to keep working: an operator who retires a compromised intermediate must not
thereby lose the ability to revoke what it signed. That single test is what
stands between a suite that looks green and a revocation lookup that reuses
the issuing one.

The deletion criterion asserts against the migrated schema that no ondelete is
declared, not just that grants are gone afterwards. Without that, it passes
with the application doing nothing and is really measuring SQLite.

The second author validated satisfiability rather than settling for red: it
built a throwaway reference implementation in a separate worktree, ran both
suites against it, found and fixed two genuine bugs in its own tests, and
discarded the worktree. Sixty tests passed there.

Two tests are green today for the wrong reason -- nothing is enforced, so
granting is a no-op -- and say so in place rather than being counted as
coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
…d at every door

735 tests pass; ruff, ruff format and mypy clean.

Who may issue from which CA is now a grant, held by users and by API tokens.
Tokens carry their own rather than inheriting any, because this project gives
them no owning user -- and MCP authenticates with the same tokens, so it
inherits them for free.

The enforcement is a required keyword-only principal on the three domain
functions, not a FastAPI dependency. A dependency runs before the request body
is bound, and the issuer to check arrives in the body; when it is omitted it is
only derivable from the database and the principal together. MCP has no
dependency layer at all. So the parameter is what makes "every door is covered"
true by construction: a ninth door becomes a type error rather than a silent
bypass. There is deliberately no default.

Two lookups, deliberately different, and confusing them is the sharpest bug
this spec could have shipped. Issuing intersects with the active issuers;
revoking is blind to status, because spec 0017 requires a retired issuer's
certificates to stay revocable -- an operator who retires a compromised
intermediate must not thereby lose the ability to revoke what it signed.

Two exemptions, both named constants and neither of them absence: ACME, which
has no cabin identity behind it, and cabin issuing its own TLS certificate. An
absent principal would turn every forgotten call site into a bypass. The grant
exceptions are deliberately absent from the swallow in tls.py: their
unreachability under the system principal is the property worth protecting, and
catching them defensively would mean a later restriction silently stops cabin
renewing its own certificate instead of failing where a test can see it.

Deleting a user clears their grants. Since foreign keys are now enforced on
SQLite and the grant tables carry no cascade, a forgotten cleanup is not orphan
rows but an IntegrityError making a granted user undeletable.

What this does not protect, stated plainly because someone will quote the
commit subject later: ACME routes around grants entirely until spec 0019 closes
it. On an instance with ACME enabled, an admin holding no grants can still
obtain a certificate.

The sweep of 53 existing call sites was done by granting each actor the issuer
its scenario needs, never by promoting it to superadmin. That shortcut would
have turned the whole suite green instantly and left every one of those tests
permanently unable to detect a permission bug.

Three lanes independently reported the same two broken tests: both retired the
only intermediate on the instance, which spec 0017 refuses, so the scenario
they existed to exercise was unreachable. Fixed by adding a second hierarchy,
with nothing they assert weakened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
14 requirements, 17 acceptance criteria. Design only.

The URL selects the issuer and the EAB key authorises it. Only the directory
and new-account paths gain an issuer segment; the rest are opaque object URLs
that already know their issuer through the account.

On spec 0018's ACME exemption: it is narrowed, not removed, and the spec says
so in those words. There is still no cabin identity behind a finalize, so the
exemption stays unrestricted -- but it stops choosing anything, because the
issuer now comes from the account. The grant check moves to the one moment an
operator actually decides something: creating an EAB key. That gives a
complete chain from grant to EAB key to account to certificate. With external
account binding required, an ungranted admin obtains no certificate over ACME
-- the sentence 0018 could not write. With it off, they still do, and cabin
says so on the ACME page rather than leaving it to be discovered.

Deriving an issuance-time grant from whoever created the EAB key was
considered and rejected: grants are read fresh, so an unattended host would
start failing renewals days later because someone unrelated was demoted.

Six things the plan did not anticipate, found by reading rather than assuming:

The EAB URL binding looks like the issuer check and is not one. The existing
parser already refuses a binding whose inner JWS url is not the published
new-account URL, which now contains the issuer id -- so it is very tempting to
conclude a key for one issuer cannot be used at another. It can: the client
builds and MACs that inner JWS itself and simply signs over the other URL.
Only the stored column refuses it. This also dictates how the test must be
written, and a naive one measures the parser and passes against an
implementation with no issuer check at all.

Two exceptions become reachable at finalize for the first time once the issuer
comes from the account, and uncaught they produce a bare 500 with no problem
document and no replay nonce -- stranding a client that has already spent one.

The order endpoint's readiness check asks the wrong question now: an instance
can have four active issuers while this account's is retired.

The directory URL is rendered at four call sites and three templates, and the
MCP field that returns a single one cannot answer the question any more.

The index Link header has no correct global answer once the shared directory
URL ceases to exist; it is emitted only on the per-issuer paths, with the
deviation recorded.

And the re-registration trap has exactly one safe answer, because the account
key's thumbprint is globally unique: the alternatives are returning the
existing account silently or rebinding it to the path's issuer, and the second
would let a bare account key move itself across hierarchies by visiting a URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
753 tests pass; ruff, ruff format and mypy clean.

The URL selects the issuer and the external account binding authorises it.
Only the directory and new-account paths carry an issuer segment; account,
order, authorization, challenge and certificate URLs are opaque and already
know their issuer through the account. The shared directory path is gone with
no alias.

This is what closes the hole spec 0018 knowingly left, and the closure is
narrower than "ACME now respects grants". The exemption stays -- there is
still no cabin identity behind a finalize -- but it stops choosing anything,
because the issuer comes from the account. The grant check moves to the one
moment an operator actually decides something: minting an EAB key. That gives
a chain from grant to key to account to certificate. With binding required, an
ungranted admin gets no certificate over ACME. With it off, they still do, and
the ACME page now says so where the toggle is.

Deriving an issuance-time grant from whoever minted the key was rejected:
grants are read fresh, so an unattended host would start failing renewals days
later because someone unrelated was demoted.

The subtle part, and the reason the test for it is written the way it is: the
binding parser already refuses a binding whose inner JWS url is not the
published new-account URL, which now contains the issuer id -- so it looks as
though a key for one issuer could not work at another. It can. The client
builds and MACs that inner JWS itself and simply signs over the other URL.
Only the stored column refuses it. The test therefore signs over the wrong
issuer's URL with the right issuer's key, because a test that does the obvious
thing measures the parser and passes against an implementation with no issuer
check at all.

Re-registration has exactly one safe answer. The account key's thumbprint is
globally unique, so the database cannot hold two accounts for one key; the
alternatives to refusing are returning the existing account silently or
rebinding it, and the second would let a bare account key move itself between
hierarchies by visiting a URL. Refused -- while re-registering at the
account's own directory still returns 200 with no binding demanded, so the
check cannot be satisfied by refusing everything.

Two exceptions became reachable at finalize once the issuer stopped being
resolved, and uncaught they produced a bare 500 with no problem document and
no replay nonce, stranding a client that had already spent one.

The order endpoint's readiness check asked whether any issuer was active; an
instance can have four while this account's is retired.

The sweep touched 87 literal paths across 11 files reaching 98 tests,
including a second private copy of the client helper that a search for imports
would have missed. The helper's issuer is keyword-only with no default on
purpose: a default would make five acceptance criteria pass on a single
hierarchy, where "used the account's issuer" and "fell back to the default
rule" are indistinguishable.

Also corrected: the spec contradicted itself on which responses carry the
index link. FR-11's text stands; the acceptance criterion's arithmetic was
wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
… guarding it

The mutation harness gave the test client's issuer_id a default and nothing
noticed. That parameter is keyword-only with no default on purpose: a default
would make five of spec 0019's acceptance criteria pass on an instance with a
single hierarchy, where "used the account's issuer" and "fell back to spec
0017's default rule" are indistinguishable.

It went uncaught because every call site already passes the issuer explicitly,
so adding a default changes nothing any existing test observes. The protection
was the signature itself, and nothing asserted anything about the signature.
Spec 0018 had already hit this and solved it for its own required parameter;
this is that test's counterpart.

Its author verified it bites rather than assuming: gave the parameter a
default, watched the test fail, restored the file and confirmed it byte-
identical. The related helper was checked too and needs nothing -- it takes no
issuer of its own and inherits it from the client.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
11 requirements, 16 acceptance criteria. Design only.

An intermediate may be restricted to the names it is allowed to sign. The
extension is the easy half; the half that matters is that cabin checks its own
constraints before signing. Writing the extension and trusting the client
means cabin cheerfully issues certificates that every validator then rejects,
and the operator finds out when a service breaks. The check sits beside the
SAN validation, because every issuance path runs through there -- including
ACME, where nobody is watching and the failure looks like a renewal that
quietly stopped.

Constraints live in the issuer's certificate, not in a column, so the rule and
the certificate cannot disagree. They are fixed at creation, and a renewal
carries them over unchanged: a routine renewal must not silently widen what a
CA may sign.

Nine things reading the code turned up that the plan had not:

There are two intermediate-creation paths, not one. Constraining only the
rotation path would leave the first intermediate of every hierarchy -- the one
most instances actually use -- unconstrained.

The service inserts and flushes the root before building the intermediate, so
a constraint that fails to parse would leave an orphan root behind for an
operation the operator was told had failed. Parsing therefore happens at the
route.

ACME maps every issuance error to a server-internal problem document. A
name-constraint refusal answered that way tells a correct client the server is
broken and invites it to retry forever. Making the refusal a subclass of the
existing error means all six doors reject it with no changes and none can be
forgotten, while ACME matches it first and answers with a rejected-identifier
problem instead.

Cabin's own TLS certificate goes through the same check, so constraining the
bound issuer to exclude cabin's own hostname would stop its renewal one to
three months later. That path already fails safely; this makes it measured.

The code cannot live in the x509 module without an import cycle -- the same
placement argument spec 0017 made for its URL helper.

Two matching rules are counter-intuitive and are pinned so cabin agrees with
the validator rather than being merely stricter: the common name is checked as
a DNS name only when no DNS subject alternative name is present, and the IP
form covers both address families, so an IPv4-only permitted set forbids every
IPv6 address while a DNS-only one forbids no address at all.

An imported CA may carry constraint forms cabin does not implement; a name of
such a form is refused rather than ignored.

And a verified library detail with teeth: constructing a network constraint
non-strictly would turn 10.1.2.3/8 into 10.0.0.0/8 -- silently widening a
constraint the operator wrote narrowly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
66 tests, red at exactly the symbols that do not exist yet.

The centre is that cabin refuses before signing rather than writing the
extension and hoping the client checks. One test walks all six ordinary doors
plus ACME against a single database, refusing a name outside the constraint
and then issuing an allowed one from the same issuer and anything from an
unconstrained one -- so a build that enforces nowhere and a build that refuses
everywhere both fail it.

ACME's refusal is asserted to be a rejected-identifier problem carrying a
nonce and releasing the order back to pending, not a server-internal one. A
correct client told the server is broken retries forever.

Each matching rule has its counter-case, because each is a place
implementations go wrong: an empty permitted set allows everything rather than
nothing; DNS matching is by label boundary, so a constraint must not match a
domain that merely ends with the same letters; excluded beats permitted; an IP
subtree holds a network, and parsing one non-strictly would silently widen
what the operator wrote; the IP form spans both address families, so an
IPv4-only permitted set forbids every IPv6 address while a DNS-only set
forbids no address at all; the common name is checked as a DNS name only when
no DNS subject alternative name is present, tested in both directions; and a
name whose form cabin cannot evaluate is refused rather than ignored.

Renewal is asserted to carry the extension over with identical bytes and
criticality. A routine renewal that silently widened what a CA may sign is
the kind of thing nobody notices for years.

Where a test depends on what an issuer's certificate actually contains, it
parses the certificate directly rather than inferring it from a chain check --
this project has a recorded finding that openssl verify does not check the
self-signature of what it is handed.

Four tests pass already and are regression guards, not accidental green: a
root never takes constraints, an unconstrained hierarchy stays unconstrained,
an unconstrained renewal adds no extension, and no constraint column exists in
the schema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
834 tests pass; ruff, ruff format and mypy clean.

An intermediate can be restricted to the names it may sign. The extension is
the easy half. The half that matters is that cabin checks its own constraints
before signing, beside the SAN validation, because every issuance path runs
through there -- including ACME, where nobody is watching and a refusal
surfaces as a renewal that quietly stopped working. Writing the extension and
trusting the client would mean cabin cheerfully issues certificates that every
validator then rejects.

The refusal is a subclass of the existing issuance error, so all six ordinary
doors reject it with no changes at all and none of them can be forgotten. ACME
is the one exception and matches it first: previously every issuance error
there became a server-internal problem document, which tells a correct client
the server is broken and invites it to retry forever. It now answers with a
rejected identifier.

Constraints live in the issuer's certificate rather than a column, so the rule
and the certificate cannot disagree, and a renewal carries them over byte for
byte -- a routine renewal that silently widened what a CA may sign would go
unnoticed for years.

Three details with teeth, all verified against the library rather than assumed:
an IP constraint holds a network, and parsing it non-strictly would turn
10.1.2.3/8 into 10.0.0.0/8, silently widening what the operator wrote, so host
bits are refused instead; an empty permitted set means everything is allowed,
not nothing, and produces no extension rather than an empty one; and the common
name is checked as a DNS name only when no DNS subject alternative name is
present, because checking it always would make cabin stricter than the
validator and refuse certificates that would have been fine.

Both intermediate creation paths take constraints, not just the rotation one --
otherwise the first intermediate of every hierarchy, the one most instances
actually have, would stay unconstrained. Parsing happens at the route, because
the service inserts and flushes the root before building the intermediate and
a late failure would leave an orphan root behind.

Also fixed here, and worth more than it looks: a test scoped its assertions to
a row by taking a fixed 500-character slice of the page, and the new markup
overran it. That was resolved during implementation by compacting production
markup until it fit, with six characters to spare -- a test dictating how much
markup a page may contain. The helper now selects the row's actual element,
and what was compacted only to fit the budget has been restored. Two other
test files use the same character-window technique and carry the same risk;
recorded, not yet changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
14 requirements, 18 acceptance criteria. Design only, and the last of the
0.2.0 cycle.

A cross certificate is a second certificate for a root that already exists --
same subject, same public key -- signed by a different root, so relying
parties that trust the old one have a path to certificates issued under the
new one. It is in scope because of devices whose trust store cannot be
reached and which will outlive a root generation. For everything else the
spec says plainly that running two hierarchies side by side is the better
transition, and cross-signing is the most error-prone mechanism in PKI.

What the code turned up, in order of how much it costs:

A root created with the default path length can never cross-sign anything,
ever. The cross path is one certificate longer, so the signing root needs a
path length of at least two, cabin's default is one, and renewal carries
BasicConstraints over unchanged -- so it cannot be repaired later. Cross-
signing has to be planned a root generation before it is needed, which is
itself the strongest argument for the spec's own framing.

Renewal adds an authority key identifier only when the input already carried
one, and a self-signed root carries none. Building a cross certificate with
that function would produce one without it -- precisely the hint a path
builder needs to tell a root's two certificates apart. The same branch is what
makes it the right function for renewing one.

The import path cannot reuse the existing loader: it demands a private key,
and nobody holds the key of a cross certificate someone else produced.

There are six chain-assembly sites, not the three the plan named. One of them
is the odd one out and must not inherit the new default: it builds the
"import this root" link, which has to stay the self-signed root.

A cross row would be a certificate cabin serves to every client and shows on
no page at all, because the CA page groups strictly by root and intermediate.

Revocation is stated as absent rather than implied. A cross certificate is
signed by a root, cabin publishes no CRL for a root, so there is no document
its serial could go into. Retiring it stops cabin serving the path and says
nothing to a relying party that already cached it.

And the failure this spec exists to avoid repeating: when DST Root CA X3
expired in 2021, clients broke because path building preferred an expired
route while a valid one sat beside it. An expired cross certificate therefore
drops out of the served chain automatically, not on an operator's next visit
to a page.

The acceptance criteria say where a direct signature check is required rather
than a chain check, because nearly every assertion here is chain-shaped and
openssl verify does not check the self-signature of what it is handed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
…drop itself

927 tests pass; ruff, ruff format and mypy clean. Last spec of the 0.2.0 cycle.

A root already in cabin can sign another root, or a cross certificate produced
elsewhere can be imported -- a second certificate for a root that already
exists, same subject and same public key, so relying parties trusting the older
root have a path to certificates issued under the newer one. The import
compares the public key, not just the name: without that an operator could
staple an unrelated certificate into their own chain.

A leaf under a cross-signed root then has two valid paths, and both are served
-- the one that satisfies the most relying parties by default, the other
alongside, including through ACME's alternate-chain mechanism, which cabin did
not previously emit.

The failure this exists to avoid repeating is not a signing mistake. When DST
Root CA X3 expired in 2021, clients broke because path building preferred an
expired route while a valid one sat beside it. So the expiry check lives where
the chain is assembled, on every request: a chain that is correct until someone
visits a page is not correct.

Three things reading the code decided the design:

Renewal adds an authority key identifier only when its input carried one, and a
self-signed root carries none -- so building a cross certificate with that
function would omit exactly the hint a path builder uses to tell a root's two
certificates apart. Cross-signing got its own function; renewal remained the
right one for renewing a cross certificate later.

The existing import loader demands a private key, which nobody holds for a
cross certificate someone else produced.

And one site that assembles a chain must deliberately not follow the new
default: the dashboard's "install this root" link, which has to keep pointing
at the self-signed root or an operator installs the wrong thing. It carries a
comment saying so, because the next reader will see the two sites side by side
and try to make them agree.

An operational fact worth stating plainly: a root created with cabin's default
path length can never cross-sign, and renewal carries that constraint over
unchanged, so it cannot be fixed later. Cross-signing has to be planned a root
generation ahead. The attempt is refused with an error that says this rather
than producing a certificate no validator will build a path through.

Cabin cannot revoke a cross certificate -- it is signed by a root, and cabin
publishes no CRL for roots, so there is no document its serial could go into.
Retiring it stops cabin serving the path and says nothing to a relying party
that already cached it. The spec states that rather than implying retirement is
revocation.

Also closed here: two files were left unowned when the work was divided, so the
two new audit actions did not exist and both cross routes raised on success,
and the REST and MCP surfaces could not describe a cross certificate at all.
That was a gap in the split, not in the lanes -- each one flagged it and stayed
inside its boundaries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
A mutation harness removed load_cross's public-key check and every
existing test stayed green. Traced why: ca_service.import_cross resolves
which existing root a cross certificate is for via its own _same_ca
helper, which already compares subject and public key before load_cross
runs -- so the only test built for this attack (a matching subject over a
different key) is refused one layer out, and load_cross's own comparison
was never reached by any input able to tell its presence from its
absence. Adds a direct unit test of load_cross, bypassing import_cross,
with the forged certificate's SubjectKeyIdentifier set to the real root's
own so the SKI check a few lines below can't be what refuses it either --
isolating the one check this test exists to pin.
Six specifications landed in this cycle and none of them had a changelog
entry -- every work split assigned it to a reviewer role that was never
dispatched. This writes the whole section, for an operator deciding whether
and how to upgrade rather than for someone reading the diff.

Three things are stated before any feature group, because getting them wrong
hurts.

A 0.1.x database cannot be carried forward. Migrations were rewritten in place
rather than added to, so Alembic -- which tracks a revision id and nothing
about the schema -- applies whatever revisions are missing, skips the ones the
database already carries including the rewritten ones, and then reports itself
current while the schema is wrong. The failure appears at the first query, not
at startup. The only path is an empty data directory, and the entry now spells
out what that costs: discarding secret.key discards the CA, so the old root
comes out of every trust store, everything 0.1.0 issued has to be issued
again, and those old certificates become unrevocable in the same moment,
because the CRL that would carry them would be signed by a key that no longer
exists.

Cross-signing has to be planned a root generation ahead: a root created with
the default path length can never cross-sign, and renewal carries that
constraint over unchanged.

And permissions do not bind ACME unless external account binding is required.
With it off, an administrator holding no grants still obtains certificates.
That is the documented boundary, and without saying so "permissions are
implemented" is the sentence someone will quote.

Spec 0017's own description of the migration hazard was corrected in the same
pass -- in two places, not one. It named a specific revision number, which
stopped being accurate the moment spec 0018 appended another migration: same
damage, different route. The new wording describes what Alembic actually
tracks, so it survives further revisions being appended.

Also recorded: turning TLS on is a four-step sequence and one of those steps
fails quietly, so it is given in order with a pointer to the README rather
than as scattered facts; and an operator repointing an ACME client needs to
read the issuer id off a page first, because it cannot be derived from the old
URL.

Version bumped to 0.2.0. 928 tests pass; ruff, ruff format and mypy clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
CI failed on a test that passed here. It compared the text `openssl crl
-issuer` printed against what cryptography formats: the runner renders a name
with spaces around the equals sign, this machine does not. Nothing about cabin
was wrong -- the assertion was hostage to whichever openssl happened to be
installed.

The question that test actually asks, whether each CRL is issued by the right
intermediate, is answered exactly by comparing parsed name objects, with no
string formatting anywhere. The openssl call stays, but only as evidence that
a real tool can parse what cabin produces; it no longer answers a question
cryptography answers precisely.

Two more instances of the same pattern were found by searching for it rather
than by waiting for another red run: one asserting on "unable to get local
issuer certificate", one on the word "permitted". Both now assert openssl's
numeric verification error code -- part of its documented API, unlike the
prose after the colon. A bare nonzero exit would have been the easy fix and
would have been weaker: it accepts a chain broken for an unrelated reason,
which is exactly what those two tests exist to rule out.

This is the failure Rule 14 is about. The suite was green here through six
specifications and a mutation harness, and none of that establishes that the
platform accepts it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
The feature list described a version that stopped existing six specifications
ago. Multiple hierarchies, per-issuer permissions, name constraints and
cross-signing were all absent from the front page of a release that adds them.

Both features with limits that bite carry the limit where someone reading
about them will see it, not only in the changelog: permissions do not bind
ACME unless external account binding is required, and cross-signing needs a
signing root created with a path length of at least two, which cabin's default
is not and renewal cannot repair.

Two ACME traps found by running the interoperability gate rather than by
review, since both produce a refusal that is hard to interpret if you do not
already know the rule. http-01 validation always dials port 80 on the resolved
address, never cabin's own listen port. And a loopback identifier is refused
unconditionally as an SSRF guard -- the first thing anyone testing on a laptop
will reach for. The setting that widens private validation targets does not
touch that branch, which the note says explicitly so nobody spends an hour on
it; wildcard DNS to a LAN address is the workable alternative, and is what the
gate itself used.

Two stale endpoints corrected in passing: the CRL routes are per issuer now
and the shared ones are gone, and the certificate route for any CA row was
never documented at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
…to one

11 requirements, 13 acceptance criteria. Design only.

The operator clicked through a fresh 0.2.0 instance and found /ca overcrowded:
creating a root, creating an intermediate and managing everything on one page.
The cause is accumulated rather than chosen -- 0017 added the hierarchy list
and its actions, 0020 the name constraints, 0021 cross-signing, each
reasonable on its own and nobody looked at the whole. With two hierarchies it
is nine forms, eleven when both roots can cross-sign.

Four pages instead of one: a list, a page per hierarchy carrying that
hierarchy's own actions, a create page and an import page, with a rail group
mirroring the one Certificates already has. The same move spec 0015 made when
it split issuing from CSR signing.

All POST routes keep their paths. That is what makes this cheap: /ca/create
appears in about ninety tests but only nine helper definitions, and none of
them change. What changes is where an error re-renders -- and one of those
fixes a real defect, since creating an intermediate currently raises a bare
HTTPException and loses five filled-in fields.

Seven things reading the code turned up that the plan had not:

A bare /{ca_id} route swallows /ca/new, /ca/import, /ca/{id}.pem and
/ca/{id}.cer -- the last of which lives in the CRL router, is included after
the CA router, and is what the dashboard's install-the-root banner links to.
This is spec 0017's /crl/7.pem collision again, this time across module
boundaries. The integer converter is required and the criterion measures all
five URLs against the running app rather than reasoning about precedence.

ca_certificates has no expiry column, so the plan's claim that a plain list
needs no certificate parsing was wrong. The list still parses one PEM per
hierarchy; what it drops is the distribution and directory URLs, the chain
computation and the constraint reads.

Re-filling the intermediate form is not enough on its own, because it sits
inside a collapsed details block -- the restored values would be invisible
beneath an error about fields that appear to have vanished. The block has to
come back open.

Two more existing tests break than the plan listed, and one of them stops
proving anything rather than failing: a counter-check that the create form on
the same page does grow the constraint fields is no longer on the same page
once the import form moves.

GET /ca/import shares a path with POST /ca/import, separated only by
Starlette's method resolution, so that is asserted rather than assumed.

And renew and retire keep their bare HTTPException, deliberately out of scope
-- but the TLS-issuer retire refusal is a carefully written sentence that
still reaches the operator as a JSON error document.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
nofuturekid and others added 28 commits August 8, 2026 21:56
Sixteen tests, all red, every failure a clean 404 for a route that does not
exist yet or the old import template still being present. No fixture fails to
build, so this is correct red rather than a test written wrong.

The CA key export carries the weight, because whoever receives that file can
issue as that CA indefinitely and cabin will see none of it. Every guard is
asserted in both directions in one test -- a superadmin succeeding beside the
admin and viewer being refused, since checking only the refusal would pass an
implementation that refuses everyone. The bundle is opened with its password
and the private key compared against the certificate's own public key, because
a non-empty response proves nothing. A missing password must give a clean 400
rather than a framework validation error, and neither refusal may write an
audit event while the success must.

Which rows may be exported is tested by whether a key exists, not by where the
certificate came from: an imported intermediate does have a stored key and is
offered, while an imported root, the signing root of an imported cross
certificate, and every cross row are not.

The disk test runs a watcher during the export and compares the directory
before and after, excluding SQLite's own journal files by name so their churn
cannot mask a genuinely leaked temporary key.

Its author caught one of its own tests passing vacuously: the headless-Chrome
rail probe was green against today's twelve-entry rail, which spec 0023 had
already fixed to scroll internally. It now also asserts the rail really carries
sixteen entries and that the nav list is genuinely scrolling, so it cannot pass
on a viewport that simply had room.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
…in or out

974 tests pass; ruff, ruff format and mypy clean.

A rail group of its own with five pages. Importing a CA and importing a cross
certificate move out of the CA group; a trust bundle, a CA key and an inventory
export join them. The import POST routes keep their paths, which is what keeps
this cheap -- only the handlers and the pages they re-render on error moved.

The CA key export is the part that needed care. Cabin hands out leaf keys
today; it has never handed out a CA key, and whoever receives that file can
issue as that CA indefinitely while cabin sees none of it, because it can only
revoke what it knows about. So: superadmin rather than admin, a mandatory
password on the pattern the leaf bundle already uses, an audit event -- unlike
leaf key downloads, which deliberately write none -- and nothing written to
disk, built in memory and streamed.

Which rows may be exported turns on whether a key is stored, not on where the
certificate came from. Importing seals the uploaded intermediate's key and
leaves only the root's empty, so hiding by origin would have hidden a CA whose
key cabin genuinely holds.

Four things found by reading rather than assumed:

require_superadmin existed twice, defined independently in two route modules
and not where require_admin lives. It now lives with the other guard and both
call sites use it, rather than a third copy appearing.

The interface contract said to build the exported chain with chain_for -- but
that substitutes a cross-signed alternate as the default the moment one exists,
so a CA key bundle would have carried a foreign root instead of its own. It now
follows the self-signed lineage.

The inventory listing is paginated and clamps its page number, so asking for a
very large page would have truncated silently; the export uses the same filters
through its own query.

And a secrets failure is a clean 409 here, which the leaf download path already
established; the CA routes still turn it into a 500 and are recorded as left
alone rather than quietly skipped.

One correction made after the fact, because it was the same mistake this
project made once before in the other direction. The new certificate-authority
select overflowed a narrow viewport by 73 pixels, since a native select will
not shrink below its longest option -- and that was first resolved by
shortening the long CA name in the test fixture, which hid the defect and threw
away the stress case that had just found it. The stylesheet now carries the
rule that spec 0019 had applied inline to one other select; that inline copy is
gone, the long name is back, and both states were measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
On a hierarchy's detail page, the renew and retire controls sit after whatever
optional lines a row happens to carry -- distribution URLs, an ACME directory,
name constraints. A row with constraints looked right because the constraints
block supplied the gap; a row without them had the renew field butting straight
against the line above.

Spacing that comes from a neighbour which may not be there is not spacing. The
controls carry their own margin now.

Found by looking at a screenshot rather than by any assertion -- the same way
the layout problems that prompted specs 0023 and 0024 were found. No test in
the suite could have caught it, which is worth remembering the next time a
green run feels like proof.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
…cover

The rule that keeps a native select from pushing a page sideways -- it will
not shrink below its longest option -- was asserted on one page and merely
assumed on four others: the issuer pickers on the new-certificate and
sign-CSR pages, the TLS issuer on settings, and the cross-sign candidate on
the hierarchy detail page. The probe never rendered those states, so nothing
measured them.

All four now render with populated selects and are measured. Each was
counter-checked: with the min-width rule removed they overflow a 390px
viewport by 7, 7, 7 and 9 pixels respectively, and pass with it restored. A
probe that cannot go red proves nothing, so this is the part that mattered.

The fixture's existing long name turned out not to be long enough to stress
any of them -- it produced a green pass in every one of the four. The two
entities added here carry names near the 64-byte subject cap instead. The
existing name is untouched; nothing was shortened to make anything pass.

No overflow found in the shipped stylesheet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
Spec 0024 made creating a root and creating an intermediate two separate
steps, so a hierarchy with no active issuer is the normal state right after
a root is created. The dashboard is one of the three surfaces the spec
requires to say so and point at the fix.

It only ever worked on an instance with a single hierarchy. The flag was
`bool(active_issuers(db))` over the whole instance, so the moment any one
hierarchy had an issuer the notice stopped rendering for all of them, and a
freshly created bare root was indistinguishable from a working one in the
expiry table. The template then compounded it by picking the hierarchy with
`| first`, so even a correct condition would have named only one.

The context now carries the roots that lack an active issuer, derived from
the same single query and the rows the handler already had -- no query per
hierarchy -- and the template names each of them. A hierarchy whose only
issuer was retired counts as lacking one, which falls out of the existing
status filter rather than needing its own rule.

Spec 0024's Interface Contract described the broken shape literally: "gains
one flag ... no other key changes". Corrected there and recorded in its
change log, because implementing that sentence faithfully is what produced
the bug.

Found by running the plan's verification against a live multi-hierarchy
instance. The suite had covered only the single-hierarchy case, which is
why it was green throughout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
The mutation harness applies a deliberately wrong version of a behaviour and
checks whether the tests go red. Three came back NOT CAUGHT against a green
979-test suite. All three behaviours are implemented correctly; only the
tests were missing or unfailable.

A hierarchy's detail page must list only its own intermediates. The test
meant to guard that asserted the other root's fingerprint was absent -- but
the filter takes the root from its own argument, so that value stays absent
either way. What moves is the other hierarchy's intermediate's fingerprint,
and nothing looked at it. The assertion landed on the wrong value when spec
0024 removed the composed name suffixes and the string it used to check
stopped existing.

The intermediate route must reject a blank name. Without the check the route
still answers 400 with the same form, so only the message distinguishes the
two -- and that assertion could never fail: Jinja escapes the apostrophe in
cryptography's sentence, while the helper strips tags without unescaping
entities. Three sites, dead in all three, harmless in two only because other
mutations moved the status code.

An intermediate must not carry its parent root's exact subject. This one is
a missing test outright. The guard is a single `if` comparing subject DER,
and it exists because the state it refuses produced a real bug: OpenSSL
picked the wrong certificate when building a path, so a chain was accepted
that should have failed and a name-constraint violation surfaced as a
signature error -- with both tests green throughout. When that guard landed,
every route into the collision was closed at the fixtures rather than at an
assertion, which left the branch reachable but unvisited.

Also removes two assertions that check for strings the fixtures can no
longer produce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
Every spec is supposed to leave an entry under [Unreleased]; these three
landed without one. The same thing happened to 0017-0022 and was caught
late, which is not an excuse for repeating it.

Two of these change behaviour for someone already running cabin, so they are
stated plainly rather than left to be inferred from a feature list. Names are
now taken verbatim, so anything matching on a name cabin generated sees
something different for newly created authorities; existing rows keep the
names they have. And the CA key export is the first time cabin hands out a
signing key at all -- superadmin only, password protected and audited, but
whoever holds that file can issue as that authority for as long as the
certificate is valid, and cabin never sees it happen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
…ages

A root's detail page lists every intermediate at the bottom as a full
section, so the issuers are invisible without scrolling and two of them
carry the page to 2368 pixels. The operator asked for the intermediates to
be visible and to stop costing so much room.

They become a compact table directly under the root -- name, status, expiry
-- and everything else about an intermediate moves to its own page. Cross
certificates get the same treatment. The two forms move below the tables.
This is the split spec 0023 already performed once on the CA page, and the
tables use the scroller and table markup that every other list in cabin
already uses, so no new layout primitive appears.

Writing it up found four things the plan had wrong or missing. The one that
matters: on a cross row parent_id names the signing root while cross_of_id
names the subject, and a page's renew button asks whether the *signer's* key
is present. Reading that from the subject root would offer a renew on an
imported cross certificate that could only end in a 500. It has its own
requirement and is asserted in both directions, as is the guard that decides
which hierarchy a row may be viewed under.

The height requirement is stated as a relation -- an intermediate costs a
row, not a section -- and its criterion also asserts the table is not empty,
because "growth stays bounded" is satisfied perfectly by rendering nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
…et pages

A root's page rendered every intermediate as a full section at the bottom,
so the issuers were invisible without scrolling and two of them carried the
page to 2368 pixels. They are now a table directly under the root -- name,
status, expiry -- and everything else about a row lives on its own page.
Cross certificates the same. The two forms moved below the tables. The same
fixture now measures 1610 pixels with the first issuer above the fold.

Two guards carry this and neither is visible in the markup. A cross row's
parent_id names the signing root while cross_of_id names the subject, and
the row belongs under its subject -- a guard written against parent_id
would serve a page reachable from no table. The renew button asks whether
the signer's key is present, so parent_has_key is read from parent_id too;
taking it from the root whose page you arrived from would offer a renew on
an imported cross certificate that could only end in a 500. Both are
asserted in both directions.

The tables reuse the scroller and table markup every other list in cabin
already uses, so the stylesheet is byte-identical to before. The retire
confirmation moved into a shared macro rather than being copied into the
new template: two drifting copies of the markup that carries that checkbox
would be a defect with a security shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
The table in spec/README.md stopped at 0014. Eleven specifications have
landed since, including everything in 0.2.0, and none of them was ever
listed -- so the index that is supposed to say what exists skipped the
entire release. Adding 0026 last week made the gap visible by jumping
straight from 0014 to 0026.

Each summary comes from that spec's own Context, not from its number. The
column widened to fit the longest slug, so every existing row is reindented;
no existing row's text changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
A retired row's page still offered a Renew form: `can_renew` asked only
whether the signing key was available, while `can_retire` next to it
already required an active row.

The button was not the whole of it. `renew_in_place` never read the status,
so the route behind that form worked -- a POST from a script or a tab left
open reissued the certificate of a CA that had been taken out of service,
and the fingerprint changed. Nothing hands that certificate out, because a
retired row is never served again, but cabin signed it and wrote an audit
event for it.

The refusal lives in the service layer, so every caller inherits it, and
`can_renew` mirrors it so the form disappears. Hiding a control while the
route still accepts the request is a defect this project has met before.

Found by clicking through spec 0026 on a running instance rather than by any
assertion. It predates that spec; the page split is only what made it
visible, by giving a retired row a page of its own.

Also folds the changelog's [Unreleased] section into [0.2.0], which is where
it belonged: 0.2.0 was dated and closed but never published -- pull request
17 is open and nothing is tagged -- so the file claimed a release that had
not happened and filed four specifications as coming after it. The heading
now says Unreleased until the release is actually cut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
First of four specifications turning cabin's interface into the design drawn
against this branch. This one rewrites the stylesheet and the layout shell and
touches no content template's markup, which is what makes it a step that can be
verified on its own: afterwards every page wears the new chrome around the
content it already had.

The design brief is checked in beside it as docs/design/0027-brief.md rather
than left in a scratch directory, because the palette is tested against it. A
later deliberate divergence then appears as a diff to that file with a reason
beside it instead of as silent drift.

Writing it up corrected the plan in four places. The contrast figures I had did
not reproduce -- 4.08 and 3.15, not 4.19 and 3.23 -- so the test computes them
rather than trusting a number in a document. There is a third failing colour,
not two: the disarmed retire button's label sits at 2.17:1, and the usual
exemption for disabled controls does not reach it, because that button is not
disabled -- it is live and its arming is checked on the server. Lifting the two
faint greys to the same floor leaves them 1.07:1 apart, so the seven-step text
ramp does not survive intact and the spec says so. And "roughly twenty new
classes" contradicts the reverse-direction test this same spec strengthens: a
class defined here but first used in 0028 fails it. Resolved without an
exemption list, which the whole redesign would have fitted through -- the names
are reserved, and the spec that first renders one defines it.

Two things the widened checks already find in today's code: `tile-label` and
`warning` are used with no rule, on pages the current seven-page list never
fetches.

The overflow probe is repaired here, before any page changes. The design's shell
is overflow:hidden, and the probe excuses every element that has a clipping
ancestor -- so it would have returned nothing for all nineteen screens at both
widths while looking green. It also exists in two identical copies, with the
rail probe in two more, so repairing one would have left the other vacuous over
nine pages; they move into a single module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
The agent writing the contract's tests reported seven things; five were
defects in the specification rather than misreadings, and each is corrected
in place with the correction recorded.

The rule that a recessed surface is darker than the page is false for this
design: the sidebar tone is measurably lighter than the page ground. That
was an assumption about how dark interfaces ought to be built, written as a
requirement. It is replaced by a rule that holds -- the three grounds are
mutually distinct by a stated minimum and each carries its own text at the
contrast floor.

The floor of forty examined elements was taken without measuring. Real pages
examine thirty to thirty-eight, so it could never have been reached. What
the floor actually guards against is the pre-repair walker leaving one
element examined under the shell, so it has to be far above one, not near a
page's total.

The repaired probe excused the scroller along with its descendants, so a
scroller pushed out of its own grid cell would no longer be reported -- a
coverage regression inside the change that claims to repair the probe.

One token the contract requires could not legally exist under the criterion
that gives every colour a provenance; the criterion now admits a third
provenance rather than the register growing a row for a value that does not
diverge from the brief.

And the reverse direction of the agreement check contradicted the criterion
listing the class names that must keep their rules. The resolution is now
the contract instead of an implementer's judgement: a rendered page is
evidence that a class is used, but its absence is not evidence of disuse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
Thirty tests red, every one of them named by the specification: ten existing
ones re-pointed or strengthened, twenty new.

The most important is not a new test but a repair. The design's shell clips
horizontally, and the overflow probe excuses any element that has a clipping
ancestor -- so the moment the shell lands it would examine one element per
page and report all nineteen screens clean. It existed in two identical
copies, with the rail probe in two more and the danger probe in two more
again, so repairing one would have left the others vacuous over nine pages.
They now have one definition in tests/probes.py, and a test asserts they
keep it.

The two new palette rules were counter-checked rather than assumed. The
distinctness floor sits above the design's own hover step and below its
tightest real pair, so it is not a line everything already clears. The
contrast rule rejects a grey missing the floor by six hundredths, and it
independently rejects all three of the colours this spec lifts -- it
reproduces the reason those colours are lifted without having been told
about them. Both counter-examples are computed inside the tests, so the
clauses are re-proved on every green run.

Three of the new tests pass today and are recorded as such: they only mean
anything alongside the one that proves the pre-repair walker went blind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
cabin now wears the design drawn for it: dark by default, with a light
counterpart that follows the browser's own setting rather than a switch
inside cabin. This step rewrites the stylesheet, adds a wrapper and an id to
the layout shell, and touches no content template's markup -- so every page
keeps its content exactly as it was and gains the new chrome around it.
That boundary is what makes it verifiable on its own, and a test enforces it
by diffing the templates against the commit this branch started from.

The palette lives entirely in tokens, checked against the design brief that
is now in the repository beside the spec, so a later divergence shows up as
a diff with a reason instead of drifting. Three colours are lifted to reach
the contrast floor and each lift is recorded as a divergence; the light
palette is solved rather than inverted, because it never existed as a
design and an inverted accent fails as link text.

The overflow probe was repaired before any of this landed. The design's
shell clips horizontally, and the probe excuses anything with a clipping
ancestor -- so it would have examined one element per page and pronounced
all nineteen screens clean. The probes had four duplicate definitions
between them; they now have one.

Public Sans retires and Inter is vendored in its place, licence beside it
and no CDN, exactly as before. IBM Plex Mono stays but loses its medium
weight: nothing in the stylesheet asks for a monospace at 500, so the file
was carrying no load. Measured before deleting rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
The second of four steps: the hierarchy list, the hierarchy page, the issuer
and cross pages and the certificate page take the division and listing the
design draws for them. This is the part the operator named specifically.

Eight requirements of earlier specs are overturned and each carries its
argument, because 0026 landed this morning and superseding it a few hours
later is only defensible with one. The issuers table gains a kind column,
which 0026 refused on the grounds that a fourth column costs width at 390
pixels -- true of a table, not of a grid row whose tracks have a zero
minimum, and re-proved by the probe rather than asserted. Whole rows become
clickable while the name cell stays the link, so 0026's assertion and five
scopings survive untouched. The root's renew and retire move into their own
section, which keeps 0026's rule that a row's controls sit with its detail
and closes the question 0026 left open about three danger buttons on one
page. And /ca becomes a grouped list, which 0026 had ruled out of scope for
the honest reason that nothing there measured it.

Two findings worth the record. The design's own text contradicts itself
about the hierarchy list's columns, and the resolution is that a row's kind
is a tag in its name cell rather than a column that would mean two different
things. And the design dims inactive rows with opacity, which would have
silently defeated the contrast probe 0027 just built -- opacity composites
after the computed colour is read, so every dimmed row would have passed
while being less legible than the check believes. Not shipped, with the
reason recorded.

One thing declined rather than dropped quietly: the design asks the
certificate page for facts it does not carry, which would be new content
rather than a new arrangement. It is in Out of Scope with that argument.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
Two claims in this specification were wrong and one of its acceptance
criteria could not fail. All three were found by the agent writing the
tests, by measuring rather than reading.

The specification said a grid row makes a fourth column affordable because
its tracks have a zero minimum. Measured at 390 pixels, that construct with
the expiry cell still marked nowrap overflows its box by 75 pixels -- four
times worse than simply adding the fourth column to the plain table this
spec was replacing, which overflows by 18. The construct introduced to make
the column affordable lost against the one it replaced. The mechanism is
not the track sizing at all; it is that the cell is allowed to wrap. The
whole six-build measurement is in the requirement now, because someone will
disbelieve it later.

The criterion meant to prove this rested on the page-level overflow probe,
which excuses everything inside a scroller -- correctly, since a scroller
exists so a wide table scrolls instead of breaking the page. It therefore
reported every variant clean, including the ones that overflow by ninety
pixels. It is replaced by two clauses that can each fail: the page does not
overflow, and each table fits inside its own box.

That second clause immediately found something older. The five-column cross
table has been hanging 78 pixels out of its box since spec 0026, and the
same exemption hid it. So 0026 declined a fourth column to protect a width
its own five-column table was already over. Recorded as a defect this spec
must fix rather than as a curiosity, and the criterion covers that table
rather than being widened to let it pass.

Also settles a sentence in FR-5 that two edits had collided in: a child row
keeps its status in the status column and carries its kind as a tag beside
the name. /ca is where an operator looks to see what is active, and a
retired issuer standing unmarked among live ones is how someone signs
against the wrong hierarchy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
Nineteen red: sixteen new, three re-pointed.

The two that matter most are instruments rather than assertions. The
width criterion now measures each table against its own box as well as
measuring the page, and the test carries the proof that it needs both: a
build with the known overflow passes the page clause and fails the box
clause, and a four-thousand-pixel element planted inside a scroller is
asserted to stay invisible to the page clause -- so if the two ever collapse
into one, the test says so instead of going quietly green.

The settled child-row reading is counter-checked in four directions: the
correct build passes, the reading that was rejected fails by name, and so do
a build that marks every row retired and one that marks none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
The hierarchy list becomes a grouped list: a root with its issuers indented
beneath it, each carrying its kind as a tag and its status in the status
column. The hierarchy page's issuers table gains a kind column and rows that
are clickable across their whole width while the name cell stays the link.
The root's renew and retire move into their own section at the end, which
puts the page's one danger control under its own heading. The issuer page's
identity block becomes a definition grid and a cross certificate's signer
line becomes a banner with room for the sentence it carries.

Two tables were hanging out of their boxes at 390 pixels and no requirement
named either. The cross table by 78, since 0026; the hierarchy list by about
160, since 0023. Both are at zero now. They were invisible because the page
probe excuses anything inside a scroller -- correctly, since that is what a
scroller is for -- so nothing was measuring the table against its own box.
The sharp version, which is in the spec: 0026 declined a fourth column to
protect a width its own five-column table was already over, and the page
that argument was about was over by twice as much.

The mechanism that makes the fourth column affordable is not the grid track
sizing this spec first claimed. Measured, that construct with the expiry
cell still marked nowrap is four times worse than adding the column to the
plain table it replaced. What makes it affordable is letting the cell wrap.
The measurement is in the requirement because someone will disbelieve it.

Four contract defects surfaced during implementation and were fixed as
contract defects rather than worked around: a criterion pair about subject
alternative names that no build could satisfy, two tests still asserting
arrangements this spec supersedes, and one test that had been proving a
property of a single commit rather than of the code. That last one is
retired with its argument written down -- what it asserted cannot regress,
and its evidence is the commit, which cannot be edited into agreeing.

Rows outside these pages lose their hover tint: the design colours a row
only where the row is clickable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
The third step: the five form pages take the design's two-column shape, and
htmx -- vendored and loaded since the beginning with not one attribute using
it -- is finally wired.

One rule governs it, and it has its own architecture decision record because
it is what keeps this from becoming a single-page application by increments:
every htmx target is a URL that also answers as a page. The disclosure the
design wants is URL state, so with JavaScript off the link is followed and
the same server renders the same panel one navigation later. That is also
how the design's behaviour arrives without reviving the element spec 0024
banned, which stays banned and is re-asserted here so nobody infers
otherwise.

The preview panels call the checker that refuses at signing time, not a
reimplementation of it -- twice, because the rule about the common name only
applies when no subject alternative name carries DNS, and a per-name loop
cannot reproduce it. A panel that disagrees with the signer is worse than no
panel.

Writing it found that one extraction is not enough. The name resolution must
be shared too: for an IP address as common name cabin resolves IP:10.0.0.5
while the checker's own fallback appends DNS:10.0.0.5 and tests it against
the DNS subtrees. Two different questions with two different answers, and
the preview must ask the one signing asks.

Recorded rather than glossed: without JavaScript the Check button posts the
whole form, which on the import page includes the CA key and its passphrase.
That is not a new exposure -- the same fields already go to the real import
route -- and the preview endpoint declares no parameter for either, so it
never reads, logs or echoes them. What is forbidden, and has its own
requirement, is a keystroke stream carrying them.

Two of spec 0023's requirements come back rather than being overturned: 0024
retired them because after it there was nothing left that could be closed,
and now there is again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
…he case

Four acceptance criteria could not be met as written, all found by writing
the tests against them.

One required a response fetched with the htmx header to be a tenth the size
of the same URL fetched plainly -- for two URLs that this spec deliberately
makes the same page, narrowed on the client. Meeting it would have forced
the fragment-only endpoint the spec forbids two requirements earlier. Those
two now carry the stronger claim instead: the responses are byte-identical.

One forbade a script element in any template, which has been false since
spec 0015 -- and this spec's own Context cites the line that makes it false.
One counted the JavaScript files in the static directory, where a vendored
Swagger bundle has always sat. One demanded that no page lose a sentence,
of a page whose whole point is that a form is not rendered until its URL
asks for it.

Also records what FR-5 had only argued. The claim was that a preview built
from the shortest available call could tell an operator a request is fine
that signing will refuse. That case is now constructed and measured: an
authority permitting one IP range and no DNS names at all, asked for a
certificate whose common name is an address outside it. The shortcut
permits, the signer refuses with a 400. The disagreement can only ever run
that way round, which is the dangerous one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
…ject

The five form pages take the design's two-column shape with preview panels
beside them, and htmx -- vendored and loaded since the beginning without a
single attribute using it -- is finally wired.

One rule governs it and has its own decision record: every htmx target is a
URL that also answers as a page. The two disclosures are URL state, so with
JavaScript off the link is followed and the same server renders the same
panel one navigation later. The four preview endpoints are the only
exception, and each answers the whole page when the request carries no htmx
header -- one macro rendered into both envelopes, so the path without
JavaScript is not a second implementation that can drift.

The constraint panel calls the checker that refuses at signing time, twice:
once per name for the marks and once over the whole set, because the rule
about the common name applies only when no subject alternative name carries
DNS and a per-name loop cannot reproduce it. Names are resolved through the
function issuance uses, not by passing an empty list -- the spec carries the
measured case where those differ, and it differs in the direction where a
preview would tell an operator a request is fine that signing refuses.

Eleven contract defects surfaced during implementation. Four criteria could
not be met: one whose spy still recorded the certificate issued after the
preview, so the only passing build was the one the criterion existed to
forbid; one that stripped a PEM's trailing newline before comparing it to
itself; one whose route lookup found nothing for any route in this
application, not merely for the new ones; and one that forbade a colour the
palette has legitimately carried since 0027. The other seven were tests this
spec predicted would keep passing and which its own disclosure breaks --
that prediction is itself corrected, because a criterion claiming things
about tests it does not own is a claim like any other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
The last of the four. Dashboard, inventory, users, tokens, ACME, audit,
settings, the three remaining transfer pages, login and setup -- plus two
things cabin does not have: a flash message carried across a redirect, and
an HTML page for a refusal that currently answers JSON to a browser.

That second one gets its own requirement and a criterion measuring both
directions, because the obvious implementation breaks the API. A client
receiving an HTML error page is a worse regression than the one being fixed,
and a test keyed on path prefixes passes while /acme/admin -- an interface
page -- answers JSON. The census is taken from the router objects instead.

Writing it corrected the plan in four places. Sorting does not exist on the
inventory at all, and adding it would make that page's own opening sentence
false, so it is declined rather than invented. The flash ships one tone, not
three: no failing form in cabin redirects -- every one re-renders with its
own error box -- so two of the three would have been rules with no user,
which the agreement test rejects in that direction. Two of the "four
transfer pages" were already done. And the toggle shape the design
recommends would clear every other flag on the two pages that need it,
because both read all their flags from one submission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
This specification opens its acceptance criteria by saying that no criterion
may assert what a test elsewhere will do, and cites spec 0029 for getting
that wrong. Two criteria later it does exactly that, twice. Both are
restated as the property itself. A specification that writes a lesson down
and then breaks it is worse than one that never wrote it, because the reader
trusts the preamble.

Three more criteria could not be met: one required zero of an attribute that
spec 0029 deliberately put in four templates and this spec's own contract
leaves alone; one asked for two generations of templates rendered on one
instance, which strict undefined makes impossible the moment the dashboard's
context changes; and one demanded a byte-identical revocation list, which is
freshly signed with clock-derived timestamps and differs between two runs of
the same commit.

Three entries described tests that do not exist -- assertions about which
forms the users page offers, and a migration sweep the suite has never had.
A specification listing a test it has not read is how a requirement comes to
be assumed covered. Three tests this spec genuinely breaks were in no list
at all.

Four claims about the design or about cabin were wrong: a word listed as new
copy that the dashboard already renders, a track rule contradicting its own
track list, a second track list following neither the rule nor the design,
and audit filters said to be the five the design names when only the count
matches.

Two things are carried in because they are worth more than the corrections.
All eighteen mutating form posts were measured rather than assumed: every
one answers a redirect and records exactly one audit entry, including the
four that write more than one row. And the six track lists marked verbatim
were parsed back out of the design and matched.

Also recorded: a route census that reads the application's route list
directly sees fourteen wrapper objects and none of cabin's own routes, so it
passes against every implementation; and a GET on the MCP path is refused
during routing, before any handler runs, so it cannot be the comparison that
proves the boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
…l page

The last of the four steps. Dashboard, inventory, users, tokens, ACME,
audit, settings, the three remaining transfer pages, login and setup all
take the design's arrangement -- and cabin gains two things it never had.

A flash message, carried across the redirect that follows every mutation:
one nullable column on the session row, written on the way out and popped on
the way in, which is the only schema change in the whole redesign. It ships
one tone rather than the design's three, because no failing form in cabin
redirects -- every one re-renders with its own error box -- so the other two
would have been rules with no user.

And a page for a refusal. Until now a viewer who reached an admin URL in a
browser got a JSON body. The handler classifies by the router that owns the
route rather than by the path, because /acme/admin is an interface page
whose path begins with /acme, and because an API client receiving an HTML
error page would be a worse regression than the one being fixed.

The rail's fourth group becomes Export, holding the trust bundle, the CA key
and the inventory, with the two imports moving under the certificate
authorities. That reverses a choice the operator made in spec 0025; he was
asked and chose the design.

Twenty-three tests were red when the implementation landed, and every one
was a defect in a test or a criterion rather than in the build: queries
against a column that has never existed, an identity comparison across two
separate parses that can never hold, a test asserting cabin has filter
values only the design uses, two instruments that forbade a string change
the spec mandates, and eight coverage floors calibrated for full pages and
applied to login and setup.

Two of the twenty-three turned out to be real defects in the build, and the
agent that found them stopped rather than absorbing them into the tests --
a link invented to satisfy a criterion that had since been withdrawn, and a
status tag with no rule. That second one had been missing since long before
this spec; it stayed invisible because no fixture in this suite had ever
created the kind of key that renders it. The check was sound all along.
Nothing had ever driven the application into the state it checks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
CI was red on work that is green here, and the eight failures had two
causes. The second one is the serious one.

The probes staged a light page by stripping the light media query out of the
stylesheet, and staged a dark page by doing nothing -- on the assumption
that headless Chrome reports a dark preference. It does not have one of its
own: it follows the machine underneath. On this desktop that is dark, so the
dark run measured the dark stylesheet. On a runner with no desktop it is
light, so every run labelled dark measured the light stylesheet instead.

The contrast check stayed green throughout, because "nothing is below the
threshold" is true of the wrong page too. So the verification that justified
lifting three of the design's colours had never run on CI, and ran here only
by accident of a desktop setting. Both schemes are now forced in the file
and nothing is asked of the browser.

The counter-check that should have caught this could not tell "the probe
examined the span and passed it" from "the probe never looked at the span".
It now separates them: the element count must rise by exactly one, the
reported entry must be the planted span by name, and silencing it must bring
the count back down. Under a browser forced to the runner's condition it now
fails with the right diagnosis instead of passing.

The other seven failures were tests reading a baseline out of git by short
commit. They fail on a shallow checkout, and they would have failed
permanently once this branch is merged and those commits cease to exist.
Three are retired -- they asserted a property of one diff, which cannot
regress and is recorded in the commit -- and one of them had already stopped
being about its own spec, having collected an exception from every spec
since. Four are converted: three to checked-in snapshots, and one reads the
handler's own signature instead, which is stronger than the baseline was,
because a field the handler gains that the form never offers now fails too.

Comparing against a git commit is an excellent instrument while a change is
being made and a poor one to merge: it smuggles a claim about history into a
suite that describes code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
Spec 0030 FR-1 requires them and they were never written, because the agent
implementing it was bounded to src/. Twenty-eight entries across the four
specifications: what the design asked, what cabin does, and why.

The brief's own header states the rule -- a divergence is made by editing
that file in the same change as the code -- and the palette half of the
register was complete while this half was empty. The difference is that the
palette half has a test that reads it and this half has none, which is the
whole reason it went missing rather than anyone deciding to skip it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
Ten specifications. cabin stops assuming there is one certificate authority
-- several hierarchies side by side, rotation without special cases, grants
deciding which issuer an identity may sign with, ACME per issuer, name
constraints enforced before signing, cross certificates for rotating a root,
and TLS for cabin itself. Then the interface was redrawn against a design
made for it: dark by default, the hierarchy list grouped, a page per row,
form pages that check a request against the same code that refuses it at
signing time, a message that survives a redirect, and a refusal that answers
a browser with a page instead of JSON.

A database from 0.1.x cannot be upgraded to this. That is deliberate and the
upgrade section says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMNjeQKSxCwMQE4zKXarxp
@nofuturekid
nofuturekid merged commit a776505 into main Aug 10, 2026
1 check passed
@nofuturekid
nofuturekid deleted the feat/0.2.0 branch August 10, 2026 13:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant