Database schema¶
The schema is owned by the Alembic migrations in
backend/alembic/versions;
app.storage.postgres.models only mirrors it for querying and is
never used to create tables outside tests.
Triggers and column comments exist only in the migrations.
Migrations¶
0001_initialAll tables, partial unique indexes, the search-vector trigger and GIN index, the append-only trigger on
decision_event(UPDATEandDELETE) andCOMMENT ONfor non-obvious columns.0002_no_truncate_decisionsA
BEFORE TRUNCATEtrigger ondecision_eventreusing the same function.0003_erratum_contentrecord.erratumand theix_record_links_correctsindex. It also clears the change token of erratum rows stored earlier, so the next poll re-ingests them with the current parser.
Every migration is hand-written and has a working downgrade().
Tables¶
erDiagram
source_registration ||--o{ record : "source_id"
source_registration ||--o{ decision_event : "source_id"
revision ||--o{ record : "digest, previous_digest"
revision ||--o{ decision_event : "digest"
record }o..o{ decision_event : "identity, no FK"
source_registration {
text id PK "id from catalog.yaml"
text type "filesystem or gitlab"
jsonb config "secrets excluded"
bool enabled
text last_status "never ok partial failed disabled"
}
revision {
text digest PK "sha256-v1:hex"
text content "canonical text"
}
record {
uuid id PK "surrogate, used in URLs"
text kind "published or candidate"
text source_id FK
text project
text path
text ref
text record_key "UUID or src: fallback"
uuid adr_uuid "nullable"
text digest FK
text previous_digest FK
text record_kind "decision or erratum"
jsonb links
jsonb erratum "errata only"
tsvector search
}
decision_event {
uuid id PK
text source_id FK
text project
text record_key
text digest FK
text action "accepted or rejected"
text actor_issuer
text actor_subject
text policy_id
int policy_version
timestamptz created_at
}
intake_run {
uuid id PK
text source_id "no FK"
text kind "poll or push"
text delivery_id
text status "running ok partial failed"
}
source_registrationOne row per registration in
catalog.yaml, synced at startup. Removed registrations are disabled with aconfig_error, never deleted, so their records and decisions keep their foreign keys. Also holds poll status:last_attempt_at,last_success_at,last_statusandlast_error(JSON, no secrets).revisionImmutable, content-addressed snapshots: the canonical text of every content version ever indexed, keyed by digest. Diffs and approval evidence refer to it. Rows are inserted with
ON CONFLICT DO NOTHINGand are never updated.recordOne row per published file or candidate (see Records). Beyond the columns in the diagram it stores
change_token,metadata(the parsed frontmatter),titleandtitle_source,decision_dateanddecision_date_inferred,rendered_html(sanitized),issues(per-document issues),source_url,author, and the timestampsfirst_seen_at,updated_atandlast_seen_at.decision_eventThe append-only approval history (see Approval). Beyond the diagram it stores
adr_uuid(never null: records without a UUID cannot be decided),actor_display,actor_email,commentandprovenance(record id, kind, ref, path and source URL at decision time). It refers to records by logical identity, not by foreign key, because the decision outlives any one row: a published row created later with the same identity and digest inherits the decision.intake_runOne row per poll of a registration and per push request, with
identity(who triggered it, no secrets),started_at,finished_at,countsanderrors.source_idhas no foreign key so that refused pushes naming an unknown registration are still logged.
Constraints and indexes¶
uq_record_published_locationUnique
(source_id, project, path)wherekind = 'published'.uq_record_candidate_identity_refUnique
(source_id, project, record_key, ref)wherekind = 'candidate'.ix_record_identity(source_id, project, record_key), for siblings and decisions.ix_record_searchGIN index on
record.search.ix_record_links_correctsPartial expression index on
links ->> 'corrects', only for rows that have the link (errata).uq_decision_event_identity_digestUnique
(source_id, project, record_key, digest): one formal decision per content revision. It also serves every approval-state lookup.uq_intake_run_deliveryUnique
(source_id, delivery_id)wheredelivery_idis set, for retry deduplication of pushes.
CHECK constraints restrict the enumerated text columns
(kind, title_source, record_kind, action,
last_status, intake kind and status).
Triggers¶
record_search_vectorBEFORE INSERT OR UPDATE OF title, adr_uuid, project, path, digestonrecord. It reads the body fromrevisionand builds the weighted vector: title (A); UUID, project and path, with the path also split at/and.(B); the body with thesimpleconfiguration (C); the body withenglishstemming (D). A trigger is used instead of a generated column because the body lives in another table.decision_event_append_onlyA statement-level
BEFORE UPDATE OR DELETEtrigger that raisesrestrict_violation, so even anUPDATEmatching no rows fails.decision_event_no_truncateThe same function for
TRUNCATE.
The triggers protect against application bugs and mistakes, not against a table owner or superuser, who can drop them. Running the application as a role that does not own the tables is the stronger, still non-cryptographic, mitigation; see Security.