Security claims¶
Every security property this project asserts, where it asserts it, and what backs it. The inventory exists because the same bug kept appearing: a sentence in the docs or the UI claiming something no test checked, which turned out to be false (#31, #55, #56, #61, #62).
pixi run check-claims reads this page and fails if a quoted sentence is no
longer in the file it cites, if the evidence named for a row does not exist or
is not something CI runs, or if a row's status lacks what it requires (evidence,
a gap, an issue number). It cannot tell whether a test really asserts the claim
as worded; that is a review question. It runs in every commit and in CI.
- Asserted in is the file and the sentence, quoted. The quote must still be in that file, so when a doc changes, its row has to change with it.
- Enforced by names the tests that enforce the claim. Each must exist and be something CI runs, because a test nobody runs (#32) is decoration.
- Status is one of four values, and the honest ones matter most:
enforced: a test CI runs fails if the claim becomes false.partial: tested, but not all of it. The gap and its issue are stated.unenforced: true or plausible, with nothing that would notice if it stopped being true. The reason is stated.violated: the sentence is not true today. It stays in the inventory, with its issue, rather than being quietly left in the docs.
A claim the code does not back is allowed to stay here, as violated, in plain
sight. What is not allowed is a claim that looks enforced and is not.
Adding a claim. Any new sentence in the docs or the UI that asserts a
security property gets a row in the same change, with the test that enforces
it. If there is no test, write the test, or soften the sentence, or list it
honestly as unenforced. See CLAUDE.md.
The claims¶
C-01 Client-supplied identity headers are stripped¶
- Claim: The proxy deletes any identity header a client sends and sets its own, so a client cannot choose who it is.
- Asserted in:
docs/self-hosting.md— "The proxy deletes anyX-Auth-Request-User,-Nameor-Emailthe browser sent" - Asserted in:
docs/running.md— "Identity cannot be forged." - Status: enforced
- Enforced by:
tests/test_proxy_identity.py::a forged X-Auth-Request-* is refusedtests/test_proxy_identity.py::a forged identity beside a valid one is replacedtests/test_proxy_identity_mutations.py::Each guard, brokentests/test_stack.py::forged identity headers are stripped
C-02 Every documented cloud identity configuration resolves an identity¶
- Claim: The IAP, ALB, Azure and oauth2-proxy settings each produce the canonical identity headers. (This was broken for months, #23.)
- Asserted in:
docs/self-hosting.md— "behind an SSO front door: the header it sets" - Status: enforced
- Enforced by:
tests/test_proxy_identity.py::request is forwardedtests/test_proxy_identity.py::no provider header reaches the APItests/test_front_doors.py::every documented front door setting matches the test's copytests/test_front_doors.py::Each side, drifted in a copy
C-03 A request with no identity is refused, and an unset source fails closed¶
- Claim: An empty or missing identity answers 401. It never falls back to a usable default.
- Asserted in:
proxy/Caddyfile— "NO DEFAULT VALUE" - Status: enforced
- Enforced by:
tests/test_proxy_identity.py::a request with no identity is refusedtests/test_proxy_identity.py::an unset identity source fails closedserver/tests/test_auth.py::test_missing_header_is_401_not_an_anonymous_user
C-04 The API reads identity from the proxy header and nowhere else¶
- Claim: Not from a body, a query parameter or a cookie. The audit log's
author is overwritten with the proxy identity. This is about the people-facing
API: the optional read-only agent API (off unless
AGENT_ID_SECRETis set) identifies a person by a per-user bearer token instead (C-99, C-105). - Asserted in:
README.md— "the API reads identity from the proxy and nowhere else" - Asserted in:
docs/adopting.md— "the application reads identity from the proxy in front of it and nowhere else" - Status: enforced
- Enforced by:
server/tests/test_auth.py::test_identity_comes_from_the_configured_headerserver/tests/test_api.py::test_log_author_is_the_proxy_identity_not_the_bodyserver/tests/test_auth.py::test_identity_in_a_query_parameter_or_cookie_is_ignored
C-05 The API is reachable only through the proxy¶
- Claim: The API publishes no host port, and nothing but the proxy shares
the internet-facing network with it (plus
mcp-gate, only if the optionalagent-mcpprofile is turned on). - Asserted in:
docs/self-hosting.md— "the API publishes no host port" - Asserted in:
README.md— "so it must be unreachable except through the proxy." - Asserted in:
docs/self-hosting.md— "The front door, and the only service reachable from outside." - Status: enforced
- Enforced by:
tests/test_compose_isolation.py::publishing a port on the api is noticedtests/test_compose_isolation.py::a new service on the edge network is noticed
C-06 The database has no route off the host¶
- Claim: Postgres sits on an
internal: truenetwork and publishes no port. - Asserted in:
docs/self-hosting.md— "on aninternal: truenetwork with no route off the host at all" - Asserted in:
docs/self-hosting.md— "PostgreSQL 18, on a network with no route off the host" - Status: enforced
- Enforced by:
tests/test_compose_isolation.py::an internal network made routable is noticedtests/test_compose_isolation.py::publishing a port on the database is noticed
C-07 The stack binds to loopback by default¶
- Claim: It speaks plain HTTP, so it is published on 127.0.0.1 unless the operator changes it.
- Asserted in:
README.md— "The stack binds to loopback by default" - Status: enforced
- Enforced by:
tests/test_compose_isolation.py::the proxy binding every interface is noticed
C-08 The development override cannot be picked up by accident¶
- Claim: It is a named file, never compose.override.yaml, which a bare
docker compose upwould load silently. It deliberately publishes the API. - Asserted in:
docs/running.md— "can never pick it up by accident" - Status: enforced
- Enforced by:
tests/test_compose_isolation.py::a compose.override.yaml is noticed
C-09 make check-isolation asserts the isolation model¶
- Claim: The docs say the target asserts that the API is unreachable and that a forged identity is stripped.
- Asserted in:
docs/self-hosting.md— "make check-isolationasserts, against a running stack, thatapianddb" - Status: enforced
- Enforced by:
tests/test_compose_isolation.py::publishing a port on the api is noticedtests/test_check_isolation.py::a published port ontests/test_check_isolation.py::a listening port is noticedtests/test_check_isolation.py::a proxy that forwards the header is noticedtests/test_check_isolation.py::with nothing running it exits non-zero, not 0tests/test_stack.py::check_isolation.py finds the isolation model intact
C-10 Access control is enforced on the server¶
- Claim: A reader cannot write, a writer cannot delete, and a stranger gets
404, not 403, so existence is not confirmed. A project with no owner is
refused to everyone except break-glass identities (see C-55), who can assign
an owner, and no write can create or leave a project without one (R-75). With
identity disabled nothing is enforced, and
/api/healthsays so. - Asserted in:
docs/running.md— "| Access control enforced | No | Yes, server-side |" - Asserted in:
docs/guide.md— "On the server these are enforced by the API, not just by the buttons the page shows." - Asserted in:
docs/guide.md— "A project with no owner is refused to everyone except emergency-access identities, who can assign one" - Status: enforced
- Enforced by:
server/tests/test_access.py::TestEnforcement::test_reader_may_not_patchserver/tests/test_access.py::TestEnforcement::test_writer_may_not_deleteserver/tests/test_access.py::TestEnforcement::test_stranger_gets_404_not_403server/tests/test_access.py::TestEnforcement::test_list_hides_projects_you_cannot_readserver/tests/test_access.py::TestRules::test_project_without_owners_is_refused_to_everyoneserver/tests/test_access.py::TestEnforcement::test_ownerless_project_is_refused_to_everyoneserver/tests/test_access.py::TestEnforcement::test_creating_never_leaves_a_project_ownerlessserver/tests/test_access.py::TestEnforcement::test_an_owner_cannot_blank_the_owner_listserver/tests/test_breakglass.py::test_break_glass_must_assign_an_owner_to_an_ownerless_projecttests/test_access.py
C-11 A writer cannot grant themselves ownership¶
- Claim: Only an owner may change who has access, and a project cannot be left with no owner.
- Asserted in:
server/app/access.py— "Only an owner may change who has access" - Status: enforced
- Enforced by:
server/tests/test_access.py::TestEnforcement::test_writer_may_not_grant_themselves_ownershipserver/tests/test_access.py::TestEnforcement::test_last_owner_cannot_be_removed
C-12 A checkpoint sign-off is attributed to the authenticated user¶
- Claim:
signedByandsignedAtare written by the server from the proxy identity and its clock, whatever the client sends. (Violated until #31.) - Asserted in:
docs/deploy.md— "A checkpoint sign-off means something." - Asserted in:
docs/running.md— "| Checkpoint sign-off means something | No, self-asserted | Yes |" - Asserted in:
docs/what-it-does.md— "On the shared server a sign-off carries the name of the person signed in through your hospital's own single sign-on" - Asserted in:
docs/guide.md— "On the shared server that identity comes from your hospital's single sign-on and cannot be set from the browser." - Status: enforced
- Enforced by:
server/tests/test_signoff.py::TestTheForgeryFromTheIssue::test_a_writer_cannot_sign_off_as_the_cmoserver/tests/test_signoff.py::TestWhoSignedAndWhen::test_a_new_decision_is_attributed_to_the_caller_not_the_claimtests/test_stack.py::the sign-off is attributed to the proxy identity, not the claim
C-14 Cross-site writes are refused¶
- Claim: A state-changing request that a browser marks with
Sec-Fetch-Siteas coming from a different site is rejected. A write that carries noSec-Fetch-Siteis rejected too, unless it shows anOriginthat is this site (or one listed inCORS_ORIGINS) or is an agent-API request with a bearer token. So a signed-in user's browser cannot be driven from another page, whether or not it sends the header. - Asserted in:
server/app/main.py— "Cross-site writes are refused." - Status: enforced
- Enforced by:
server/tests/test_csrf.py::TestCrossSiteWrites::test_a_cross_site_delete_is_refusedserver/tests/test_csrf.py::TestCrossSiteWrites::test_every_write_method_is_coveredserver/tests/test_csrf.py::TestWritesWithoutSecFetchSite::test_a_write_with_no_header_and_no_origin_is_refusedserver/tests/test_csrf.py::TestWritesWithoutSecFetchSite::test_any_other_origin_is_refusedserver/tests/test_csrf.py::TestWritesWithoutSecFetchSite::test_a_bearer_token_on_a_session_route_is_not_a_pass - Gap: a client that is not a browser can send any
Origin, so this stops a browser being driven, not a caller who already holds the identity header.CSRF_TRUSTED_SITEScan relax theSec-Fetch-Sitecheck (test_same_site_is_refused_by_defaultpins the default).
C-15 Security headers are on every response¶
- Claim: The Content-Security-Policy, HSTS, nosniff and frame denial apply
to
/apiand the dashboard alike, andServeris removed. - Asserted in:
proxy/Caddyfile— "Security headers, at the SITE level so /api/* gets them too." - Status: enforced
- Enforced by:
tests/test_proxy_identity.py::content-security-policytests/test_proxy_identity.py::no Server headerserver/tests/test_api_hardening.py::test_api_responses_get_security_headers
C-16 Request bodies are bounded¶
- Claim: The edge and the API both refuse an oversize body, and the API does so without buffering it.
- Asserted in:
proxy/Caddyfile— "A project document is a governance record: kilobytes." - Status: enforced
- Enforced by:
tests/test_proxy_identity.py::Caddy cut the over-size body off at the capserver/tests/test_api_hardening.py::test_body_limit_rejects_large_payloadsserver/tests/test_api_hardening.py::TestBodyLimitDoesNotBuffer::test_an_oversize_chunked_body_is_rejected
C-17 Abuse is rate limited¶
- Claim: Requests are counted per identity over a sliding window. The limit is per replica.
- Asserted in:
server/app/ratelimit.py— "Per-key request accounting over a moving window." - Status: enforced
- Enforced by:
server/tests/test_api_hardening.py::test_rate_limit_blocks_burst_requestsserver/tests/test_api_hardening.py::TestRateLimiterMemory::test_the_window_really_slides
C-18 The audit log is append-only¶
- Claim: No route under a project's
/logchanges or removes an entry, and the database refuses any UPDATE except the redaction a purge performs, which it verifies by comparing the result with a function. The database does not refuse a DELETE: the only route that removes entries is deleting the whole project, which takes its live log with it and records that it did (C-45). - Asserted in:
README.md— "Audit log | Yes, append-only" - Asserted in:
docs/what-it-does.md— "The database itself refuses to alter the audit log or the revision history" - Asserted in:
docs/adopting.md— "The audit trail is enforced by the database, not only by the application." - Status: enforced
- Enforced by:
server/tests/test_api.py::test_the_database_itself_refuses_to_update_a_log_entryserver/tests/test_api.py::test_there_is_no_route_to_change_or_remove_a_log_entryserver/tests/test_api.py::test_log_timestamp_is_the_server_clockserver/tests/test_disposal.py::TestTheDatabaseAllowsARedactionAndNothingElse::test_an_arbitrary_update_is_refusedserver/tests/test_disposal.py::TestTheDatabaseAllowsARedactionAndNothingElse::test_a_partial_redaction_is_refusedserver/tests/test_disposal.py::TestTheDatabaseAllowsARedactionAndNothingElse::test_changing_when_or_who_is_refused
C-19 Version history cannot be rewritten¶
- Claim: Every revision is kept, and the only permitted change to one is a purge.
- Asserted in:
docs/running.md— "| Version snapshots | No | Yes |" - Status: enforced
- Enforced by:
server/tests/test_versions.py::TestVersions::test_history_is_append_onlyserver/tests/test_versions.py::TestPurge::test_history_cannot_be_rewritten_by_hand
C-20 History survives deletion and stays restricted¶
- Claim: Deleting a project does not erase its history, and deleting a restricted record does not widen access to it.
- Asserted in:
server/migrations/002_versions.sql— "No foreign key to projects, deliberately." - Status: enforced
- Enforced by:
server/tests/test_versions.py::TestVersions::test_history_survives_deletionserver/tests/test_versions.py::TestDeletedHistoryStaysRestricted::test_a_stranger_cannot_read_a_deleted_projects_history
C-21 Disposal leaves a tombstone, not a gap¶
- Claim: A purge empties a snapshot's content and keeps its revision, author, timestamp and original hash. Only an owner may purge.
- Asserted in:
server/migrations/003_version_access.sql— "The purge below is deliberately NOT a delete." - Status: enforced
- Enforced by:
server/tests/test_versions.py::TestPurge::test_an_owner_can_purge_and_the_tombstone_survivesserver/tests/test_versions.py::TestPurge::test_a_writer_cannot_purge
C-22 Deleting a project does not erase its history, and the docs say so¶
- Claim: Deleting removes the live project and its live audit log. The version history is kept, and the guide says plainly that deletion is not erasure. (It used to say it deleted "a project and its log", which implied the opposite. #36)
- Asserted in:
docs/self-hosting.md— "Deleting a project does not erase its history." - Asserted in:
docs/developing.md— "The server keeps its version history" - Status: enforced
- Enforced by:
server/tests/test_versions.py::TestVersions::test_history_survives_deletionserver/tests/test_disposal.py::TestDeletionLeavesATombstone::test_the_log_still_goes_with_the_project
C-23 Backups are encrypted by default, and a plaintext dump needs an explicit flag¶
- Claim:
make backuprequires a passphrase and writes only an encrypted dump.make backup-plaintext PLAINTEXT=1writes an unencrypted one and is refused without the flag. - Asserted in:
Makefile— "BACKUP_PASSPHRASE is required" - Status: enforced
- Enforced by:
tests/test_backup_crypto.py::make backup with no passphrase is refusedtests/test_backup_crypto.py::a plaintext dump needs PLAINTEXT=1 and is refused without ittests/test_backup_crypto.py::the passphrase is in no process's argv while encryptingtests/test_backup_crypto.py::the dump is not plaintexttests/test_backup_crypto.py::a backup whose pg_dump fails exits non-zero
C-24 make up refuses to start on unsafe settings¶
- Claim: A placeholder or short database password, a literal identity id or
email source, and a non-loopback bind over plain HTTP are all refused, whether
they are in
.envor exported in the shell (the environment wins over.env, as in compose), and values are read the way compose reads them, so a blank value or a trailing comment cannot get past the gate. Not judged, by design (D-106): the owner's password whenDATABASE_URLnames an external database (it has its own credential), andIDENTITY_NAME_SOURCE(a display name). - Asserted in:
README.md— "refuses to start on unsafe settings" - Asserted in:
docs/adopting.md— "make uprefuses to start on unsafe settings and says why." - Asserted in:
docs/self-hosting.md— "make uprefuses to start on unsafe settings: an empty or placeholder password" - Status: enforced
- Enforced by:
tests/test_preflight.py::an empty password is refusedtests/test_preflight.py::15 characters is refusedtests/test_preflight.py::the message points at make dev for local usetests/test_preflight.py::0.0.0.0 over plain HTTP is refusedtests/test_preflight.py::SITE_ADDRESS= (blank) is plain HTTP, so 0.0.0.0 is REFUSEDtests/test_preflight.py::so a weak one with a long note is still refusedtests/test_preflight.py::an unsafe .env exits 1tests/test_preflight.py::FORCE=1 skips the check and exits 0tests/test_preflight.py::a short password only in the environment is refusedtests/test_preflight.py::a placeholder only in the environment is refusedtests/test_preflight.py::an EMPTY exported password beats a good .env valuetests/test_preflight.py::a literal identity only in the environment is refusedtests/test_preflight.py::an unsafe .env value overridden by a safe exported one is finetests/test_preflight.py::mutation: .env alone -> an exported unsafe value slips throughtests/test_preflight.py::mutation: .env over the environment -> a safe export no longer winstests/test_preflight.py::an external DATABASE_URL skips the OWNER password checks onlytests/test_preflight.py::a literal IDENTITY_NAME_SOURCE is a display name, not refused
C-25 Any database password works¶
- Claim: A password from
openssl rand -base64 32, or one with any character in it, connects once it reaches the API. The.envquoting of$and#is not tested. (About half of them crashed the API before #72.) - Asserted in:
.env.example— "Any characters in POSTGRES_PASSWORD are fine once they reach the API" - Status: partial
- Gap: (#72, the URL builder) The tests cover the URL builder and a real role, not
.envor compose parsing, where an unquoted#cuts the value and$is interpolated. - Enforced by:
server/tests/test_database_url.py::TestThePiecesBecomeAUrlThatSurvivesAnyPassword::test_every_awkward_password_round_tripsserver/tests/test_database_url.py::TestARealRoleWithAHostilePassword::test_it_connects_and_a_wrong_password_does_not
C-26 Browser-only mode says so, in a standing banner¶
- Claim: The app states in the layout that it is storing in one browser, with no access control or audit log, and names the risk.
- Asserted in:
README.md— "The app says so in a standing banner when it is in this mode." - Asserted in:
docs/adopting.md— "and it says so in a standing banner" - Status: enforced
- Enforced by:
tests/test_boot_storage.py::static host -> standing warning is showntests/test_boot_storage.py::static host -> warning names the risktests/test_boot_storage.py::static host -> warning: this browser, no audit log, no access control
C-27 The app never silently falls back to browser storage¶
- Claim: If the API answers 401, 403 or 503, or this browser has seen an API at this origin before and it cannot be reached, the app stops and says so. A browser that has never seen an API at the origin starts in browser-only mode with the standing banner (C-26).
- Asserted in:
README.md— "never silently falls back" - Asserted in:
docs/running.md— "The app never silently falls back from one to another." - Status: enforced
- Enforced by:
tests/test_boot_storage.py::no store was createdtests/test_boot_storage.py::nothing written to localStoragetests/test_boot_storage.py::blocking error, not a workspacetests/test_boot_storage.py::a fallback to browser storage is noticedtests/test_boot_storage.py::mutation: the stop branches are found in the built apptests/test_boot_storage.py::fresh browser, non-JSON answertests/test_boot_storage.py::the standing banner is shown
C-28 After a save, the app says where the save went¶
- Claim: The server mode says the shared workspace and never the browser; only the browser-only mode may mention the browser. (Violated until #55.)
- Asserted in:
app/js/00-core/40-writes.js— "What a save tells the user about where it went." - Status: enforced
- Enforced by:
tests/test_boot_storage.py::server mode: a save says it went to the shared workspacetests/test_boot_storage.py::browser-only mode: a save says it stayed in this browsertests/test_boot_storage.py::only the browser-only mode may say 'browser'
C-29 Each storage mode is labeled distinctly¶
- Claim: The header names the mode, so a user can tell the audited server from the example page.
- Asserted in:
app/js/00-core/30-state.js— "and no two modes share one" - Status: enforced
- Enforced by:
tests/test_boot_storage.py::no two modes share a header labeltests/test_boot_storage.py::every Mode has a header label
C-30 Live updates reach other users, and only those who may see the project¶
- Claim: A change arrives over the event stream without a refresh. The stream carries ids, not documents, and a stranger is not told a restricted project changed.
- Asserted in:
docs/running.md— "Live updates between people" - Status: enforced
- Enforced by:
tests/test_stack.py::external change arrives over SSEserver/tests/test_events.py::test_a_stranger_does_not_see_a_restricted_project_changeserver/tests/test_events.py::test_the_stream_carries_ids_not_documents
C-31 The generated dashboard can never go stale¶
- Claim: A hook rebuilds
docs/app/index.html,docs/app/manifest.jsonandproxy/csp.caddyon any change underapp/or to a generated file, and a rebuild that changes a file is what makes prek fail the run. That prek fails a run in which a hook modified a file is prek's documented behavior, which this repository does not test. - Asserted in:
docs/developing.md— "so the generated file can never go stale" - Status: enforced
- Enforced by:
.pre-commit-config.yaml::build-apptests/test_build_hook.py::the real build-app hook is righttests/test_build_hook.py::equals a fresh buildtests/test_build_hook.py::a rebuild changes a hand-edited index.html (which is what fails prek)tests/test_build_hook.py::an edited source makes a rebuild differ from the committed file
C-32 A test cannot be added and quietly left out of CI¶
- Claim: CI fails if a
test-*task is not run by name, or a test file is named by no pixi task. It does not check that the task naming a file is atest-*task that CI runs. - Asserted in:
docs/developing.md— "so a test cannot be added and quietly left out." - Status: enforced
- Enforced by:
tests/test_workflows.py::every rule passes on .github/workflows/test.ymltests/test_workflows.py::a suite dropped from CI is noticed
C-33 In CI, a test that cannot run is a failure¶
- Claim:
REQUIRE_TESTS=1turns a skip into a failure, because a skip exits 0 and reads as a pass. Every skip construct the test code uses (skipif,pytest.skip,mark.skip,importorskip,SkipTest, a script that prints "skipping" and returns) reads the variable within a few lines, which a scan oftests/,server/tests/andmcp/tests/checks. Beyond the scan, each suite is run for real with its tool missing and must exit non-zero underREQUIRE_TESTS=1and skip without it: the stack, MCP stack, proxy identity, proxy agent-mutation, proxy MCP-mutation, proxy image, backup crypto, verify-backup and dockerignore suites and the node check inscripts/check_app.py(an emptyPATHand a refused port), and the server and MCPrequires_dbmarkers and the node parity test (no database, nonode). The three guards that run only after a live stack has answered (playwrightnot installed, and a 404 from the agent API intest_stack.pyandtest_stack_mcp.py) asktests/require.pyinstead, which is tested the same way (it fails under the variable and skips without it), and the scan requires each call in the form that matters (return cannot_run(...),and skip_allowed()). - Asserted in:
docs/developing.md— "to turn every skip into a failure; CI always does." - Status: enforced
- Enforced by:
tests/test_require_behavior.py::every guarded script suite fails under REQUIRE_TESTS=1, skips withouttests/test_require_behavior.py::every guarded script suite with a guard that returns 0 is noticedtests/test_require_behavior.py::scripts/check_app.py node check fails under REQUIRE_TESTS=1tests/test_require_behavior.py::a suite that crashes is not accepted as a guardserver/tests/test_require_behavior.py::test_requires_db_skips_without_the_variable_and_fails_with_itserver/tests/test_require_behavior.py::test_node_parity_skips_without_the_variable_and_fails_with_itserver/tests/test_require_behavior.py::test_a_marker_that_ignores_the_variable_is_noticedmcp/tests/test_require_behavior.py::test_requires_db_skips_without_the_variable_and_fails_with_ittests/test_workflows.py::REQUIRE_TESTS removed is noticedtests/test_workflows.py::REQUIRE_TESTS set to 0 is noticedtests/test_require_behavior.py::tests/require.py fails under REQUIRE_TESTS=1 and skips without ittests/test_require_behavior.py::a cannot_run that ignores the variabletests/test_require_tests.py::calls the helper the way a guard musttests/test_require_tests.py::a printed skip with no REQUIRE_TESTS and no helper is noticedtests/test_require_tests.py::the scan still finds every known skip sitetests/test_require_tests.py::a bare skipiftests/test_require_tests.py::a script that prints skipping and exits 0
C-34 The walkthrough video says what it shows¶
- Claim: The page says it was recorded on the Docker stack and that the demonstration copy is browser-only.
- Asserted in:
docs/index.md— "The video shows a shared workspace" - Status: enforced
- Enforced by:
tests/test_docs_media.py::dropping the mode caveat is noticed
C-35 PROXY_SHARED_SECRET is a working control¶
- Claim: Set once in
.env, the proxy sends the secret and the API refuses a request without it. It authenticates the proxy to the API, not a caller to the proxy. - Asserted in:
docs/deploy.md— "Set it once in.envand Compose gives the same value to both" - Status: enforced
- Enforced by:
tests/test_proxy_identity.py::the proxy sends X-Proxy-Secret to the APItests/test_proxy_identity.py::a client-supplied X-Proxy-Secret is replaced, not forwardedtests/test_compose_isolation.py::PROXY_SHARED_SECRET missing fromtests/test_stack.py::the API refuses a request that skipped the proxy's secrettests/test_stack.py::and accepts the same request carrying itserver/tests/test_auth.py::test_shared_secret_blocks_a_request_that_skipped_the_proxy
C-36 The self-hosting identity example is the safe one¶
- Claim: The identity example the self-hosting guide shows uncommented is a
placeholder, never a literal, and local development is sent to
make dev. - Asserted in:
docs/self-hosting.md— "Never put a literal such asdev@localhostinIDENTITY_ID_SOURCE." - Status: enforced
- Enforced by:
tests/test_preflight.py::passes preflighttests/test_preflight.py::the scan found the examples it is meant to check
C-37 The AWS audience setting matches what the load balancer signs¶
- Claim: The task definition sets
IDENTITY_AUDIENCEto the value the API compares against, and the API refuses to start on a listener ARN. - Asserted in:
docs/deploy.md— "loadbalancer/app/chai/a" - Status: partial
- Gap: No test has a live ALB, so that it puts its load balancer ARN in
signerrests on AWS's documented token format. #51 is fixed to that extent. - Enforced by:
server/tests/test_auth.py::TestAlbAudienceIsTheLoadBalancerArn::test_every_arn_the_docs_show_for_the_audience_startsserver/tests/test_auth.py::TestAlbAudienceIsTheLoadBalancerArn::test_a_listener_arn_is_refused_at_startupserver/tests/test_auth.py::TestAlbAudienceIsTheLoadBalancerArn::test_the_load_balancer_arn_is_what_the_alb_signs
C-38 MFA is whatever the hospital already requires¶
- Claim: Authentication, including multi-factor, is enforced at the front door and not by this application.
- Asserted in:
docs/deploy.md— "MFA is whatever the hospital already requires" - Status: unenforced
- Gap: Rests on the hospital's identity provider and the cloud front door, which are outside this repository and cannot be exercised from a test here.
C-39 Following 'Close the back door' makes the API unreachable except through SSO¶
- Claim: Each cloud section's back-door steps are, in the guide's words, "not hardening"; they are the deployment.
- Asserted in:
docs/deploy.md— "They are the deployment." - Status: unenforced
- Gap: Rests on each cloud's own network configuration. The guide's 'Proving the back door is closed' commands are for an operator to run per deployment; nothing here runs them.
C-40 The API refuses identity headers from an unexpected peer¶
- Claim: With
TRUSTED_PROXY_CIDRset, a request whose TCP peer is outside it is rejected, andX-Forwarded-Forcannot satisfy the check. - Asserted in:
docs/deploy.md— "refuses identity headers arriving from anywhere else" - Status: enforced
- Enforced by:
server/tests/test_auth.py::test_trusted_proxy_cidr_rejects_an_outside_peerserver/tests/test_auth.py::test_x_forwarded_for_cannot_satisfy_the_peer_check
C-41 The data survives docker compose down¶
- Claim: Postgres writes to a named volume, which
downleaves alone. - Asserted in:
docs/self-hosting.md— "the optional MCP service included; pgdata stays" - Status: enforced
- Enforced by:
tests/test_compose_isolation.py::the database leaving its named volume is noticed
C-42 The database image is pinned to a major version¶
- Claim: Postgres does not migrate its on-disk format, so a floating tag could make a volume unreadable on an unrelated pull.
- Asserted in:
compose.yaml— "Pinned to a major version, never :latest." - Status: enforced
- Enforced by:
tests/test_compose_isolation.py::db image '{image}' is noticed
C-43 A purge destroys the content of the history and of the audit log¶
- Claim: Every revision's document is emptied and every audit-log entry is redacted, including its prose and any extra field the client added, so a value pasted in by mistake is gone from both tables. A client cannot exempt its own entry.
- Asserted in:
docs/self-hosting.md— "A purge destroys the content of every revision and of every audit-log entry" - Status: enforced
- Enforced by:
server/tests/test_disposal.py::TestAPurgeReachesTheAuditLog::test_the_value_is_gone_from_the_history_and_the_logserver/tests/test_disposal.py::TestAPurgeReachesTheAuditLog::test_the_prose_is_redacted_not_just_the_structured_valuesserver/tests/test_disposal.py::TestAPurgeReachesTheAuditLog::test_a_field_the_server_does_not_own_is_gone_tooserver/tests/test_disposal.py::TestAPurgeReachesTheAuditLog::test_a_client_cannot_exempt_its_own_entry_from_redaction
C-44 A purge leaves who, when and which field, and records itself¶
- Claim: What survives is the author, the time, the field's path and the fingerprint, so the history shows that something was written and later destroyed, not a gap. The server writes its own record of the purge into the log, and that record is never redacted.
- Asserted in:
docs/self-hosting.md— "What stays is who changed what, when, which field, and the fingerprint" - Status: enforced
- Enforced by:
server/tests/test_disposal.py::TestAPurgeReachesTheAuditLog::test_who_what_and_when_surviveserver/tests/test_disposal.py::TestAPurgeReachesTheAuditLog::test_the_purge_itself_is_recorded_in_the_logserver/tests/test_disposal.py::TestAPurgeReachesTheAuditLog::test_the_record_of_a_purge_survives_the_next_purge
C-45 Deletion leaves a permanent tombstone¶
- Claim: A delete writes who deleted the project, when, and what its last revision hashed to, in an append-only table that holds no content and survives a purge. A refused or failed delete writes nothing.
- Asserted in:
docs/self-hosting.md— "It writes a permanent tombstone toproject_deletion: who deleted it, when," - Asserted in:
docs/guide.md— "writes a permanent record of who deleted it and when" - Status: enforced
- Enforced by:
server/tests/test_disposal.py::TestDeletionLeavesATombstone::test_a_delete_records_who_when_and_what_it_hashed_toserver/tests/test_disposal.py::TestDeletionLeavesATombstone::test_it_survives_a_purge_of_the_historyserver/tests/test_disposal.py::TestDeletionLeavesATombstone::test_it_cannot_be_changed_or_removed_by_handserver/tests/test_disposal.py::TestDeletionLeavesATombstone::test_a_refused_delete_writes_nothing
C-46 The delete dialog says what deleting does, and offers to destroy the history¶
- Claim: In the server mode the dialog says the version history is kept, offers destroying it as a separate unticked choice, does the destroy step first, and does not delete the project if that step fails. The modes with no history do not offer it.
- Asserted in:
app/i18n/en.json— "Its version history is kept." - Status: enforced
- Enforced by:
tests/test_disposal_ui.py::it says the version history is KEPTtests/test_disposal_ui.py::history first, then the project, in that ordertests/test_disposal_ui.py::the project is NOT deleted if its history could not be destroyed
C-47 Backups made before a purge still hold the data¶
- Claim: The guide states that a backup taken earlier keeps the destroyed
content.
make restoreno longer brings it back unnoticed: it re-applies the live database's purges, and says so when it cannot (C-75). - Asserted in:
docs/self-hosting.md— "Backups made earlier still hold the data." - Status: enforced
- Enforced by:
tests/test_purge_ledger.py::restoring a dump from before the purge brings the content backtests/test_purge_ledger.py::the mistaken content is gone again
C-48 A change cannot be merged to main unless CI passes¶
- Claim:
mainrequires thetests passedcheck, pinned to GitHub Actions, with no administrator bypass, so a pending or red run blocks the merge. - Asserted in:
docs/developing.md— "a pull request cannot be merged while it is pending or red" - Asserted in:
docs/adopting.md— "so a change cannot merge unless the whole test suite passes" - Status: unenforced
- Gap: A repository setting, not a file, so no test in the repository can
detect it being switched off. It was verified by hand, by trying to merge a
throwaway PR with the check pending and then red; both were refused. Re-check it
with
gh api repos/OWNER/REPO/rules/branches/main.
C-49 The dashboard's free-text fields do not feed the browser's spellchecker¶
- Claim: Every field that can hold free text, multi-line or single-line, turns spellcheck and form history off, so what is typed into it is not sent to a cloud spellchecker by the dashboard's own markup. (R-76.)
- Asserted in:
docs/deploy.md— "The dashboard turns spellcheck and form history off on every field that can hold free text" - Status: partial
- Gap: Number, date, select and checkbox controls keep the browser's default, because they hold no free text. A browser or extension may ignore the attribute. The deployment guide therefore also tells operators to disable cloud-backed spellcheck by policy, which nothing here can enforce. #59
- Enforced by:
tests/test_text_fields.py::every editable free-text field opts out of spellcheck and form historytests/test_text_fields.py::every free-text <input> in the source uses NO_BROWSER_ASSISTtests/test_text_fields.py::a field with the browser defaults is noticed
C-50 make up refuses a Google identity source without its prefix¶
- Claim: Google IAP's
accounts.google.com:prefix must be stripped, andmake uprefuses a Google identity source that does not set it. - Asserted in:
docs/self-hosting.md— "make uprefuses a Google source without it" - Status: enforced
- Enforced by:
tests/test_preflight.py::a Google identity source with no strip prefix is refusedtests/test_preflight.py::uncommenting the template's Google block, in place, passes preflightserver/tests/test_auth.py::TestThePrefixIsStrippedOnEveryPath::test_the_same_person_gets_the_same_id_by_either_path
C-51 The proxy runs unprivileged, with a read-only filesystem and no extra capabilities¶
- Claim: The only internet-facing service runs as a non-root user, drops every capability but one, and cannot write its own filesystem, so a compromise starts with far less than root.
- Asserted in:
docs/self-hosting.md— "it runs as an unprivileged user (uid 65532), drops every Linux" - Status: enforced
- Enforced by:
tests/test_compose_isolation.py::the proxy running as roottests/test_compose_isolation.py::the cloud proxy image: dropping USER is noticedtests/test_stack.py::the proxy runs as an unprivileged usertests/test_stack.py::the proxy and the api have a read-only root filesystem
C-52 Denied access is logged¶
- Claim: A refused request on a project the caller has no right to leaves one
access.deniedline naming who, which project, what was needed and what they held, without the document's contents. - Asserted in:
server/README.md— "produces oneaccess.deniedsecurity event" - Status: enforced
- Enforced by:
server/tests/test_denial_log.py::test_a_stranger_reading_a_project_is_loggedserver/tests/test_denial_log.py::test_a_reader_trying_to_write_is_loggedserver/tests/test_denial_log.py::test_the_line_does_not_leak_the_documentserver/tests/test_denial_log.py::test_permitted_requests_log_no_denial
C-53 The API cannot disable the append-only triggers¶
- Claim: The API serves as a restricted database role that cannot disable a trigger, truncate a table, alter the schema or hold the owner's credential, so the audit log and history are append-only against the application and not only against its bugs.
- Asserted in:
docs/self-hosting.md— "it cannot disable or drop a trigger, truncate, alter a table or create one" - Asserted in:
docs/what-it-does.md— "the shipped stack runs the service as a database role that cannot switch that protection off" - Asserted in:
docs/deploy.md— "An API whose connection owns the tables or is a superuser refuses to start" - Asserted in:
server/README.md— "The API refuses to start when its connection is the table owner or a superuser" - Status: partial
- Gap: A PostgreSQL superuser bypasses it, and the owner's password is still
in
.envon the host and in themigratejob. An API whose connection is the owner's refuses to start (R-74), but an operator who setsALLOW_OWNER_ROLE=truegets the weaker setup (reported on/api/health, logged, and failed bymake doctor). The cloud job definitions have not been run against a real managed database. #48 - Enforced by:
server/tests/test_app_role_config.py::test_serving_as_the_owner_is_refused_unless_allowedserver/tests/test_app_role_config.py::test_serving_as_the_owner_is_not_allowed_by_defaultserver/tests/test_roles.py::test_it_is_refused_every_statement_that_removes_a_guaranteeserver/tests/test_roles.py::test_its_table_privileges_are_exactly_the_documented_onesserver/tests/test_roles.py::test_every_table_has_a_decided_granttests/test_compose_isolation.py::the api being given the owner's passwordtests/test_stack.py::using the credential the API holds, the triggers cannot be disabledtests/test_stack.py::the API container holds no owner credential
C-54 The name lookup answers only for people the caller can already see¶
- Claim:
GET /api/principalsis not a staff directory anyone can walk: it answers for the caller and for people named on a project the caller can read, and for no one else. - Asserted in:
server/README.md— "It answers only for people the caller can already see" - Status: enforced
- Enforced by:
server/tests/test_principals.py::test_it_is_not_a_directory_anyone_can_enumerateserver/tests/test_principals.py::test_the_lookup_resolves_people_on_a_project_you_can_readserver/tests/test_principals.py::test_the_batch_is_bounded
C-55 Every use of emergency access is recorded in the project's own audit log¶
- Claim: An identity with emergency access holds owner rights on every project, and each use is recorded in that project's own audit log, which a purge never redacts, and as a warning an alert can match.
- Asserted in:
docs/deploy.md— "every use is recorded where the project's owners will see it" - Status: partial
- Gap: Reads of one project by one person are throttled to one entry per ten
minutes (every read is still an
access.breakglasssecurity event), and a delete cannot leave a log entry because the log goes with the project; it is recorded by the warning and the tombstone. Nothing here tests that a deployment alerts on the warning. #42 Listing projects that only emergency access could see is recorded as anaccess.breakglasssecurity event with a count, not in any project's own log. - Enforced by:
server/tests/test_breakglass.py::test_every_use_on_a_project_is_recorded_in_its_own_logserver/tests/test_breakglass.py::test_the_entry_survives_a_purgeserver/tests/test_breakglass.py::test_reads_are_throttled_but_writes_are_notserver/tests/test_breakglass.py::test_without_configuration_nobody_has_emergency_accessserver/tests/test_breakglass.py::test_the_record_is_visible_to_the_projects_new_owners
C-56 An event stream cannot outlive a revoked session by more than its lifetime¶
- Claim: The API ends an event stream after
SSE_MAX_LIFETIME_SECONDS, so the browser reconnects through the front door and is authenticated again; a revoked session loses its stream within that time. - Asserted in:
docs/deploy.md— "A revoked session therefore loses its stream within this many seconds." - Status: partial
- Gap: Tested that the API closes the stream and that a reconnect with no identity is refused. Nothing here runs a real front door, so that a real IAP, ALB or Easy Auth refuses the reconnect after revocation rests on how those products work. #49
- Enforced by:
server/tests/test_session.py::test_a_stream_is_closed_by_the_server_after_its_lifetimeserver/tests/test_session.py::test_reconnecting_works_and_is_authenticated_againtests/test_session_ui.py::a stream that is closed for good is reported
C-57 The front doors' session settings are listed correctly¶
- Claim: The table in the deployment guide names where each front door's session lifetime and sign-out are configured.
- Asserted in:
docs/deploy.md— "the IAP reauthentication policy on the protected resource (session duration)" - Status: unenforced
- Gap: Nothing here starts an IAP, an ALB, an Easy Auth or an oauth2-proxy session, so these come from the providers' documentation and could be out of date. The page says so. #49
C-58 Security events are named and the docs list exactly them¶
- Claim: The security events the code names (
SecurityEvent) are each written as one JSON object tagged"stream": "security"with a stable name and fields, and the table in the deployment guide lists exactly those names. Levels and fields are documented but not machine-checked, and the guide says so. Not every security-relevant action has a named event: an access-list change, an archive and amake disposerun are recorded in the project log or a table, and no test would notice a new action that has no event. - Asserted in:
docs/deploy.md— "a test fails if a name here and in the code ever disagree" - Status: enforced
- Enforced by:
server/tests/test_security_events.py::test_the_documented_events_are_exactly_the_ones_the_code_can_emitserver/tests/test_security_events.py::test_every_line_is_one_json_object_with_a_streamserver/tests/test_security_events.py::test_a_request_with_no_identity_is_a_named_eventserver/tests/test_security_events.py::test_a_hostile_actor_id_cannot_forge_a_second_linetests/test_stack.py::creates, deletes and purges are named security events
C-59 Security events are collected, retained and alerted on¶
- Claim: The deployment guide tells an operator how to collect, retain and alert on the security stream on each platform.
- Asserted in:
docs/deploy.md— "Where it goes." - Status: unenforced
- Gap: This repository emits the events and tests that it does. It collects, retains and alerts on nothing: the sink, the filters and the retention are the operator's, and the per-cloud settings come from the providers' documentation and have not been run. The guide says so. #38
C-60 Every read of a record is recorded, and the trail cannot be edited¶
- Claim: A list, a version read, a log read and a stream attach are each recorded in the transaction of the read, in an append-only table that outlives the project, so a read that cannot be recorded is not served.
- Asserted in:
docs/deploy.md— "A read that cannot be recorded is not served." - Asserted in:
docs/what-it-does.md— "Who read a record is recorded too." - Asserted in:
docs/running.md— "| Who read what is recorded | No | Yes |" - Status: partial
- Gap: Exports are built in the browser and reported by a beacon a client can omit: they are a record of ordinary use, bounded by the recorded reads that fetched the data. The table is append-only against the application and the restricted role; a database owner can still disable its trigger, which is why the same facts are also security events. Rows past the retention period may be deleted by the owner's disposal step (R-56, C-77). #33
- Enforced by:
server/tests/test_access_audit.py::test_each_read_route_records_what_it_returnedserver/tests/test_access_audit.py::test_a_read_that_fails_leaves_no_rowserver/tests/test_access_audit.py::test_the_trail_is_append_onlyserver/tests/test_access_audit.py::test_the_trail_outlives_the_projectserver/tests/test_access_audit.py::test_the_check_constraint_and_the_enum_agreetests/test_export_beacon.py::the PDF export is reportedtests/test_stack.py::each read was recorded in the real database
C-61 A backup can be verified by restoring it, and a restore asks first¶
- Claim:
make verify-backuprestores a dump into a throwaway PostgreSQL and fails unless the tables, rows and append-only triggers came back with the triggers enabled, and, from migration 010, unless the retirement rules are exactly the ones the latestretirement_rule_changerow recorded; andmake restoreasks forYESbefore overwriting the live database. - Asserted in:
docs/self-hosting.md— "make verify-backuprestores the newest dump" - Status: enforced
- Enforced by:
tests/test_verify_backup.py::a good encrypted dump verifiestests/test_verify_backup.py::a dump that restores without the append-only triggers failstests/test_verify_backup.py::a dump whose rules' history trigger is disabled failstests/test_verify_backup.py::a dump whose rules differ from their history's last change failstests/test_verify_backup.py::the wrong passphrase failstests/test_verify_backup.py::a truncated dump failstests/test_verify_backup.py::with no answer it aborts and touches nothing
C-62 The per-platform backup settings are listed correctly¶
- Claim: The deployment guide names the flags that set backup retention, point-in-time recovery, deletion protection and an off-account copy on each managed database.
- Asserted in:
docs/deploy.md— "Two rules apply on every platform." - Status: unenforced
- Gap: Nothing here creates a Cloud SQL, RDS or Azure database, so the flags come from the providers' documentation and could be out of date. The page says so and does not recommend a retention length. #52
C-63 A production-shaped stack is not brought up without an encryption decision¶
- Claim:
make uprefuses a stack using its own database volume, until the operator confirms the disk is encrypted, whenHTTP_BINDis not loopback orSITE_ADDRESSis a real hostname (anything other thanlocalhost,127.0.0.0/8,::1or a bare port), and does not claim to be able to check it. - Asserted in:
docs/self-hosting.md— "make uprefuses a stack that uses its own database volume until you do, whenHTTP_BINDis not loopback orSITE_ADDRESSis a real hostname" - Status: enforced
- Gap: A loopback bind with a bare-port
SITE_ADDRESS(http://:80) behind a reverse proxy the operator runs is reachable from the network and is not asked for an encryption decision:.envalone cannot show it. The test "loopback needs no confirmation" pins that case as exempt, and the docs tell the operator to set the confirmation anyway. - Enforced by:
tests/test_preflight.py::a non-loopback bind with no confirmation is refusedtests/test_preflight.py::STORAGE_ENCRYPTION_CONFIRMED=1 accepts ittests/test_preflight.py::loopback needs no confirmation: that is a laptoptests/test_preflight.py::loopback bind + a real hostname still needs the decisiontests/test_preflight.py::mutation: every site local -> a hostname slips throughtests/test_doctor.py::when the host cannot be inspected it says it cannot tell, not that it is fine
C-64 The managed databases' encryption settings are described correctly¶
- Claim: Cloud SQL and Azure encrypt storage by default, and RDS only if
--storage-encryptedis passed at creation. - Asserted in:
docs/deploy.md— "off unless you pass--storage-encryptedwhen you create the instance" - Status: unenforced
- Gap: Nothing here creates a managed database, so these come from the providers' documentation. The page says so and tells the operator to confirm the setting on the instance they have. #47
C-65 What runs is locked, pinned and watched¶
- Claim: The API's dependencies are installed from a hash-pinned lock, the base images and actions are pinned by digest or commit, Dependabot watches each ecosystem, and a scan fails on a fixable HIGH or CRITICAL finding in our images. The API runtime image ships no pip, setuptools or ensurepip, neither in its venv nor for the base image's own interpreter. (Until D-93 the base interpreter's pip stayed in the API image, though R-43 said it did not: the only test looked in the venv.)
- Asserted in:
docs/deploy.md— "What is running is what the repository says" - Asserted in:
server/Dockerfile— "carries no compiler, no pip cache, no pip" - Status: enforced
- Enforced by:
tests/test_supply_chain.py::every locked requirement is pinned and hashedtests/test_supply_chain.py::the API image's bases are pinnedtests/test_supply_chain.py::the images compose pulls are pinnedtests/test_supply_chain.py::dependabot covers pip, both Dockerfiles, compose and the actionstests/test_supply_chain.py::security.yml scans HIGH and CRITICAL, on a scheduletests/test_supply_chain.py::pip is not in the runtime imagetests/test_supply_chain.py::no pip, setuptools or ensurepip is anywhere in the API runtime imagetests/test_supply_chain.py::and the API image's system interpreter cannot import piptests/test_supply_chain.py::and removes the base image's own pip, setuptools and ensurepip (R-43, D-93)
C-66 The security policy promises no response times¶
- Claim:
SECURITY.mdcommits to no response or fix times, so no one relying on it is told the project will meet one. - Asserted in:
SECURITY.md— "There are no response or fix times" - Status: enforced
- Enforced by:
tests/test_supply_chain.py::SECURITY.md promises no response or fix times
C-67 Growth is reported and the disk alerts are documented¶
- Claim:
make doctorreports how large the append-only tables are, and the deployment guide names the disk alert for each platform. - Asserted in:
docs/deploy.md— "Alert on the disk instead" - Status: partial
- Gap: The report is tested; the per-platform alert metrics and flags are from the
providers' documentation and nothing here creates a database or an alert. The
tables do not shrink by themselves.
make dispose(R-56) applies the retention periods when an operator runs it, and nothing here runs it on a schedule, so the disk alert stays the control. #54 - Enforced by:
tests/test_doctor.py::the revision count and size are reportedtests/test_doctor.py::the read trail's size is reported too, with why it growstests/test_doctor.py::it says these tables never shrink by themselves
C-68 The deployment guide makes no legal claim about a region¶
- Claim: The deployment guide leaves the region to the reader and does not say that any region meets a legal requirement.
- Asserted in:
docs/deploy.md— "This guide is not legal advice" - Status: partial
- Gap: The test checks that the section exists, names no region as adequate or compliant, and that no command example names a region. It cannot tell whether the provider region names are current; they are marked unverified. #58
- Enforced by:
tests/test_deploy_residency.py::no command example names a concrete regiontests/test_deploy_residency.py::the Data residency section is complete
C-69 A restore brings purged content back, and the docs say so¶
- Claim: A dump restored after a purge reinstates the purged content, so a purge for an erasure request is undone by an ordinary recovery unless it is re-applied.
- Asserted in:
docs/privacy.md— "A restore brings purged content back" - Status: enforced
- Enforced by:
tests/test_subject_access.py::a restore of a dump from before a purge brings the purged content back
C-70 Subject access searches every database column that records who did something¶
- Claim:
make subject-accessreports every table and column that records who did something, and changes nothing. - Asserted in:
docs/privacy.md— "It changes nothing." - Status: partial
- Gap: Columns named
*_by,by_idoractorare checked against the query; an identifier stored under another name, or only inside new jsonb, is not. #57 The security-event log, the proxy's logs, backups and the purge ledger also hold identifiers and are not searched. The column check is a substring test of the query text, so a column named only in a comment would count. - Enforced by:
tests/test_subject_access.py::every column that records who did something is searchedtests/test_subject_access.py::it changed nothing
C-71 An export carries its own provenance¶
- Claim: Each export states which storage mode produced it, in the header's own words, and browser-only exports say sign-offs are self-asserted.
- Asserted in:
app/js/00-core/30-state.js— "Sign-offs here are self-asserted" - Status: enforced
- Enforced by:
tests/test_export_provenance.py::the HTML report states the modetests/test_export_provenance.py::browser-only exports say sign-offs are self-assertedtests/test_export_provenance.py::the JSON export states the mode
C-72 The data scope is stated in every mode¶
- Claim: Every storage mode, the self-hosted server included, tells the user never to enter patient-identifiable information, and the docs say so first.
- Asserted in:
app/i18n/en.json— "Governance metadata only: never enter patient-identifiable information." - Status: partial
- Gap: A statement of policy. Nothing inspects text as it is typed, so the
tests prove the instruction is shown, not that it is followed.
make phi-scan(C-73, C-74, R-50) checks stored records on demand for a few identifier patterns; it does not look for names or dates on their own, and a clean run is not a guarantee. R-22 was answered by R-50. #34 - Enforced by:
tests/test_boot_storage.py::healthy API -> the data scope is stated in the layouttests/test_boot_storage.py::static host -> the warning states the same data scopetests/test_data_scope.py::the comparison table says Never in every mode
C-73 The PHI scan never repeats what it finds¶
- Claim:
make phi-scanreports the project, field and revisions of a match, never the matched text, and a clean run does not say no patient information is present. - Asserted in:
docs/self-hosting.md— "never the matched text" - Status: enforced
- Enforced by:
tests/test_phi_scan.py::the matched text is never printedtests/test_phi_scan.py::a clean run does not claim there is no patient information
C-74 The PHI scan reaches the history¶
- Claim: The scan reads every unpurged revision and unredacted audit entry, not only the live record, so a value edited out of the record is still found.
- Asserted in:
docs/self-hosting.md— "every revision not yet purged" - Status: partial
- Gap: It finds only the patterns it has. A name or date that is not labeled as one, and a free-form case description, is not checked, by design (R-50). Earlier backups are not scanned. #66
- Enforced by:
tests/test_phi_scan.py::the history is scanned, and the revisions are namedtests/test_phi_scan.py::the audit log is scanned
C-75 A restore puts purges back¶
- Claim:
make restorere-applies every purge the live database holds after it restores an older dump, and says so when it cannot. - Asserted in:
docs/privacy.md— "make restoreputs the purges back." - Status: partial
- Gap: When the live database cannot be read, the purges come back only if
someone rebuilds the ledger from the security log; purge events from before
this change lack the fields and must be re-applied by hand. #116
The warning when the live database cannot be read is checked as text in the
recipe (
make -n), not by a run where the capture fails and the restore proceeds. - Enforced by:
tests/test_purge_ledger.py::the mistaken content is gone againtests/test_purge_ledger.py::work written after the restore is untouchedtests/test_purge_ledger.py::capture, then the restore, then reapply
C-76 A translated warning is shown only once someone has reviewed it¶
- Claim: The safety-bearing text listed under
@meta.safetyinen.jsonstays in English in a language until a reviewer is recorded for that key in that catalog. The list is the patient-data notice (safety.scope), the storage-mode banners (safety.local.title,safety.local.detail,safety.server.detail), the report disclaimers and footers, the provenance notes, the rules banner and the agent-token warnings. - Asserted in:
docs/developing.md— "are shown in English until someone" - Status: enforced
- Enforced by:
tests/test_i18n.py::the unreviewed safety warning stays in Englishtests/test_i18n.py::once a reviewer is recorded, it shows in Spanishtests/test_i18n.py::every safety key, every language: English until reviewed, then translatedtests/test_i18n.py::mutation: a lookup that skips the reviewer check fails every pair
C-77 Only the database owner can dispose, and not of a recent read¶
- Claim: By default the API's role cannot run disposal or delete the read
trail (with
ALLOW_OWNER_ROLE=truethe API serves as the owner and can; see C-53, R-74). The database refuses an ordinary DELETE of a read-trail row younger than the period, even from the owner, unless the period itself is first shortened (R-54). - Asserted in:
docs/privacy.md— "Only the database owner can dispose. The API cannot: its database role has no" - Asserted in:
docs/privacy.md— "database refuses an ordinary DELETE of a read-trail row younger than the period," - Status: partial
- Gap: A database owner or superuser can still disable the trigger with one statement (see C-60); the text says so and asks the operator to treat the owner role as trusted. #33
- Enforced by:
server/tests/test_retention.py::test_the_api_role_cannot_dispose_or_delete_the_read_trailserver/tests/test_retention.py::test_the_trigger_refuses_what_the_policy_does_not_allow
C-78 A litigation hold stops disposal¶
- Claim: The scheduled disposal (
make dispose) does not dispose of a project under a litigation hold, or its read trail, until the hold is lifted. A hold does not stop an owner from purging or deleting the project by hand in the dashboard. - Asserted in:
docs/privacy.md— "A project under a hold is listed in the" - Asserted in:
docs/privacy.md— "A project's owner can place a litigation hold, with a reason, and the scheduled disposal (make dispose) keeps the project and its read trail until it is lifted. A hold does not stop an owner from purging or deleting the project in the dashboard." - Status: enforced
- Gap: A hold stops the scheduled disposal only. An owner can still purge or delete a
held project by hand:
purge_versionsand delete do not consult the hold, and the cited tests exercisedispose_due()only. docs/privacy.md says so (owner ruling D4 of 2026-10-12). The quoted sentence in the quick-answer table must move to the corrected wording when the privacy.md change merges. - Enforced by:
server/tests/test_retention.py::test_a_held_project_is_reported_and_kept_until_the_hold_is_liftedserver/tests/test_retention.py::test_the_read_trail_loses_only_rows_past_the_period_and_not_held
C-79 Only a project's owners, and audited emergency access, see or change its litigation hold¶
- Claim: A hold, and the reason given for it, can be seen, placed and lifted only by an owner of the project, or by an identity with emergency access (C-55), whose every use is recorded in the project's own log. The owner chose to allow that, audited, on 2026-10-12.
- Asserted in:
app/i18n/en.json— "until it is lifted. Owners, and audited emergency access, see this." - Status: enforced
- Enforced by:
server/tests/test_holds.py::test_only_an_owner_may_see_or_change_a_holdserver/tests/test_breakglass.py::test_it_can_read_place_and_lift_a_hold_and_each_use_is_recordedtests/test_holds_ui.py::sees no hold section
C-80 The dashboard and its exports contact no outside service¶
- Claim: No screen, dialog, language or export of the dashboard makes a
request to another host, none of its exports carries a resource a viewer would
fetch on its own, and opening a saved report announces nothing to a third party
(R-05). A link the reader chooses to follow is not such a request. Checked on
the browser-only page with sample data, and on the server-backed page (served
from a local static server with a stand-in API, the signed-in person owning the
project): sign-out link, server scope notice, retirement-rules banner (each of
its states), access settings, litigation hold (section and both dialogs), the
audit log and its older entries, a read-only project, every export, agent
access (feature off, token list, mint, one-time reveal, revoke), the idle lock
and the delete dialog with its choice to destroy the history, in every
language. A request another host would receive, one the page's own policy
blocks, and a
securitypolicyviolationall fail the walk. - Asserted in:
docs/what-it-does.md— "The dashboard and its exports contact no outside service." - Asserted in:
docs/privacy.md— "the dashboard itself sends nothing to" - Asserted in:
docs/adopting.md— "The dashboard and its exports contact no outside service." - Status: enforced
- Enforced by:
tests/test_no_third_party.py::loading the dashboard makes no external requesttests/test_no_third_party.py::no screen, dialog, language or export makes a request to another hosttests/test_no_third_party.py::the Markdown export has no image (an image is a request when it is read)tests/test_no_third_party.py::opening the saved report makes no request to another hosttests/test_no_third_party.py::the HTML export has no http(s) resource URLs at alltests/test_no_third_party.py::the server-backed page requests no other host (catch-all route)tests/test_no_third_party.py::and its own policy refused nothing (no securitypolicyviolation)tests/test_no_third_party.py::every server-only screen was reachedtests/test_no_third_party.py::an <img> to another host is noticed although the policy blocks ittests/test_no_third_party.py::a fetch() to another host is noticed although the policy blocks it
C-81 A SHA-256 of the record travels in the JSON export¶
- Claim: The JSON export, and the HTML and Markdown reports, carry a SHA-256 and an MD5 of the record, and the digests can be recomputed from the JSON file alone.
- Asserted in:
app/i18n/en.json— "A SHA-256 travels in the JSON export for anyone who needs the stronger property." - Status: enforced
- Enforced by:
tests/test_fingerprint.py::a second implementation recomputes both from the file alonetests/test_fingerprint.py::an edited record fails to verifytests/test_fingerprint.py::the HTML report (which the PDF prints) has both digeststests/test_fingerprint.py::the notice says a SHA-256 travels in the JSON export, and now it doestests/test_export_schema.py::a record keyed by numbers: fingerprint recomputed (regression)tests/test_export_schema.py::answers keyed by numbers: fingerprint recomputed (regression)tests/test_export_schema.py::load_export.py reads it and says the fingerprint matchestests/test_fingerprint_skip.py::all six places name the same fields
C-82 Text a person types stays text, on every screen and in every export¶
- Claim: A value typed into any field of a project, or imported, never runs and never becomes markup in the dashboard, the HTML report, the PDF's frame or the Markdown report, cannot become a spreadsheet formula, and a new way of turning text into markup is reviewed before it is added (R-59).
- Asserted in:
docs/developing.md— "Text a person types stays text." - Asserted in:
docs/exports.md— "Whatever a person typed stays text in every export." - Status: partial
- Gap: Escaping is the first barrier, and the page's own policy is the second (C-84),
and both are tested in Chromium, Firefox and WebKit. The Markdown report is now also
rendered by markdown-it-py, a CommonMark reference implementation, as CommonMark and
as GFM, each with raw HTML refused and with it passed through (the worst case, no
sanitizer), and the HTML audited (#167, D-85). What that does not cover is every
other reader: GitHub's own renderer and sanitizer, an editor preview and a wiki are
not run, and syntax only some of them add (an @mention, a #reference,
$math, an emoji code, a footnote, an email autolink, a wiki's own markup) is not audited. A GFM renderer links a bare address a person typed, and a bare address with credentials (https://user:pass@host/) used to pass the audit; since #194 (D-88) the dashboard removes the credentials on entry and import and the export removes them again, and the audit fails on any credentialed link (C-118). A claim about every renderer cannot be proven with one, so this stays partial. - Enforced by:
tests/test_markdown_render.py::every payload, sample and language renders as text in every configurationtests/test_markdown_render.py::the example build's poisoned definition and records render as texttests/test_markdown_render.py::every weakened export is rejectedtests/test_markdown_render.py::a bare address with credentials, linked by a renderer, fails the audittests/test_injection.py::an app that does not escape is rejectedtests/test_injection.py::the CSV rule notices a formula celltests/test_injection.py::the Markdown rules notice a heading, a script link and raw HTMLtests/test_check_injection.py::a stored value interpolated into a class attribute is countedtests/test_check_injection.py::is within its baseline
C-83 The server never builds SQL from text¶
- Claim: Every SQL call passes a constant string with values as parameters, except the migration files and the role the API serves as, and hostile text in any field is stored and returned as text, except a NUL character, which the API refuses with a 422 because PostgreSQL cannot hold it.
- Asserted in:
docs/developing.md— "the server passes values to SQL as parameters and never in the SQL text" - Status: enforced
- Enforced by:
server/tests/test_sql_injection.py::test_every_sql_call_passes_a_constant_stringserver/tests/test_sql_injection.py::test_the_guard_notices_each_way_text_reaches_sqlserver/tests/test_sql_injection.py::test_hostile_text_is_stored_and_returned_as_textserver/tests/test_sql_injection.py::test_a_nul_character_is_refused_cleanly_not_with_a_server_error
C-84 The proxy's policy refuses script the page did not ship¶
- Claim: The Content-Security-Policy the proxy serves, and the same policy carried
by the page itself as a meta tag (so it holds on the example page and in a copy
opened from disk), allows the dashboard's one inline script by its SHA-256 and not
by
'unsafe-inline', so an injected script, an event handler or ajavascript:link is refused by the browser, and the exported report carries a policy of its own that allows no script at all. Each browser enforces the policy with its own implementation, so the tests run in Chromium, Firefox and WebKit (#160). - Asserted in:
proxy/Caddyfile— "The script is allowed by its hash, not by 'unsafe-inline'." - Status: enforced
- Enforced by:
tests/test_csp.py::names the hash of the page's one scripttests/test_csp.py::an injected inline script, handler and javascript: link did not runtests/test_csp.py::a policy that names a different script does not boot the apptests/test_csp.py::so a script put into a saved report does not runtests/test_csp.py::served with no header it boots, with no violationtests/test_csp.py::and refuses an injected script, handler and javascript: linktests/test_csp.py::opened from disk it refuses the same injection tootests/test_csp.py::a tag naming a different script does not boot the apptests/test_proxy_identity.py::content-security-policy
C-85 An evidence reference's file never leaves the browser¶
- Claim: Attaching a file to an evidence reference reads it in the browser and stores its name, size and SHA-256, and nothing else: the file is not uploaded, its bytes are not in the record, and the code that reads it has no way to send anything.
- Asserted in:
docs/guide.md— "The file is never uploaded." - Asserted in:
app/i18n/en.json— "A file is never uploaded: only its name, size and SHA-256 are kept" - Status: enforced
- Enforced by:
tests/test_refs.py::the stored file is a name, a size and a digest, nothing elsetests/test_refs.py::no bytes of the file are in the recordtests/test_refs.py::adding a file made no network requesttests/test_refs.py::the reference code has no way to send anythingtests/test_refs.py::a fetch in the reference code is noticedtests/test_refs.py::the stored SHA-256 is hashlib'sserver/tests/test_refs_merge.py::test_concurrent_adds_to_one_item_keep_every_reference
C-86 A reference's link is http or https, or it is not a link¶
- Claim: The address of an evidence reference becomes a link only if it is an http or https address with no credentials in it, both when it is typed and when it is read back from a stored or imported record, in the dashboard and in every export.
- Asserted in:
docs/guide.md— "A link opens only if it is an http or https address" - Status: enforced
- Enforced by:
tests/test_refs.py::a stored javascript: address is never made a linktests/test_refs.py::parentheses and brackets in an address cannot end the link earlytests/test_refs.py::entries that are not references are droppedtests/test_refs.py::with safeUrl weakened, the same probe shows the holetests/test_markdown.py::with safeUrl weakened the structure check would catch the holetests/test_injection.py::an app that does not escape is rejected
C-87 Formatting in notes cannot introduce markup¶
- Claim: In the notes and rationale fields, formatting is limited to bold, italic, lists and http(s) links, and for any text the HTML and the Markdown written from it contain only that: no raw HTML, image, table, heading, script or non-http link, whatever the text holds, and the work is bounded on input built to make a parser crawl.
- Asserted in:
docs/guide.md— "raw HTML, images, tables and headings are shown as the text they are" - Status: enforced
- Enforced by:
tests/test_markdown.py::HTML holds only the subset's tags, links and attributestests/test_markdown.py::no raw HTML, image, line break or foreign linktests/test_markdown.py::its links are the HTML's linkstests/test_markdown.py::with esc weakened the structure check would catch the tagtests/test_markdown.py::with safeUrl weakened the structure check would catch the holetests/test_injection.py::an app that does not escape is rejectedtests/test_markdown.py::every crawl input is boundedtests/test_markdown.py::text past the limit is kept, as plain text
C-88 The server retires exactly what the build's definition marks ending¶
- Claim: The database treats a project as retired, and starts its retention
clock (R-56), exactly when one of its checkpoints holds a decision that the
project's framework definition, as the deployed build's manifest states it,
gives a stop or retire class at that checkpoint. A record without a framework
stamp is CHAI's. The migrate job, and the API when it migrates itself with a
manifest, refuses any change (an ending decision added or dropped, or a new
primary) unless
RETIREMENT_RULES_ACKis the acknowledgment of exactly that change: the SHA-256 of every rule the table holds before and after it, the primary before and after, the history row it follows (its id and time), and the database's identity (012: the cluster's system identifier, the database's OID and the OID of the history table, none of which a dump carries). So a value printed for one change never accepts a different one (a build that also drops an unlisted framework's rules, a rollback to another primary, a switch to another primary over the same rules), once used it accepts nothing later, and another database refuses it even with the same history, as does a copy restored from a dump that creates the tables again (make restore), into another database or over the same one in place. It leaves the rules of frameworks the manifest does not list, and fails without a usable manifest, so disposal never changes silently (R-66, D-76). The operator is shown the projects that would become due or stop being due as they stood when the value was printed; the value binds the rules and the primary, not the records, so a project edited before the acknowledged run is affected without having been listed. Each guard in the SQL (the join on the record's framework, a blank stamp read as CHAI's, the clock at the latest ending decision), the parts of the acknowledgment (the identity dropped, a value from one database is accepted by another; the database's OID dropped, by a copy in another database; the history table's OID dropped, by a copy restored in place; the primary after dropped, by a switch to another primary), and the migration lock (taken before the rules are read, so a sync waiting on another job works from what that job committed) has a test that breaks it and fails. - Gap: the stack test proves only the default build: its rule set is
010's seed (the same hash), so it shows the migrate job read and confirmed the
manifest (
synced), not that a different rule set reaches a running stack. That a different set is loaded, refused or acknowledged is the server tests' evidence, against a real PostgreSQL and the real migrate job. The server tests restore a dump by copying its rows with COPY, as pg_dump does, into a database the real migrate job built;tests/test_verify_backup.pyshows with a real pg_dump that the identity differs after a restore into another database, over the same one in place, and into another cluster. A copy of the database's files (a base backup, a snapshot, point-in-time recovery, a promoted replica) keeps the identity and the history, so it accepts again a value printed against the state it holds, and a staging database made that way and production each accept a value printed on the other;docs/self-hosting.mdsays so, and advises building staging from a dump. A restore of the data alone into the tables already there (pg_dump --data-only, or TRUNCATE and reload, as the owner) keeps the history table's OID, so a value spent after the dump was taken is accepted again;docs/self-hosting.mdsays so too. Nothing prevents either. And the value does not bind the records, so the list the operator saw can be out of date when the value is used. - Asserted in:
docs/self-hosting.md— "another database refuses it even when its history is the same" - Asserted in:
docs/self-hosting.md— "loads those decisions into the database, which decides from them when a project is retired" - Asserted in:
docs/privacy.md— "A build of another framework retires on its own decisions" - Asserted in:
docs/frameworks/custom.md— "from the checkpoint options your definition classesstoporretire" - Status: enforced
- Enforced by:
server/tests/test_retention.py::test_every_chai_decision_retires_exactly_when_its_class_ends_a_projectserver/tests/test_retention.py::test_a_stand_in_primary_retires_by_its_own_words_once_acknowledgedserver/tests/test_retention.py::test_a_new_ending_decision_is_refused_until_acknowledgedserver/tests/test_retention.py::test_a_leftover_acknowledgment_never_matches_another_transitionserver/tests/test_retention.py::test_a_stale_manifest_cannot_undo_a_newer_oneserver/tests/test_retention.py::test_a_manifest_leaves_the_rules_of_frameworks_it_does_not_listserver/tests/test_retention.py::test_an_ack_for_a_harmless_addition_never_accepts_a_removalserver/tests/test_retention.py::test_a_rollback_is_acknowledged_and_does_not_revive_an_old_ackserver/tests/test_retention.py::test_a_leftover_ack_never_accepts_a_later_changeserver/tests/test_retention.py::test_a_primary_switch_over_the_same_rules_needs_an_ackserver/tests/test_retention.py::test_a_value_printed_for_one_database_is_refused_by_anotherserver/tests/test_retention.py::test_the_database_test_fails_without_the_identityserver/tests/test_retention.py::test_a_dump_restored_elsewhere_refuses_the_originals_value_both_waysserver/tests/test_retention.py::test_a_dump_restored_in_place_refuses_a_value_printed_before_itserver/tests/test_retention.py::test_the_restore_tests_fail_without_the_history_table_in_the_identityserver/tests/test_retention.py::test_the_restore_tests_fail_without_the_database_oid_in_the_identityserver/tests/test_retention.py::test_without_the_identity_a_change_is_refused_and_nothing_else_isserver/tests/test_retention.py::test_012_reads_the_identity_the_acknowledgment_statesserver/tests/test_retention.py::test_an_identity_with_a_null_part_is_refused_cleanlyserver/tests/test_retention.py::test_a_value_for_one_new_primary_is_refused_for_another_over_the_same_rulesserver/tests/test_retention.py::test_the_new_primary_test_fails_when_the_value_omits_the_primary_afterserver/tests/test_retention.py::test_every_part_of_the_acknowledgment_changes_itserver/tests/test_retention.py::test_a_record_follows_only_its_own_frameworks_rulesserver/tests/test_retention.py::test_the_mirror_a_chai_record_never_retires_by_anothers_wordsserver/tests/test_retention.py::test_a_blank_stamp_is_chaiserver/tests/test_retention.py::test_the_clock_starts_at_the_latest_ending_decisionserver/tests/test_retention.py::test_the_database_retires_by_the_table_not_by_literalsserver/tests/test_retirement_rules.py::test_the_migrate_job_fails_closed_without_a_usable_manifestserver/tests/test_retirement_rules.py::test_a_manifest_the_server_would_misread_is_refusedserver/tests/test_retirement_rules.py::test_the_api_migrating_itself_syncs_from_its_manifestserver/tests/test_retirement_rules.py::test_the_api_migrating_itself_without_a_manifest_says_soserver/tests/test_retirement_rules.py::test_an_api_with_no_manifest_never_reports_syncedserver/tests/test_retirement_rules.py::test_two_syncs_serialize_on_the_migration_lockserver/tests/test_retirement_rules.py::test_the_lock_test_fails_when_the_lock_is_taken_after_the_readstests/test_stack.py::the database retires by the served build's rule set, synced from ittests/test_verify_backup.py
C-89 The API's role cannot change which decisions retire a project¶
- Claim: By default (not with
ALLOW_OWNER_ROLE=true, C-53, R-74) the API's database role can read the retirement rules and their history and cannot insert, update, delete or truncate either, and the history refuses an update or a delete even from the owner. - Asserted in:
docs/privacy.md— "Nor can it change which decisions retire a project: its role may only read those rules." - Status: enforced
- Enforced by:
server/tests/test_retirement_rules.py::test_the_api_role_can_read_the_rules_and_not_change_themserver/tests/test_retirement_rules.py::test_the_rules_history_is_append_only_even_for_the_ownerserver/tests/test_roles.py
C-90 A writer cannot choose which framework's rules retire a record¶
- Claim: A record follows the retirement rules of the framework its
meta.framework.idnames, so a writer who could stamp any id could keep a record from ever coming due. The API refuses (422) a create that setsmeta.frameworkto anything but exactly{"id": <the primary the latest sync recorded>}; a record without one is CHAI's. While that primary is not CHAI, it also refuses a create that leaves the stamp out, so omitting it cannot choose CHAI's rules. It refuses a patch that would change the framework the record follows, as 010'srecord_framework()reads it before and after the patch (by setting, removing or blanking the stamp, or replacingmeta), and a patch that sets the stamp to anything but exactly the record's own framework: so a record written before a switch keeps its framework and stays editable, and one written under a later primary keeps it after a rollback. A create reads the primary under the migration lock, shared, so creates do not wait on one another: a sync that changes the primary waits for every create in flight, and one that starts during the sync waits and reads the new primary, so none is checked against a primary that has just been replaced. A patch reads no primary and takes no lock. Stamps written before this check, or by the database owner, are not re-checked, andmake disposeandmake verify-backupcount the records whose framework has no rules (R-66, D-76). - Asserted in:
docs/privacy.md— "Nor can a writer choose which framework's rules a record follows" - Asserted in:
docs/frameworks/custom.md— "the API accepts a new record only with your primary's stamp" - Asserted in:
docs/self-hosting.md— "so no record is checked against a primary that has just been replaced" - Status: enforced
- Enforced by:
server/tests/test_retirement_rules.py::test_the_api_refuses_a_stamp_that_is_not_the_active_primaryserver/tests/test_retirement_rules.py::test_the_api_accepts_no_stamp_or_exactly_the_active_primarysserver/tests/test_retirement_rules.py::test_a_stamp_follows_the_primary_the_latest_sync_recordedserver/tests/test_retirement_rules.py::test_under_another_primary_a_record_must_carry_its_stampserver/tests/test_retirement_rules.py::test_a_correctly_stamped_record_retiresserver/tests/test_retirement_rules.py::test_the_sql_reads_a_stamp_as_the_api_doesserver/tests/test_retirement_rules.py::test_a_write_that_read_the_primary_holds_off_a_switchserver/tests/test_retirement_rules.py::test_the_race_test_fails_without_the_shared_lockserver/tests/test_retirement_rules.py::test_two_creates_hold_the_lock_at_onceserver/tests/test_retirement_rules.py::test_the_shared_lock_test_fails_with_an_exclusive_lockserver/tests/test_retirement_rules.py::test_a_patch_never_waits_on_the_migration_lockserver/tests/test_retirement_rules.py::test_the_no_wait_test_fails_when_a_patch_takes_the_lockserver/tests/test_retirement_rules.py::test_a_patch_cannot_stamp_a_pre_switch_record_with_the_new_primaryserver/tests/test_retirement_rules.py::test_a_patch_cannot_unstamp_a_record_after_a_rollbackserver/tests/test_retirement_rules.py::test_no_patch_changes_a_records_framework_or_reshapes_its_stampserver/tests/test_retirement_rules.py::test_a_patch_that_keeps_a_records_framework_still_worksserver/tests/test_retirement_rules.py::test_the_framework_check_fails_when_a_patch_is_checked_against_the_primaryserver/tests/test_retirement_rules.py::test_the_framework_check_fails_when_only_the_stamp_shape_is_checkedtests/test_dispose.pytests/test_verify_backup.py
C-91 A framework definition's text is never run¶
- Claim: Whatever characters a framework definition holds (a name, an item, a status, a checkpoint, a sample), the dashboard shows them as text and runs none of them, on every screen and in the HTML and Markdown exports.
- Asserted in:
docs/frameworks/custom.md— "A definition's text is never run as code" - Status: enforced
- Enforced by:
tests/test_custom_build.py::no definition text is in the page raw, where it could end the scripttests/test_custom_build.py::the markup shows as text, on every screen that shows ittests/test_custom_build.py::no screen makes an element it namestests/test_custom_build.py::the flag's checkpoint name is escaped in the Markdowntests/test_custom_build.py::the HTML export holds no tag from ittests/test_custom_build.py::the Markdown export holds no tag from it
C-92 A build opens only its own framework's records¶
- Claim: A build of other frameworks lists, opens and imports only records stamped with its own primary; a record of another framework, including an unstamped one from the published build, is left off the portfolio and refused when opened or imported (R-67).
- Asserted in:
docs/frameworks/custom.md— "Your build lists, opens and imports only its own records" - Status: enforced
- Enforced by:
tests/test_custom_build.py::the portfolio lists only the example's recordtests/test_custom_build.py::opening an unstamped record is refusedtests/test_custom_build.py::opening another primary's record is refusedtests/test_custom_build.py::every record it makes is stamped with its primarytests/test_custom_build.py::an import of a recordtests/test_custom_build.py::an unstamped earlier review is not offeredtests/test_custom_build.py::and pressing import anyway adds nothing
C-93 A custom build's saved work is kept apart from the published build's¶
- Claim: In local mode a build of other frameworks stores its records under browser storage keys carrying its primary's id, never the published build's keys.
- Asserted in:
docs/frameworks/custom.md— "your build saves under browser storage keys that carry your primary's id" - Status: enforced
- Enforced by:
tests/test_custom_build.py::its records are stored under the example's key, not the published build'stests/test_custom_build.py::its view state is stored under the example's key, not the published build's
C-94 A custom build cannot overwrite the published page or its policy¶
- Claim:
build_app.py --configrefuses an--outthat would write underdocs/,proxy/orapp/(following links), and a custom build leaves the published dashboard and the proxy's policy byte for byte as they were. - Asserted in:
docs/frameworks/custom.md— "The build refuses an--outunderdocs/orproxy/" - Status: enforced
- Enforced by:
tests/test_build_config.py::the published page and policy are untouchedtests/test_build_config.py::an --out into docs/, proxy/ or app/ is refusedtests/test_build_config.py::--config without --out is refused
C-95 The page says when its server retires records by other rules, and records no decision¶
- Claim: A page served by the API compares the rule-set hash and primary its
build embedded with the server's
retirement_rules. When they differ it shows an alert saying so, and records no checkpoint decision and creates no record until they agree; when the server's rules were not synced from a manifest it says so. Nothing is shown when they agree. These refusals are the page's; the server's stamp and acknowledgment checks (C-88 to C-90) are what bind. - Asserted in:
docs/self-hosting.md— "Until they agree, the page records no checkpoint decision and creates no new record" - Asserted in:
docs/frameworks/custom.md— "it shows a banner saying so and records no decision and creates no new record" - Status: enforced
- Enforced by:
tests/test_rules_banner.py::a different hash shows the bannertests/test_rules_banner.py::as an alerttests/test_rules_banner.py::and a decision is refused even if pressedtests/test_rules_banner.py::a new record is refusedtests/test_rules_banner.py::a different primary shows the bannertests/test_rules_banner.py::unsynced rules show the bannertests/test_rules_banner.py::rules that agree show no bannertests/test_rules_banner.py::the page embeds its manifest's rule-set hash
C-96 A revision's fingerprint is the record's, as the dashboard shows it¶
- Claim: The server hashes each revision in the version history by the rule
the dashboard and
examples/load_export.pyuse (the record as stored, the same fields left out, keys in the same order, strings written alike, the project's id included), so the same record has one fingerprint on the setup page, in an export and in the history, an older record missing fields the dashboard fills in included. - Asserted in:
docs/exports.md— "a revision's fingerprint is the one the setup page and an export showed for that record" - Status: enforced
- Enforced by:
server/tests/test_fingerprint.py::test_the_server_gives_the_record_the_dashboards_fingerprintserver/tests/test_fingerprint.py::TestARevisionCarriesTheRecordsFingerprint::test_the_version_history_agrees_with_an_exportserver/tests/test_fingerprint.py::TestAnOlderRecordIsHashedAsStored::test_the_revision_is_the_stored_records_fingerprintserver/tests/test_fingerprint.py::test_a_lone_surrogate_is_written_as_javascript_writes_itserver/tests/test_fingerprint.py::test_the_server_gives_a_real_export_the_dashboards_fingerprintserver/tests/test_fingerprint.py::TestARealSampleRecordsRevision::test_the_version_history_gives_it_the_exports_fingerprinttests/test_export_schema.py::the committed sample export's fingerprint is the dashboard's digest of itstests/test_export_schema.py::the dashboard's stored record is the store's, nothing filled intests/test_export_schema.py::the shared record's fingerprint is the one the server andtests/test_export_schema.py::its export's fingerprint is the stored record'stests/test_export_schema.py::an edit's history entry carries the fingerprint of what was storedtests/test_fingerprint_skip.py::all six places name the same fields
C-118 Credentials are removed from addresses in text¶
- Claim: A user name or password in an address a person types, pastes or imports, in any field, is removed and the person is told, the change history never records it, and the Markdown report never prints it, even for a record that already holds it; the stored record, its fingerprint and the JSON export are not changed by the report (R-70).
- Asserted in:
docs/guide.md— "Credentials are removed from addresses." - Asserted in:
docs/exports.md— "An address in a value never keeps its credentials in the Markdown report" - Status: enforced
- Enforced by:
tests/test_address_credentials.py::every case: what remains, the count and idempotencetests/test_address_credentials.py::every weakened rule is noticedtests/test_address_credentials.py::the password is nowhere: not the record, the history, the queue or storagetests/test_address_credentials.py::the field ends as the address without credentialstests/test_address_credentials.py::a reference's title loses the credentialstests/test_address_credentials.py::a new project's name loses the credentialstests/test_address_credentials.py::a hold reason is sent without the credentialstests/test_address_credentials.py::and holds no password, in its words or its from and totests/test_address_credentials.py::every field that carried credentials holds the address without themtests/test_address_credentials.py::a legacy review keeps its address without the credentialstests/test_address_credentials.py::a record made with credentials is stored withouttests/test_address_credentials.py::the Markdown report has no passwordtests/test_address_credentials.py::the stored record is unchanged by the exporttests/test_address_credentials.py::so is its fingerprinttests/test_address_credentials.py::the JSON export keeps the stored text, as its contract saystests/test_address_credentials.py::every weakened guard is noticedtests/test_markdown_render.py::every weakened export is rejected
C-97 A token is accepted on the agent routes and on no other¶
- Claim: A bearer token authenticates a request only under
/api/agent/. On every other route it is not read: with no identity header the request is a 401, and beside an identity header the session answers and the token is ignored. Only the agent module reads theAuthorizationheader. - Asserted in:
docs/agent-api.md— "It is accepted under/api/agent/and on no other route. On every other route it is not read at all." - Asserted in:
docs/deploy.md— "It is not a way to sign in: no other route accepts it." - Status: enforced
- Enforced by:
server/tests/test_agent_api.py::test_a_bearer_alone_authenticates_nobody_on_any_other_routeserver/tests/test_agent_api.py::test_the_session_identity_never_reads_a_bearerserver/tests/test_agent_api.py::test_only_the_agent_module_reads_a_bearer_headerserver/tests/test_agent_api.py::test_the_agent_prefix_is_exactly_the_agent_routesserver/tests/test_agent_mutations.py::test_each_guard_is_noticed_when_broken
C-98 A token cannot mint, list or revoke tokens¶
- Claim: The routes that mint, list and revoke tokens take the signed-in session and nothing else, so a stolen token cannot make itself a successor or end its owner's other tokens.
- Asserted in:
docs/agent-api.md— "it cannot mint, list or revoke tokens (those routes take the person's signed-in session and nothing else)" - Status: enforced
- Enforced by:
server/tests/test_agent_api.py::test_a_token_cannot_mint_list_or_revoke_tokensserver/tests/test_agent_api.py::test_a_bearer_alone_authenticates_nobody_on_any_other_routeserver/tests/test_agent_mutations.py::test_each_guard_is_noticed_when_broken
C-99 Every bad token is one answer¶
- Claim: A token that is missing, malformed, unknown, wrong, expired, revoked or whose owner is gone gets the same status, the same body and the same header on every agent route, except that a source that has already used up its failures for the window is answered 429 instead, whatever bad token it sent (a working token is still served). For a token whose public id exists, the read trail records which it was (revoked or expired; a wrong secret on an existing id is recorded as unknown) and an agent.refused event is emitted, within the row budgets; a missing, malformed or unknown token leaves no row. None of it is in the response. Response timing is not claimed to be equal: whether a public token id exists can be told by timing.
- Asserted in:
docs/agent-api.md— "and removed are the same answer: the same status, the same body, the same header." - Status: enforced
- Enforced by:
server/tests/test_agent_api.py::test_every_bad_token_gets_the_same_401server/tests/test_agent_api.py::test_a_token_works_until_the_eighth_hour_and_not_afterserver/tests/test_agent_api.py::test_only_a_token_that_exists_leaves_a_trail_row_when_refusedtests/test_stack.py::a token that is wrong is the uniform 401server/tests/test_agent_api.py::test_a_flood_of_bad_tokens_writes_nothing_and_never_blocks_a_real_token
C-100 The identity header means nothing on an agent route¶
- Claim: The API never uses an identity header on the agent routes to decide who a caller is, whatever its name, so a client cannot choose who it is by sending one. As defense in depth the proxy deletes sixteen identity header names it knows and sets none; that list is fixed, and a header with another name reaches the API and is ignored there.
- Asserted in:
docs/agent-api.md— "The API never uses an identity header there to decide who a caller is, whatever its name, so a client cannot choose who it is by sending one." - Asserted in:
proxy/Caddyfile— "It is safe because of what this block does NOT do: it never sets an identity" - Status: enforced
- Enforced by:
server/tests/test_agent_api.py::test_the_identity_header_means_nothing_on_an_agent_routetests/test_proxy_identity.py::no client-supplied identity header reaches the APItests/test_proxy_identity.py::the proxy sets no identity on the agent routetests/test_proxy_agent_mutations.py
C-101 The proxy forwards only the three agent GET routes on that path¶
- Claim: Without a single sign-on session, the proxy forwards only GET
/api/agent/me,/api/agent/portfolioand GET paths under/api/agent/projects/(Caddy's*crosses slashes, so the API refuses a deeper path), and answers any other method or path under/api/agent/with a 404 itself. Every other route is as it was: a request with no signed-in identity is refused, whatever it carries. - Asserted in:
docs/agent-api.md— "forwards exactly the three agentGETroutes, deletes sixteen known identity header names on them as defense in depth and sets none" - Asserted in:
docs/self-hosting.md— "deletes the identity header names it knows (a fixed list of sixteen) as defense in depth and sets none" - Status: enforced
- Enforced by:
tests/test_proxy_identity.py::is refused by the proxytests/test_proxy_identity.py::agent route: none of those reached the APItests/test_proxy_identity.py::a bearer alone is refused by the proxytests/test_proxy_identity.py::a forged identity beside a bearer is refusedtests/test_proxy_agent_mutations.py
C-102 An agent sees what its owner's dashboard shows, and no more¶
- Claim: A token reads the projects the same person's own project list gives, from the same code, with archived projects taken out. It never has emergency access, whoever minted it, and loses a project the moment its owner loses access.
- Asserted in:
docs/agent-api.md— "The projects the token's owner's own dashboard lists, with archived ones taken out." - Asserted in:
docs/agent-api.md— "An agent never has emergency access, whoever minted its token" - Status: enforced
- Enforced by:
server/tests/test_agent_api.py::test_a_token_sees_exactly_the_projects_its_users_dashboard_listsserver/tests/test_agent_api.py::test_an_archived_project_is_never_returned_by_any_routeserver/tests/test_agent_api.py::test_an_emergency_user_gets_no_emergency_access_through_a_tokenserver/tests/test_agent_api.py::test_losing_access_takes_the_project_from_a_live_tokenserver/tests/test_agent_mutations.py::test_each_guard_is_noticed_when_broken
C-103 No name, email or free text reaches an agent¶
- Claim: An agent response is built from an allowlist of stored keys, never copied from the stored record: no name, no email, no note, rationale, evidence title, committee name or project name, and no computed phase, flag, score or verdict. A key a writer chose (an item, checkpoint or supplement id) and a status or decision are returned only if they are words of the build's framework definitions (see C-113); a person and a project are opaque ids.
- Asserted in:
docs/agent-api.md— "No name and no email appears in any response, and a project is a reference, not its name." - Asserted in:
docs/agent-api.md— "Each response is built field by field from an allowlist of stored keys." - Asserted in:
docs/privacy.md— "No name, no email and no free text is returned" - Status: enforced
- Enforced by:
server/tests/test_agent_api.py::test_nothing_hostile_planted_anywhere_reaches_an_agentserver/tests/test_agent_api.py::test_the_response_models_are_closed_allowlistsserver/tests/test_agent_api.py::test_the_vocabulary_is_the_framework_definitionsserver/tests/test_agent_api.py::test_a_builds_own_words_pass_and_the_published_ones_do_not_cross_overserver/tests/test_agent_docs.py::test_the_documented_response_fields_are_the_models_exactlyserver/tests/test_agent_mutations.py::test_each_guard_is_noticed_when_broken
C-104 People are opaque ids¶
- Claim: A person and a project are shown to an agent only as keyed
pseudonyms (HMAC-SHA256 under
AGENT_ID_SECRET, a different domain prefix for each): the same one is the same id in every call of a deployment, another deployment's key gives other ids, and the id is not the identity or a bare hash of it. A project is found by its reference among what the person's dashboard shows, and an unknown, an invisible and an archived one are one 404. - Asserted in:
docs/agent-api.md— "People and projects as opaque ids (u_andpr_, then 32 hex characters)." - Status: enforced
- Enforced by:
server/tests/test_agent_api.py::test_opaque_ids_are_stable_keyed_and_not_the_identity_or_its_hashserver/tests/test_agent_api.py::test_the_same_person_is_the_same_id_across_calls_and_projectsserver/tests/test_agent_api.py::test_a_different_deployments_secret_gives_different_idsserver/tests/test_agent_api.py::test_a_short_secret_stops_the_server_startingserver/tests/test_agent_api.py::test_a_project_ref_is_stable_keyed_and_resolves_only_what_is_visible
C-105 A token lasts exactly 8 hours and is stored only as a hash¶
- Claim: A token is valid for exactly 8 hours from minting, and the database refuses any other lifetime. It is shown once, in the mint response; the server keeps a SHA-256 hash of it, and its identity and expiry cannot be rewritten.
- Asserted in:
docs/agent-api.md— "Exactly 8 hours from the moment it is minted, and the database refuses any other lifetime." - Asserted in:
docs/agent-api.md— "it stores only a SHA-256 hash of the whole token" - Status: enforced
- Enforced by:
server/tests/test_agent_api.py::test_a_minted_token_has_the_shape_and_the_fixed_lifetimeserver/tests/test_agent_api.py::test_the_database_refuses_any_other_lifetimeserver/tests/test_agent_api.py::test_the_secret_is_shown_once_and_stored_only_as_a_hashserver/tests/test_agent_api.py::test_a_token_works_until_the_eighth_hour_and_not_afterserver/tests/test_agent_api.py::test_a_tokens_identity_and_expiry_cannot_be_rewrittenserver/tests/test_agent_api.py::test_tokens_are_compared_in_constant_time_and_only_thereserver/tests/test_agent_mutations.py::test_each_guard_is_noticed_when_broken
C-106 A person holds at most five live tokens by default¶
- Claim: By default a sixth live token is refused with a 409 (the limit is
AGENT_MAX_ACTIVE_TOKENS), also when two mints arrive at once. Expired and revoked tokens do not count. - Asserted in:
docs/agent-api.md— "A sixth is refused with409until one expires or is revoked." - Status: enforced
- Enforced by:
server/tests/test_agent_api.py::test_a_person_holds_at_most_five_live_tokensserver/tests/test_agent_api.py::test_two_mints_at_once_cannot_both_pass_the_countserver/tests/test_agent_api.py::test_the_limit_is_a_setting
C-107 A token dies with its owner's principal¶
- Claim: Erasing a person's principal, which
make disposedoes when nothing retained names them, deletes their tokens with it, and a deleted token stops working. Removal from the identity provider is not seen by the app, so the 8 hours is the bound there (docs/agent-api.mdsays so, under "Removal from your identity provider is not seen"). - Asserted in:
docs/agent-api.md— "Their tokens are deleted with it." - Asserted in:
docs/privacy.md— "A token is deleted with the person'sprincipalsrow" - Status: enforced
- Enforced by:
server/tests/test_agent_api.py::test_a_token_is_deleted_with_its_principal_and_stops_workingserver/tests/test_agent_api.py::test_disposing_of_a_principal_takes_their_tokens_with_ittests/test_dispose.py::a disposed person's agent tokens go with themserver/tests/test_agent_mutations.py::test_each_guard_is_noticed_when_broken
C-108 A revoked token stops working on its next call¶
- Claim: The owner's revocation through the API, and the operator's through
make agent-tokens, end a token on its next call; a person can revoke only their own, and the operator's script ends exactly what it is told to and only live tokens. - Asserted in:
docs/agent-api.md— "A revoked token stops working on its next call." - Status: enforced
- Enforced by:
server/tests/test_agent_api.py::test_you_can_list_and_revoke_only_your_owntests/test_agent_tokens.py::revoke --token ends that tokentests/test_agent_tokens.py::revoke --user ends a person's live tokenstests/test_agent_tokens.py::revoke --everyone ends every live tokentests/test_stack.py::the owner can revoke it, and it stops workingserver/tests/test_agent_mutations.py::test_each_guard_is_noticed_when_brokenserver/tests/test_agent_api.py::test_a_token_the_operator_revoked_is_refused_on_its_next_call
C-109 Every agent call is in the read trail, marked as agent access¶
- Claim: An answered call writes a row to
access_event, in the same transaction as the answer, markedvia = 'agent'with the token's public id and never its secret. A refusal of a token that exists, or of a project the person cannot see, writes one with the reason. A malformed or unknown token, and a call turned away by a rate limit, write no row and are counted in the security log. - Asserted in:
docs/agent-api.md— "Not every call is a row, because the read trail is append-only and an unauthenticated caller must not be able to grow it" - Status: enforced
- Enforced by:
server/tests/test_agent_api.py::test_every_agent_call_is_in_the_read_trail_marked_as_agent_accessserver/tests/test_agent_api.py::test_the_portfolio_entry_lists_the_ids_that_were_returnedserver/tests/test_agent_api.py::test_only_a_token_that_exists_leaves_a_trail_row_when_refusedserver/tests/test_agent_api.py::test_agent_calls_also_reach_the_security_log_without_the_secretserver/tests/test_access_audit.py::test_the_check_constraint_and_the_enum_agreetests/test_stack.py::each call is in the real read trail, marked as agent accessserver/tests/test_agent_mutations.py::test_each_guard_is_noticed_when_brokenserver/tests/test_agent_api.py::test_an_agent_call_that_cannot_be_recorded_is_not_answered
C-110 An agent token has its own, lower rate limit¶
- Claim: Each token is limited separately and lower than a session is, and
only a call whose token verified spends it, so nobody can spend another token's
allowance by naming it. A failed call is counted against its source, which is the
socket peer (or the proxy-reported address only inside
TRUSTED_PROXY_CIDR), never a header a client can rotate. Through/mcpthe source is the peer the proxy itself observed, never a client'sX-Forwarded-For(C-139). - Asserted in:
docs/agent-api.md— "A working token** may make 60 calls per 60 seconds (AGENT_RATE_LIMIT_MAX_REQUESTS), lower than the limit a person at the dashboard has (240)." - Status: enforced
- Enforced by:
server/tests/test_agent_api.py::test_an_agent_token_has_its_own_lower_limitserver/tests/test_agent_api.py::test_the_source_is_the_peer_and_not_a_header_a_client_choosesserver/tests/test_agent_api.py::test_the_default_agent_limit_is_lower_than_the_default_session_limitserver/tests/test_agent_mutations.py::test_each_guard_is_noticed_when_brokentests/test_proxy_identity.py::mcp route: X-Client-Address is the peer this proxy observedtests/test_proxy_identity.py::mcp route: rotating the claimed addresstests/test_stack_mcp.py::rotated forwarding headers still end in 429, like the direct route
C-111 The agent API has no write route¶
- Claim: The agent API is three GET routes. A write verb is a 405 from the API and a 404 from the proxy, and no route under the prefix changes a record.
- Asserted in:
docs/agent-api.md— "Read-only. ThreeGETroutes under/api/agent/, and nothing that writes." - Status: enforced
- Enforced by:
server/tests/test_agent_api.py::test_the_agent_routes_are_read_onlytests/test_proxy_identity.py::is refused by the proxytests/test_stack.py::a write verb on the agent route is refused by the proxyserver/tests/test_agent_mutations.py::test_each_guard_is_noticed_when_broken
C-112 A flood of wrong tokens cannot grow the trail, burn the server or lock out a working one¶
- Claim: The token is checked before anything is limited. A bad token writes no read-trail row unless it names a token that exists, and then rows are bounded per token id across all sources and overall, by kind (wrong secret, right secret on a stopped token, project not visible), so a flood of one kind cannot starve another. A source that fails too often, and every source once all failures pass a global budget, is answered 429 with nothing written. The failure tally is bounded and O(1) per failure, and the flood leaves one summary line a window. A working token is never counted against a source or a budget and never refused for them, and a person's tokens share one allowance.
- Asserted in:
docs/agent-api.md— "A working token is never counted against a source or a budget" - Status: enforced
- Enforced by:
server/tests/test_agent_api.py::test_a_flood_of_bad_tokens_writes_nothing_and_never_blocks_a_real_tokenserver/tests/test_agent_api.py::test_nobody_can_spend_another_tokens_allowance_by_naming_itserver/tests/test_agent_api.py::test_the_per_token_limit_counts_only_requests_that_verifiedserver/tests/test_agent_api.py::test_wrong_secret_rows_are_bounded_per_token_across_every_sourceserver/tests/test_agent_api.py::test_wrong_secret_rows_are_bounded_overallserver/tests/test_agent_api.py::test_a_flood_of_wrong_secrets_cannot_starve_the_holders_own_refusalsserver/tests/test_agent_api.py::test_a_valid_tokens_unknown_refs_write_a_bounded_number_of_rowsserver/tests/test_agent_api.py::test_a_persons_tokens_share_one_allowanceserver/tests/test_agent_api.py::test_failures_past_the_global_budget_are_refused_without_accountingserver/tests/test_agent_api.py::test_the_summary_is_written_by_the_app_without_another_failureserver/tests/test_agent_api.py::test_the_summary_is_written_when_the_app_shuts_downserver/tests/test_agent_limits.py::test_a_hundred_thousand_sources_cost_a_bounded_time_and_memoryserver/tests/test_agent_limits.py::test_an_active_attackers_bucket_survives_the_evictions_it_causesserver/tests/test_agent_limits.py::test_a_flood_of_sources_is_one_log_line_not_one_per_sourceserver/tests/test_agent_limits.py::test_past_the_global_budget_nothing_is_accounted_per_sourceserver/tests/test_agent_limits.py::test_the_limiter_sweeps_the_keys_a_window_leaves_behindserver/tests/test_agent_mutations.py::test_each_guard_is_noticed_when_broken
C-113 A key a writer chose reaches an agent only if the build's definitions have it¶
- Claim: An item id, a checkpoint id, a supplement id, a status and a decision are returned only if they are words of the framework definitions the server's build carries, for the framework the record follows; a project's own id is never returned. A writer's sentence in any key position does not reach an agent.
- Asserted in:
docs/agent-api.md— "A key a writer chose (an item id, a checkpoint id, a supplement id) is returned only if it is a word of the framework definitions the server was built with" - Status: enforced
- Enforced by:
server/tests/test_agent_api.py::test_a_key_a_writer_chose_never_reaches_an_agentserver/tests/test_agent_api.py::test_a_builds_own_words_pass_and_the_published_ones_do_not_cross_overserver/tests/test_agent_api.py::test_with_no_vocabulary_loaded_an_agent_is_shown_no_framework_wordsserver/tests/test_agent_api.py::test_the_vocabulary_is_the_framework_definitionsserver/tests/test_agent_vocabulary.py::test_loading_replaces_the_table_wholesaleserver/tests/test_agent_vocabulary.py::test_the_migrate_job_loads_the_words_from_the_manifestserver/tests/test_agent_vocabulary.py::test_the_server_reads_the_words_strictlyserver/tests/test_agent_mutations.py::test_each_guard_is_noticed_when_broken
C-114 The token is read from one header, and this stack tries not to log it¶
- Claim: The token is read from the Authorization header and nowhere else: a token in a query parameter, a cookie or another header is refused. The API's access log drops the query string by the same rule as the proxy's, decided on the target as written: the word agent in any case, a percent-encoded character, a doubled slash or an empty or dot path segment before the query, or the word in the query itself; the proxy's log drops the query string of any request whose target is, or might be, an agent route (the word agent in any case, a percent-encoded character before the query, an absolute-form target, an empty or dot path segment) and the values of five token-like request headers.
- Asserted in:
docs/agent-api.md— "In theAuthorizationheader and nowhere else: one in a query parameter, a cookie or another header is refused." - Asserted in:
docs/agent-api.md— "The API's access log drops the query string by the same rule as the proxy's, decided on the target as written rather than the decoded path" - Status: partial
- Enforced by:
server/tests/test_agent_api.py::test_the_token_comes_from_the_authorization_header_and_nowhere_elseserver/tests/test_agent_api.py::test_a_token_sent_in_a_url_is_refused_and_not_logged_by_the_serverserver/tests/test_agent_api.py::test_the_access_log_does_not_keep_a_query_on_an_agent_routetests/test_proxy_identity.py::the proxy's log keeps no query oftests/test_proxy_identity.py::the proxy's log keeps no token-like request headertests/test_proxy_agent_mutations.py - Gap: A token in a URL path is logged by the API and the proxy, a request header with a name not on the proxy's fixed list is logged by the proxy, and a front door, load balancer or client in front of this stack may log a URL or a header (#164). The page says never to send a token anywhere but the Authorization header.
C-115 A client cannot choose the address it is limited by¶
- Claim: The source of a failed agent call is the socket peer, or the address
the proxy reported when
TRUSTED_PROXY_CIDRis set and the peer is inside it. A client that reaches the API directly and sends its ownX-Real-IPorX-Forwarded-Foris still one source. The source is always a parsed IP address orunknown, never text a client sent, and the image starts uvicorn with--no-proxy-headers, whose default would let a loopback peer set it. - Asserted in:
docs/agent-api.md— "The source** is the socket peer." - Status: enforced
- Enforced by:
server/tests/test_agent_api.py::test_the_source_is_the_peer_and_not_a_header_a_client_choosesserver/tests/test_agent_api.py::test_the_proxys_address_is_believed_only_inside_the_trusted_rangeserver/tests/test_agent_api.py::test_behind_a_trusted_proxy_each_client_is_its_own_sourceserver/tests/test_agent_api.py::test_the_source_is_always_an_address_or_unknownserver/tests/test_agent_api.py::test_uvicorn_is_started_without_proxy_headers_and_that_mattersserver/tests/test_agent_mutations.py::test_each_guard_is_noticed_when_brokentests/test_proxy_identity.py::mcp route: X-Client-Address is the peer this proxy observedtests/test_proxy_identity.py::mcp route: rotating the claimed addresstests/test_stack_mcp.py::rotated forwarding headers still end in 429, like the direct route
C-116 A weak secret for the opaque ids is refused at startup¶
- Claim: The server refuses to start on an
AGENT_ID_SECRETshorter than 32 characters, with fewer than 10 different ones, or made of one short pattern repeated, and the generator the docs give always passes. This is a filter for obvious mistakes and not a strength test, and the page says so. - Asserted in:
docs/agent-api.md— "The server refuses to start on one shorter than 32 characters" - Status: enforced
- Enforced by:
server/tests/test_agent_api.py::test_a_weak_secret_stops_the_server_startingserver/tests/test_agent_api.py::test_a_short_secret_stops_the_server_starting
C-117 A manifest with words no display word has is refused before anything changes¶
- Claim: The migrate job reads the framework words from the manifest with a shape and safety filter: it refuses a lone surrogate, a control, zero-width, bidirectional or private-use character, a word over 64 characters or with surrounding space, more than 5,000 words of a kind or 50,000 in all, and a repeated JSON key, before it changes anything. The manifest is trusted build output, and the page says the check is not a judgment of content.
- Asserted in:
docs/agent-api.md— "A manifest that fails is refused before the job changes anything." - Status: enforced
- Enforced by:
server/tests/test_agent_vocabulary.py::test_a_word_a_display_word_could_not_be_is_refusedserver/tests/test_agent_vocabulary.py::test_a_word_with_spaces_is_a_display_wordserver/tests/test_agent_vocabulary.py::test_a_manifest_with_too_many_words_overall_is_refusedserver/tests/test_agent_vocabulary.py::test_a_repeated_json_key_is_refused_and_not_silently_resolvedserver/tests/test_agent_vocabulary.py::test_a_manifest_that_cannot_be_loaded_stops_the_job_cleanlyserver/tests/test_agent_vocabulary.py::test_the_api_role_may_only_read_the_vocabularyserver/tests/test_agent_mutations.py::test_each_guard_is_noticed_when_broken
C-119 The agent token is shown once and the browser never keeps it¶
- Claim: The page that creates an agent token shows it once, in one element, and it is gone when the person dismisses it, leaves the page, changes language or closes the page. It is never in browser storage, the address, the history, the change log, a toast, the console, a pending save, the projects or the store, and never in a live region (R-72).
- Asserted in:
app/i18n/en.json— "It is shown once, when you create it, and cannot be shown again." - Asserted in:
app/i18n/en.json— "Copy it now. You will not see this token again." - Asserted in:
docs/agent-api.md— "The page shows the token once and the browser keeps it nowhere" - Status: enforced
- Enforced by:
tests/test_agent_access.py::the token is in one element onlytests/test_agent_access.py::and in no place a page keeps thingstests/test_agent_access.py::the token is nowhere, DOM includedtests/test_agent_access.py::a change of language draws the page again without the tokentests/test_agent_access.py::going back to the portfolio clears ittests/test_agent_access.py::and coming back does not show it againtests/test_agent_access.py::the page closing clears ittests/test_agent_access.py::the live regions are live and never hold ittests/test_agent_access.py::a secret the server should never send is neither kept nor showntests/test_agent_access.py::Mutations of the built page are each noticed
C-120 A token's label and the server's words are text on the agent page¶
- Claim: The label a person gives a token, and anything the server says in answering the page, is set as text and never as markup, so a label or a refusal carrying markup shows as characters and runs nothing.
- Asserted in:
docs/agent-api.md— "The label is shown to you only, as text" - Status: enforced
- Enforced by:
tests/test_agent_access.py::a label with markup is texttests/test_agent_access.py::a refusal quotes the server as text, cappedtests/test_agent_access.py::Mutations of the built page are each noticed
C-121 A person revokes their own token from the page, after being asked¶
- Claim: The page revokes a token only after a confirmation, sends exactly one revoke for it, and shows the token as revoked afterward.
- Asserted in:
docs/agent-api.md— "Revoke asks first, sends one request, and the list shows the token as revoked." - Status: enforced
- Enforced by:
tests/test_agent_access.py::Cancel sends nothingtests/test_agent_access.py::DELETE was sent once, for that tokentests/test_agent_access.py::the list shows it revoked, with no buttontests/test_agent_access.py::Mutations of the built page are each noticed
C-122 The agent access page is offered only where there is a server¶
- Claim: The browser-only copy has no agent access page and sends no request for one, because there is no server to create a token (R-72).
- Asserted in:
docs/agent-api.md— "The page is offered only when the dashboard is served by the app's server" - Status: enforced
- Enforced by:
tests/test_agent_access.py::no button in the browser-only copytests/test_agent_access.py::and opening it does nothingtests/test_agent_access.py::Mutations of the built page are each noticed
C-123 The MCP service is off unless its profile is asked for¶
- Claim: A plain
docker compose upstarts neither the MCP service nor its gate; only--profile agent-mcpdoes. With the profile off, the proxy's/mcpanswers502and nothing it reaches is the main API. - Asserted in:
docs/mcp.md— "The MCP server is off by default." - Asserted in:
docs/mcp.md— "A plaindocker compose up -ddoes not start it" - Asserted in:
compose.yaml— "OFF BY DEFAULT: both services below are in the" - Asserted in:
proxy/Caddyfile— "OFF BY DEFAULT. The upstream is themcp-gateservice" - Status: enforced
- Enforced by:
tests/test_compose_isolation.py::the mcp service without its profile (on by default)tests/test_compose_isolation.py::the gate without its profiletests/test_compose_isolation.py::a new service with no profiletests/test_compose_isolation.py::compose itself: a baretests/test_stack_mcp.py::no mcp or mcp-gate container is runningtests/test_stack_mcp.py::the proxy's /mcp is a 502 with nothing behind ittests/test_proxy_identity.py::mcp route: with {label}, POST /mcp is a 502
C-124 The MCP service holds no credential of its own¶
- Claim: The MCP service is given no token, secret, password or key: none in its environment, no env file, no way to the database, and no setting that could hold a token. The API's shared secret is the gate's, never the service's.
- Asserted in:
docs/mcp.md— "It stores no token, and has none of its own." - Status: enforced
- Enforced by:
tests/test_compose_isolation.py::given to mcptests/test_compose_isolation.py::mcp with an env filetests/test_compose_isolation.py::the gate without the shared secretmcp/tests/test_mcp_service.py::test_nothing_in_the_settings_can_hold_a_tokenmcp/tests/test_mcp_service.py::test_a_token_is_not_kept_after_its_requestmcp/tests/test_mcp_mutations.py::test_each_guard_is_noticed_when_broken
C-125 A token is forwarded for one request and is in no log, error or result¶
- Claim: Each request's own bearer token is forwarded, for that request only, to the agent API's routes. It is stored nowhere and is in no log line of the MCP service, in no error message and in no tool result, and a redirect is never followed with it.
- Asserted in:
docs/mcp.md— "It reads theAuthorizationheader of each request, forwards it for that request, validates what comes back, and answers." - Asserted in:
docs/mcp.md— "never a header, a query string or a body, and so never a token." - Status: enforced
- Enforced by:
mcp/tests/test_mcp_service.py::test_each_tool_returns_its_documented_result_and_forwards_the_callers_tokenmcp/tests/test_mcp_service.py::test_only_the_bearer_is_forwarded_no_identity_and_no_other_credentialmcp/tests/test_mcp_service.py::test_a_redirect_is_not_followed_so_the_token_goes_nowhere_elsemcp/tests/test_mcp_service.py::test_a_token_the_api_rejects_is_one_message_and_is_never_echoedmcp/tests/test_mcp_service.py::test_the_token_is_in_no_log_line_on_any_pathmcp/tests/test_mcp_service.py::test_a_token_is_not_kept_after_its_requestmcp/tests/test_mcp_process.py::test_what_the_process_writes_holds_no_tokentests/test_stack_mcp.py::the token's secret is in no log of the proxy, gate, service or APImcp/tests/test_mcp_mutations.py::test_each_guard_is_noticed_when_brokenmcp/tests/test_mcp_abuse.py::test_a_hostile_request_cannot_write_a_line_or_a_marker_into_the_logmcp/tests/test_mcp_abuse.py::test_a_crash_outside_the_tools_guard_is_one_plain_message_and_not_loggedmcp/tests/test_mcp_service.py::test_the_per_request_protocol_offers_the_same_three_tools_and_nothing_else
C-126 The MCP tools are exactly three read-only ones¶
- Claim: The service registers
whoami,list_portfolioandget_projectand nothing else: no write tool, no resource, no prompt and no tool that takes a URL or a path. Each tool is one call to one of the agent API's threeGETroutes, and no tool can reach another route. - Asserted in:
docs/mcp.md— "There is no write tool, no resource, no prompt, and no tool that takes a URL or a path." - Asserted in:
docs/mcp.md— "A client on the newer per-request protocol revision (2026-07-28, noinitialize) reaches the same three tools, with nothing to subscribe to." - Status: enforced
- Enforced by:
mcp/tests/test_mcp_service.py::test_only_the_three_allowlisted_tools_are_registeredmcp/tests/test_mcp_service.py::test_the_per_request_protocol_offers_the_same_three_tools_and_nothing_elsemcp/tests/test_mcp_service.py::test_no_resource_template_or_prompt_is_registeredmcp/tests/test_mcp_service.py::test_the_protocol_lists_exactly_the_allowlistmcp/tests/test_mcp_service.py::test_every_tool_is_read_only_typed_and_says_how_to_read_itmcp/tests/test_mcp_service.py::test_the_upstream_routes_are_the_three_get_routes_and_no_othersmcp/tests/test_mcp_service.py::test_a_call_asks_for_a_reference_it_can_name_and_nothing_elsemcp/tests/test_mcp_service.py::test_no_tool_can_reach_any_route_but_the_threemcp/tests/test_mcp_contract.py::test_the_routes_are_the_apis_three_get_routesmcp/tests/test_mcp_mutations.py::test_each_guard_is_noticed_when_brokentests/test_stack_mcp.py::tools/list is exactly the three read-only tools
C-127 No free text, name or email reaches a model through the MCP server¶
- Claim: Text planted in a stored record, in every field and as an item, project or checkpoint key, is in no tool result and no error: not a name, an email, a sentence of instructions or markup. What is returned is the framework's own words, opaque ids and dates.
- Asserted in:
docs/mcp.md— "No free text is returned in this version" - Status: enforced
- Enforced by:
mcp/tests/test_mcp_integration.py::test_hostile_text_planted_in_a_stored_record_never_reaches_a_modelmcp/tests/test_mcp_service.py::test_a_value_that_is_not_a_vocabulary_word_never_reaches_a_modeltests/test_stack_mcp.py::hostile text planted in the record is in no answerserver/tests/test_agent_api.py::test_nothing_hostile_planted_anywhere_reaches_an_agent
C-128 The service checks every answer's shape a second time, and says what that cannot do¶
- Claim: Every string the service passes on must be shaped like an id (one token
of at most 32 characters, no space, no run of five digits), a status or decision
(one to three plain words of letters, at most 32 characters), an opaque id or a
date, and an unknown field is refused, so a sentence of four or more words, an
address, markup or a word with digits in it makes the whole answer refused. Any
short phrase of one to three plain words passes: the shape cannot tell
Jane Doefrom a decision, so this is a second wall behind the API's list of the build's own words, and the page says so. - Asserted in:
docs/mcp.md— "This is a shape filter, not a list of words" - Status: partial
- Enforced by:
mcp/tests/test_mcp_service.py::test_a_value_that_is_not_a_vocabulary_word_never_reaches_a_modelmcp/tests/test_mcp_service.py::test_an_answer_that_is_not_the_documented_shape_is_refused_wholemcp/tests/test_mcp_service.py::test_a_name_that_is_shaped_like_a_word_is_the_apis_to_stopmcp/tests/test_mcp_contract.py::test_the_api_and_this_service_agree_on_what_a_vocabulary_word_looks_likemcp/tests/test_mcp_contract.py::test_the_second_wall_refuses_what_the_reviewer_got_through - Gap: a short phrase of one to three plain words, a name included, passes the shape check by design; the API's vocabulary allowlist (C-113) is the wall that stops it. A custom framework whose words are longer or have digits or punctuation would be refused whole (D-91 default (d)). (#164)
C-129 A failure is a fixed message, never the token or the upstream's body¶
- Claim: Every failure from this service or the API is one of a few fixed messages; a call the MCP SDK refuses itself (an argument that fails the schema, an unknown tool) carries the SDK's text, cleaned and cut to 300 characters. The token and the upstream's response body are in none of them; a 422's message is cut to plain characters, and an unexpected failure inside the service says only that it could not complete.
- Asserted in:
docs/mcp.md— "A failure is a tool error (isErroristrue). Failures that come from this service or the API carry one of a few fixed messages (below)." - Status: enforced
- Enforced by:
mcp/tests/test_mcp_service.py::test_a_failure_is_a_fixed_message_and_never_the_upstream_bodymcp/tests/test_mcp_service.py::test_a_token_the_api_rejects_is_one_message_and_is_never_echoedmcp/tests/test_mcp_service.py::test_a_422_carries_the_apis_message_cut_to_plain_textmcp/tests/test_mcp_service.py::test_a_429_says_when_to_retrymcp/tests/test_mcp_service.py::test_an_unexpected_failure_inside_the_service_is_one_plain_messagemcp/tests/test_mcp_abuse.py::test_a_crash_outside_the_tools_guard_is_one_plain_message_and_not_loggedmcp/tests/test_mcp_integration.py::test_a_token_that_is_wrong_in_any_way_gets_the_one_messagemcp/tests/test_mcp_docs.py::test_the_error_table_is_every_message_the_service_can_say
C-130 The Host and Origin are validated, and no CORS header is sent¶
- Claim: A request whose
Hostis not on the operator's list is refused, so is one carrying anOriginthat is not listed (the list is empty by default), both before the token is looked at, and no CORS header is ever sent. The service will not start without a host list. - Asserted in:
docs/mcp.md— "a request whoseHostheader is not on the list is refused with a421." - Asserted in:
docs/mcp.md— "No CORS header is ever sent." - Status: enforced
- Enforced by:
mcp/tests/test_mcp_service.py::test_a_request_for_another_host_is_refusedmcp/tests/test_mcp_service.py::test_a_browser_origin_that_is_not_listed_is_refused_even_with_a_tokenmcp/tests/test_mcp_service.py::test_with_no_origins_configured_no_browser_origin_gets_throughmcp/tests/test_mcp_service.py::test_origin_is_checked_before_the_tokenmcp/tests/test_mcp_service.py::test_no_cors_headers_are_ever_sent_and_a_preflight_is_not_servedmcp/tests/test_mcp_process.py::test_it_refuses_to_start_with_no_allowed_hosttests/test_stack_mcp.py::a foreign browser Origin is refusedtests/test_stack_mcp.py::a foreign Host is refused
C-131 The proxy's /mcp route skips sign-on, strips every identity header and sets none¶
- Claim:
/mcpbypasses single sign-on, deletes every header that could be read as an identity (andX-Client-AddressandX-Proxy-Secret) and sets none, even with a front door configured, forwards onlyPOST,GETandDELETEon exactly/mcp, caps the body, refuses everything else under the prefix with a404, and never reaches the main API. It deletes every forwarding header a client sent and setsX-Client-Addressfrom the peer it observed. - Asserted in:
docs/mcp.md— "deletes every identity header and sets none, forwards only the transport's three methods on exactly/mcp, and answers anything else under the prefix with a404." - Asserted in:
proxy/Caddyfile— "this block never SETS an identity and STRIPS" - Status: enforced
- Enforced by:
tests/test_proxy_identity.py::mcp route: forwarded with no sign-ontests/test_proxy_identity.py::the proxy sets no identity on the mcp route, with a front door configuredtests/test_proxy_identity.py::mcp route: the service saw exactly the three allowed requeststests/test_proxy_identity.py::mcp route: nothing reached the main API through ittests/test_proxy_identity.py::mcp route: a body over the cap is refusedtests/test_proxy_mcp_mutations.pytests/test_proxy_identity.py::mcp route: X-Client-Address is the peer this proxy observed
C-132 The MCP service can reach the gate and nothing else¶
- Claim:
mcpis on the internalagentnetwork withmcp-gateand no other network, publishes no port, and from inside it the API, the proxy and the database cannot be reached. - Asserted in:
docs/mcp.md— "so it cannot reach the API, the database, or the proxy's single sign-on listener." - Status: enforced
- Enforced by:
tests/test_compose_isolation.py::mcp on the edge networktests/test_compose_isolation.py::the proxy on the agent networktests/test_compose_isolation.py::the agent network made routabletests/test_compose_isolation.py::a stranger on the agent networktests/test_compose_isolation.py::a published port on mcptests/test_stack_mcp.py::from inside mcp only the gate is reachable (not the API, proxy or database)tests/test_stack_mcp.py::mcp is on the agent network and nothing else
C-133 The gate serves only the three GET routes and the MCP path, and holds the API's secret¶
- Claim:
mcp-gateforwards only the three agentGETroutes to the API and only/mcpto the service (and answers its own static health route), deletes every identity header and sets none, passes the caller's token untouched, passes the proxy'sX-Client-Addresson as the proxy set it and never reads a forwarding header or trusts a proxy list, sets the shared secret from its own environment, caps the body and drops a body that does not finish, and refuses everything else. - Asserted in:
proxy/Caddyfile.mcp-gate— "This file has no sign-on, no identity, and sets no identity header" - Status: enforced
- Enforced by:
tests/test_proxy_identity.py::gate: the answer is not cacheabletests/test_proxy_identity.py::gate: none of those reached the APItests/test_proxy_identity.py::gate: the service saw only the allowed requests on /mcptests/test_proxy_identity.py::gate: no identity header and no secret reaches the servicetests/test_proxy_identity.py::gate: X-Client-Address is passed on as the proxy set ittests/test_proxy_identity.py::gate: a forwarded-for claim never becomes the addresstests/test_proxy_identity.py::gate: a body trickled forever is cut off ontests/test_proxy_identity.py::gate: a body over the agent listener's cap is refusedtests/test_proxy_mcp_mutations.py
C-134 The MCP image is non-root, locked and pinned, and its containers are hardened¶
- Claim: The image installs only from a hash-verified lock with no pip, from digest-pinned bases, runs as a numeric non-root user with no login shell, and has no shell and no package manager at all, and a health check, is started directly and not through a shell, and its containers have a read-only root filesystem, no capability, no new privileges and no published port.
- Asserted in:
mcp/Dockerfile— "Non-root, no login shell, no home directory." - Asserted in:
compose.yaml— "SECURITY PROPERTY of this service: it can reach mcp-gate and nothing else." - Status: enforced
- Enforced by:
tests/test_compose_isolation.py::mcp with a writable root filesystemtests/test_compose_isolation.py::mcp keeping its capabilitiestests/test_compose_isolation.py::the gate running as roottests/test_compose_isolation.py::the mcp image: the real Dockerfile passestests/test_supply_chain.py::the MCP image's bases are pinnedtests/test_supply_chain.py::its user has no login shelltests/test_supply_chain.py::no shell and no apt or dpkg is in the MCP runtime imagetests/test_compose_isolation.py::keeping the shellstests/test_supply_chain.py::no pip, setuptools or ensurepip is anywhere in the MCP runtime imagetests/test_supply_chain.py::and the MCP image removes the base image's own pip (R-43, D-93)tests/test_stack_mcp.py::mcp has a read-only root filesystem
C-135 Only an empty AGENT_ID_SECRET turns the agent API off for everyone¶
- Claim: Turning the MCP service off does not stop a person calling the agent
API with their own program; the API is off only while
AGENT_ID_SECRETis empty, and the MCP service then says so. - Asserted in:
docs/mcp.md— "settingAGENT_ID_SECRETempty does, and it is the only switch for that." - Status: enforced
- Enforced by:
server/tests/test_agent_api.py::test_the_agent_api_is_off_without_its_secretmcp/tests/test_mcp_integration.py::test_it_says_so_when_the_agent_api_is_off
C-136 The MCP service is stateless and two callers never see each other's data¶
- Claim: The service keeps no session and hands out no session id; each request stands alone with its own token, and interleaved callers with different tokens each get exactly their own answers.
- Asserted in:
docs/mcp.md— "Stateless. Each request carries its own token and stands alone." - Status: enforced
- Enforced by:
mcp/tests/test_mcp_service.py::test_the_service_keeps_no_sessionmcp/tests/test_mcp_service.py::test_the_per_request_protocol_offers_the_same_three_tools_and_nothing_elsemcp/tests/test_mcp_service.py::test_interleaved_callers_with_different_tokens_never_see_each_others_datamcp/tests/test_mcp_integration.py::test_two_people_calling_at_once_never_see_each_others_projectsmcp/tests/test_mcp_docs.py::test_the_documented_curl_commands_run_and_answer_what_the_page_shows
C-137 The MCP service bounds its body, its time and its concurrency¶
- Claim: A request body over the limit is refused, a body not complete within
its deadline is dropped by the service with
408(the gate cuts off its own upstream leg at the same deadline), a call to the API that takes longer than the timeout fails, calls past the in-flight bound, in all or from one source, are refused after a short wait, and a credential that is not shaped like a token never takes a slot or reaches the API. The page says what is not bounded: a slow client's own connection to the proxy, which Caddy holds as it does on every route, and connections per source at the proxy and the gate. At the service one source holds at most a fixed number of requests in progress, a body still arriving included, and the service holds a fixed number of connections in all, so one source's slow bodies cannot stop another source's calls. - Asserted in:
docs/mcp.md— "8 requests in progress per source (MCP_MAX_REQUESTS_PER_SOURCE, then429), so one source cannot hold every connection." - Asserted in:
docs/mcp.md— "A call waits up to a second (MCP_QUEUE_WAIT_SECONDS) for a slot and is then refused as busy." - Asserted in:
docs/mcp.md— "A request whose body is not complete by then is dropped with408." - Status: enforced
- Enforced by:
mcp/tests/test_mcp_service.py::test_a_body_over_the_limit_is_refusedmcp/tests/test_mcp_service.py::test_a_slow_governance_server_times_outmcp/tests/test_mcp_service.py::test_concurrency_is_bounded_and_the_overflow_is_refusedmcp/tests/test_mcp_abuse.py::test_slots_are_exactly_the_limit_and_are_given_backmcp/tests/test_mcp_abuse.py::test_one_source_cannot_take_every_slotmcp/tests/test_mcp_abuse.py::test_a_call_waits_a_short_bounded_time_for_a_slotmcp/tests/test_mcp_abuse.py::test_garbage_is_the_one_rejected_message_with_no_slot_and_no_requestmcp/tests/test_mcp_abuse.py::test_garbage_bearers_from_many_threads_do_not_starve_a_legitimate_callermcp/tests/test_mcp_abuse.py::test_one_source_alternating_valid_tokens_cannot_starve_another_sourcemcp/tests/test_mcp_abuse.py::test_a_body_trickled_forever_is_dropped_with_408mcp/tests/test_mcp_abuse.py::test_a_slow_but_complete_body_is_served_and_nothing_is_cut_after_ittests/test_stack_mcp.py::a body over the limit is refusedtests/test_stack_mcp.py::a body trickled to /mcp through the proxy is dropped by the servicetests/test_proxy_identity.py::mcp route: a body over the cap is refusedtests/test_proxy_identity.py::mcp route: a service that does not answer in 30 seconds is an errormcp/tests/test_mcp_abuse.py::test_one_sources_trickles_cannot_stop_another_sources_callsmcp/tests/test_mcp_abuse.py::test_the_service_holds_a_bounded_number_of_connections
C-138 The proxy and the gate keep a token out of their logs¶
- Claim: The proxy's log redacts the query string of
/mcpand theAuthorizationheader; the gate's log redacts the query string of every request. - Asserted in:
docs/mcp.md— "The proxy's log redacts a query string on/mcpand theAuthorizationheader." - Status: enforced
- Enforced by:
tests/test_proxy_identity.py::the proxy's log keeps no query of /mcptests/test_proxy_identity.py::the proxy's log keeps no bearer tokentests/test_proxy_identity.py::gate: its log keeps notests/test_proxy_mcp_mutations.py
C-139 The address a call is limited and recorded by is the one the edge observed¶
- Claim: Against a client, the source address the API rate-limits and audits
for a call through
/mcpis the address of the peer the proxy itself observed. (Against a compromised service or member ofedgeit is not: see C-142.) The proxy deletes anyX-Forwarded-For,Forwarded,X-Real-IPandX-Client-Addressa client sent and setsX-Client-Address; the gate passes it on as it is and never reads a forwarding header; and no client can setX-Client-Addresson any route. Rotating every such header over a flood of failing calls ends in429exactly as the direct route does, and a valid call's read-trail source is the peer. Behind a load balancer the peer is the balancer, which the page says. - Asserted in:
docs/mcp.md— "The proxy deletes anyX-Forwarded-For,Forwarded,X-Real-IPandX-Client-Addressa client sent and setsX-Client-Addressto the address of the peer it sees." - Status: enforced
- Enforced by:
tests/test_proxy_identity.py::mcp route: X-Client-Address is the peer this proxy observedtests/test_proxy_identity.py::mcp route: no forwarding claim a client made reaches the servicetests/test_proxy_identity.py::mcp route: rotating the claimed addresstests/test_proxy_identity.py::X-Client-Address is stripped ontests/test_proxy_identity.py::gate: a forwarded-for claim never becomes the addresstests/test_proxy_mcp_mutations.pytests/test_stack_mcp.py::rotated forwarding headers still end in 429, like the direct routetests/test_stack_mcp.py::a valid call's source in the read trail is the peer, not a claimmcp/tests/test_mcp_details.py::test_the_callers_address_is_the_proxys_header_and_an_ip_address_or_nothing
C-140 The gate is inside the trust boundary, and the documentation says so¶
- Claim:
mcp-gateshares theedgenetwork with the API and holdsPROXY_SHARED_SECRET, so a compromised gate can forge an identity to the API as a compromised proxy can. compose.yaml says so. compose.yaml and the page also say why that is judged acceptable and to leave the profile off if it is not; the wording of each is tested. - Asserted in:
docs/mcp.md— "mcp-gateis inside the trust boundary, exactly as the proxy is." - Asserted in:
compose.yaml— "THIS SERVICE IS INSIDE THE TRUST BOUNDARY." - Status: enforced
- Enforced by:
tests/test_compose_isolation.py::mcp-gate holds the secret and sits on edge, and says sotests/test_compose_isolation.py::the gate without the shared secrettests/test_compose_isolation.py::mcp on the edge networktests/test_compose_isolation.py::docs/mcp.md says the gate is trusted, why, and to leave it offtests/test_compose_isolation.py::the reason it is acceptabletests/test_compose_isolation.py::the way out
C-141 The service writes no request content to its log, and a flood is a bounded log¶
- Claim: Only the service's own messages reach its log, one bounded JSON object a line: the method and path (only if known), the status, and the class of an exception, never a header, a query, a body, a message a library quoted or a traceback. Text in a request cannot start a line, and a burst of records is cut off at a rate.
- Asserted in:
docs/mcp.md— "so text in a request cannot start a line of its own." - Status: enforced
- Enforced by:
mcp/tests/test_mcp_abuse.py::test_a_hostile_request_cannot_write_a_line_or_a_marker_into_the_logmcp/tests/test_mcp_abuse.py::test_a_small_request_cannot_make_a_large_logmcp/tests/test_mcp_abuse.py::test_a_flood_of_bad_requests_is_a_bounded_amount_of_logmcp/tests/test_mcp_abuse.py::test_an_upstream_failure_logs_the_class_and_never_the_exception_textmcp/tests/test_mcp_abuse.py::test_only_our_own_loggers_may_say_anythingmcp/tests/test_mcp_abuse.py::test_a_traceback_is_reduced_to_its_classmcp/tests/test_mcp_abuse.py::test_a_line_is_one_json_object_whatever_the_message_holdsmcp/tests/test_mcp_mutations.py::test_each_guard_is_noticed_when_broken
C-142 Behind the proxy, the address the API records is the caller's claim, and the page says so¶
- Claim: The gate believes
X-Client-Addressfrom whatever reaches it, so a compromised MCP service (or any member ofedge) can choose the address the API records and rate-limits for calls it makes with tokens it holds; a client cannot. The page states this, and a stack test pins it both ways, so a change in either direction is noticed. - Asserted in:
docs/mcp.md— "That holds against a client, not against the parts behind the proxy." - Status: enforced
- Enforced by:
tests/test_stack_mcp.py::from inside mcp the gate believes X-Client-Address on an authenticated calltests/test_stack_mcp.py::a valid call's source in the read trail is the peer, not a claim
C-143 Caddy's own logs never carry the shared secret or a credential header¶
- Claim: Every log the proxy and the gate write, not only the access log, deletes
X-Proxy-Secret,Authorization,Cookieand the token-like headers from any request it records. An aborted response, which logs the request it was proxying, is the case shown. - Asserted in:
docs/mcp.md— "Caddy's own logs, the proxy's and the gate's, as well as the access logs, delete the shared secret and every credential-like header from any request they record" - Status: enforced
- Enforced by:
tests/test_proxy_identity.py::Caddy's own logs never carry the shared secrettests/test_proxy_identity.py::gate: its own logs never carry the shared secrettests/test_proxy_agent_mutations.pytests/test_proxy_mcp_mutations.py
C-144 A pull request cannot skip the tests by being called docs-only¶
- Claim:
tests passedreports on every pull request and accepts a skipped heavy job only when thechangesjob found every changed file to be prose that no heavy suite reads. A push tomainand any doubt run everything. - Asserted in:
docs/developing.md— "still reports on every pull request, and accepts a skipped heavy job only whenchangessaid docs-only" - Status: enforced
- Enforced by:
tests/test_workflows.py::an aggregate that accepts a skipped heavy job without docs_only is noticedtests/test_workflows.py::a heavy job with no docs-only skip condition is noticedtests/test_workflows.py::a workflow-level paths-ignore on pull_request is noticedtests/test_ci_changes.py::a push is never docs-onlytests/test_ci_changes.py::HEAVY_DOCS is exactly the documents a heavy suite reads