Repository navigation
feat(release): 0.2.0 — six specifications, no upgrade path from 0.1.x - #17
Merged
Merged
Conversation
… 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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
create_intermediate_undermakes rotation ordinary operation,retireandrenew_in_placeexist, every leaf records its issuer and every CRL is per issuer.principalparameter on the domain functions rather than a FastAPI dependency./acme/directoryis gone with no alias.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_eaboff, 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_TLSandCABIN_HTTP_PORTare 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.mdrecords 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 formatandmypyare 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:
ca/x509.py:renew_certificatetook a key parameter that was silently ignored, sorenew_in_placegenerating a fresh key instead of reusing the stored one was caught by nothing. The reason it was invisible:openssl verify -CAfiledoes 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.test_sigterm_stops_both_listenerspassed with cabin's own signal cross-wiring deleted outright, because uvicorn'scapture_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, becauseimport_crossrefuses one layer out; and the ACME test client'sissuer_idcould 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:100against a chowned directory — the case Unraid creates. Capabilities are dropped and/healthzreports 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, noLocationand noSet-Cookie.Port publishing was never exercised. This machine's kernel has no
vethsupport, 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 imagesreports 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