Invariants¶
These rules must hold at all times.
A change that breaks one is a design change, not a bug fix,
and needs a new entry in Design decisions.
Each rule names where it is enforced and which tests pin it;
test paths are relative to backend/tests unless they start with
frontend/.
Sources and intake¶
- Reads never call a source
Browse, search, detail, diff, facets, sources and
/meread only PostgreSQL. Source adapters are opened only throughSourceBinding.open_adapter, which onlyapp.services.pollingcalls.Tests:
routers/test_no_adapter_calls.py(test_every_read_route_never_constructs_an_adapterpatches every adapter constructor to raise),routers/approval/test_candidates.py(test_intake_decisions_and_diff_never_open_an_adapter).- Push never writes a published row
app.services.intakealways ingests asRecordState.CANDIDATEand refuses the literalpublished_ref. Published and candidate rows live under separate partial unique indexes, so an upsert of one kind cannot hit a row of the other.Tests:
test_candidate_never_overwrites_published,test_published_ref_is_refused(routers/approval/test_candidates.py),test_partial_unique_indexes(storage/test_records.py).- Polling writes only published rows and never deletes candidates
Polling ingests as
RecordState.PUBLISHED. Its deletions go throughCatalogStore.delete_published, whose SQL is restricted tokind = 'published'.Tests: none dedicated; see Not enforced by a test.
- An incomplete listing deletes nothing
Nothing is deleted unless the project listing was complete, and within a project nothing unless its document listing was complete. A failed poll (no listing, adapter not opened, write failed) keeps all prior content.
Tests:
test_incomplete_listings_delete_nothing,test_complete_poll_deletes_removed_files_and_projects(services/test_polling.py),test_missing_root_fails_the_poll_and_keeps_records(services/test_filesystem_polling.py),test_persistent_failure_is_partial_and_deletes_nothing,test_failed_group_listing_deletes_nothing(services/test_gitlab_polling.py).- Unchanged content is not fetched again
Adapters compare change tokens; only documents marked changed are fetched.
Tests:
test_unchanged_tokens_are_not_fetched(services/test_polling.py),test_second_poll_does_not_read_unchanged_files,test_polls_ingest_then_skip_unchanged_branches.- Poll and push share one ingest path
Both call
app.services.ingest.prepare_documentandingest_document; nothing else writes record rows.Tests:
services/test_ingest.py.- Writes for one registration serialize
Every write runs inside
lock_registration(a transaction-scoped advisory lock).Tests:
storage/test_lock.py.
Content and identity¶
- The digest depends only on canonical content
app.domain.hashing.content_digesttakes bytes only; no path, ref or registration enters it. The server always computes it: a client-supplieddigestfield is refused as unexpected.Tests:
domain/test_hashing.py,test_client_digest_is_refused(routers/approval/test_candidates.py).- One broken file affects only itself
app.domain.records.parse_recordnever raises for content problems; every problem becomes an issue on that record.Tests:
domain/test_records.py(test_parse_record_never_raises, property-based).- No UUID is ever invented
app.domain.values.record_keyfalls back tosrc:<source_id>/<project>/<path>.Tests:
domain/test_records.py.
Approval and audit¶
- At most one decision per identity and digest
uq_decision_event_identity_digest; the insert usesON CONFLICT DO NOTHINGand a lost race becomesDecisionExistsErrorand HTTP 409already_decided.Tests:
test_second_decision_for_digest_is_refused(storage/test_events.py),test_deciding_twice_is_409(routers/approval/test_decisions.py).- Decision history is append-only
Statement-level triggers refuse
UPDATE,DELETEandTRUNCATEondecision_event. No code path updates or deletes an event, and ingest never touches the table.Tests:
test_decision_events_are_append_only(storage/test_events.py),test_history_survives_a_new_app_and_is_append_only(routers/approval/test_decisions.py).- SQL and Python agree on approval state
approval_joinmirrorsapp.domain.approval.evaluate.Tests:
storage/test_approval_parity.py(every row, every policy variant, paged and unpaged).- A missing policy never means “not required”
The loader yields
policy_missing; every decision is refused.Tests:
test_missing_policy_refuses_decisions(routers/approval/test_decisions.py),test_catalog_config.py.- Unapprovable content cannot be decided
No valid UUID or invalid UTF-8 means 409
not_approvable.Tests:
test_unapprovable_records_cannot_be_decided(routers/approval/test_decisions.py).- Acceptance follows the digest, never the row
Identical content on the published ref is accepted with its history; edited content is
changed_since_acceptance.Tests:
routers/approval/test_lifecycle.py(test_identical_content_is_accepted_when_published,test_edited_content_needs_new_approval_and_diffs).- An erratum never changes its target
Errata are decided on their own identity and digest.
Tests:
test_published_erratum_is_reported_then_verified_then_rejected(routers/approval/test_errata.py).- Legacy sign-off is never approval
signed-off-byis not an input toevaluate.Tests:
test_signoff_never_implies_approval(domain/test_records.py).
Access¶
- Every API route needs a token, except health and docs
/api/v1routes depend onrequire_permissionor the push-token check;/healthz,/openapi.jsonand/api/docsare public and expose no record data.Tests:
routers/test_auth.py(test_every_api_route_requires_authenticationsweeps the OpenAPI schema,test_healthz_and_docs_stay_public).- Read never implies review
Eligibility comes from the policy only.
Tests:
test_reader_is_not_eligible(routers/approval/test_decisions.py).- Push tokens only write candidates
They are refused by read routes, and JWTs are refused by intake.
Tests:
test_push_token_cannot_read,test_dev_jwt_is_refused_for_intake(routers/approval/test_candidates.py).- The dev bypass needs both gates, and never reaches production builds
See Security.
Tests:
routers/test_auth.py(test_hs256_is_rejected_without_the_dev_secret,test_rs256_token_is_accepted_and_never_routed_to_dev,test_dev_router_is_absent_when_disabled,test_dev_router_needs_the_secret),routers/test_dev_roles.py,frontend/scripts/check-no-dev-auth.sh(run by the frontendbuildscript).- Rendered Markdown is sanitized before storage
app.services.ingest.render_markdownpasses every body through nh3; the frontend renders only that field withv-html.Tests:
services/test_ingest.py(test_render_markdown_sanitizes_html,test_prepare_document_renders_sanitized_erratum_sections).- Secrets are never logged or stored
Tokens are referenced by env var name and held as secret strings.
Tests:
test_secrets_never_reach_storage_messages_or_logs(test_catalog_config.py),test_token_never_appears_in_logs_or_problems(services/test_gitlab_polling.py),test_intake_runs_are_recorded_without_the_token(routers/approval/test_candidates.py).
Not enforced by a test¶
- The domain package is pure
app.domainmust not import FastAPI, SQLAlchemy, httpx or do file or network I/O (its only third-party import is PyYAML). No lint rule or test checks this today; it is kept by review.- Polling never deletes candidates
Guaranteed by the
kindfilter inapp.storage.postgres.records.delete_published, but no test polls a registration that also holds candidates and checks they survive a reconciling poll.