audit(db): score Flyway migrations + DB schema against legibility rubric #391
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
Part of Epic #387 — Codebase Legibility Audit. This is AUDIT-4: read-only assessment of the database schema and migration history. Tobias (PM-with-CS) will look at this to understand the data model; Anja (Backend Dev) will look to understand evolution and conventions.
The output is a Markdown report under
docs/audits/audit-db-report.mdfollowing the per-subsystem orient template.Inputs (read first)
See the first comment of #387 for the complete reference bundle.
Scope (in)
backend/src/main/resources/db/migration/(V*__*.sql)scripts/schema.sql(the snapshot for reference)scripts/large-data.sql(note its purpose; flag if confusing)Scope (out)
Rubric checks particularly relevant to this subsystem
C3.3 (table naming consistency, glossary alignment —
usersvsapp_usersvspersonsis exactly the kind of clarity test Tobias will run), C7.5 (per-table comments OR a domain-grouped schema overview), C9.3 (migration naming and non-trivial migration explanations), C10.4 (any rolled-back / commented-out / abandoned migration patterns).Specific things to verify (subsystem-specific)
audit_logtable — is the append-only constraint enforced at DB level (REVOKE UPDATE/DELETE) and documented?Acceptance criteria
docs/audits/audit-db-report.mdexists on a feature branchdocs/SCHEMA.mdoverview documentDefinition of Done
Report committed under
docs/audits/audit-db-report.mdonmain. Top-5 recommendations summarized as a closing comment.Dispatch
AUDIT-4 — Database schema & Flyway migrations
Scope:
backend/src/main/resources/db/migration/V*.sql(V1–V59, 57 files),scripts/schema.sqlsnapshot,scripts/large-data.sql.Reference bundle: issue #387 first comment (Legibility Rubric + Per-Subsystem Orient Template).
§1 Subsystem profile
The database layer is PostgreSQL 16 managed by Flyway with versioned
Vn__description.sqlmigrations. The schema is owned by the Spring Boot backend; JPA/Hibernate maps entities onto these tables. Migrations are append-only by convention, but the layer also includes:V1__initial_schema.sql) that is a verbatimpg_dumpof an early schema (Hibernate-generatedfkXXXXXXXXconstraint names, nogen_random_uuid()defaults),Counts. 57 migration files, V1–V59 with two gaps (V37, V43). V37 was created and later removed (
needsExpertrolled back; see §7). V43 was never assigned (silent gap).§2 Inventory — every table mapped to a canonical domain
documentsdocument_receiversdocuments ⇄ persons(M:N)document_tagsdocuments ⇄ tag(M:N)document_versionsdocument_annotationsdocument_commentsparent_idcomment_mentionsdocument_comments ⇄ userstranscription_blockstranscription_block_versionstranscription_block_mentioned_personsperson_idby design (V56 header)document_training_labelsdocument_thumbnails(cols)documentspersonsperson_name_aliasesperson_relationshipssender_modelstagparent_idfrom V39usersAppUser— name mismatch, see §5user_groupsusers_groupsusers ⇄ user_groupsgroup_permissionsuser_groups ⇄ permission_string(no FK to a permissions table;Set<String>)password_reset_tokensinvite_tokensinvite_token_group_idsgeschichtengeschichten_personspersonsgeschichten_documentsdocumentsnotificationsocr_jobsocr_job_documentsocr_jobs ⇄ documentsocr_training_runsaudit_logTotal: 27 tables (1 outlier-named, 1 ambiguous, 1 dual-domain — see §5).
§3 Rubric scorecard (focus C3.3, C7.5, C9.3, C10.4)
userstable is mapped to entityAppUser(backend/src/main/java/.../model/AppUser.java:27); table name says "users" but the domain glossary explicitly insistsPerson ≠ AppUser. A Tobias-style reader who opensusersfirst will conflate it withpersons.users→app_usersin a dedicated migration (also renameusers_groups→app_users_groups,comment_mentions.user_id→app_user_id). OR add aCOMMENT ON TABLE users IS 'AppUser accounts — NOT historical persons. See persons.'public.tagis singular while every other table is plural (documents,persons,tags-as-junction-prefix indocument_tags).Tag.javahas no@Tableannotation so JPA naming defaults leaked through.Vn__rename_tag_to_tags.sql:ALTER TABLE tag RENAME TO tags;then add@Table(name="tags")toTag.java. Updatedocument_tags.tag_idFK target — implicit, but verify.<plural>_<plural>(document_tags,document_receivers,geschichten_persons,geschichten_documents,users_groups), butgroup_permissionsuses<singular>_<plural>(no second table — it's@ElementCollection),comment_mentionsis<singular>_<plural>(also implicit),invite_token_group_idsends in_idswhich no other junction does (backend/src/main/resources/db/migration/V45__add_invite_tokens.sql:18).transcription_block_mentioned_personsis descriptive but breaks the pattern.invite_token_group_ids→invite_token_groups. Document the@ElementCollectionpattern (group_permissions,comment_mentions) explicitly so readers don't expect a sister entity.COMMENT ON TABLE/COMMENT ON COLUMNstatements anywhere in the migration set. Nodocs/SCHEMA.md. The migration headers are the only documentation, and several creation migrations (V1, V8, V9, V10, V12, V17, V25, V29, V45) ship with no comment at all. Tobias has to read application code to learn whataudit_log.kindvalues exist, whatdocuments.script_typevalues are valid, whattranscription_block_mentioned_persons.display_nameis for.docs/SCHEMA.mdwith one paragraph per domain group + an ER sketch. Cheaper than per-table SQL comments, mutates with the codebase. (See §8.)REVOKE UPDATE, DELETE ON audit_log FROM CURRENT_USER(V46:25). Header explicitly notes the REVOKE is a no-op when the migration runs as the table owner — meaning the application user retains all privileges in dev/local because Flyway runs as the same role. V47 then runs the same REVOKE again "for the actual application role" but still usesCURRENT_USER(V47:8) — same no-op problem. The append-only guarantee is not enforced at DB level today. The migration is honest about it ("enforced at the application layer only") but the dual-REVOKE looks like it should work and doesn't.archive_ownerand the app runs asarchive_app, thenREVOKE … FROM archive_appwill bite, or (b) drop both REVOKE statements with a comment "kept at app layer only" so future readers don't think it's enforced. Document the choice indocs/SCHEMA.md§ audit.V<n>__<verb>_<noun>is consistent (verbs:add,drop,backfill,make,seed). 25 of 57 migrations carry a header comment explaining intent — strong on the harder ones (V31 nullable transcript, V34 FTS triggers, V46 audit, V51 annotation_id backfill, V54 family tree, V56 mentioned persons, V59 BLOG_WRITE seed). 18 migrations are one-linerALTER TABLE … ADD COLUMNand self-explanatory. Caveat: V13, V20, V32, V36, V41, V44, V47 add cross-cutting columns or fix prior migrations without naming the issue/PR — Anja can't trace why.-- #<issue>: <one-line why>. Backfill is optional.ca0cf490, see §7). V43 was never created (no git history). Flyway tolerates gaps but a reader scanninglssees holes and asks "did I lose a file?"V43__skipped.sqlcontaining only a comment-- intentionally skipped during issue #XXX rebase; see commit YYYYand same forV37__skipped.sql, OR rebase the sequence (riskier on prod).scripts/schema.sqlis a stale snapshot — only contains tables created up to ~V2 (8 tables). It's missing 19 of the 27 current tables (notranscription_blocks,audit_log,geschichten,notifications,ocr_*,person_*, etc.).scripts/CLAUDE.mdsays it's "for quick reference only" but Tobias will be misled hard. (b) V37 hole + git note "V37 was already applied; rename V37→V38" implies a production rebase that the file tree no longer reflects. (c)scripts/large-data.sqlhas no header explaining what it is or how to safely reset before reloading.scripts/schema.sqlor regenerate it on every release viapg_dump(CI step). (b) Add aMIGRATIONS.mdnext to the migration directory listing intentional gaps. (c) Add a 5-line header tolarge-data.sql: purpose, prereq, idempotency.documents.meta_date↔Document.documentDate(field) — naming diverges between layer (V1 baseline).comment_mentions.user_idreferencesusers(id)but the entity isAppUser.audit_log.actor_idreferencesusers(id)(V46:9) but is read as "the AppUser who acted". Tobias opens schema, then opens entity, has to translate.@Column(name="...")everywhere and mention the convention indocs/SCHEMA.md.§4 Subsystem health summary
tag)scripts/schema.sql+ V37 ghost)Overall health: 🔴 RED. One Critical (C3.3
usersvsAppUser) and three Majors triggers the threshold (🟢 requires 0 Critical AND ≥80% pass — currently 1 of 8 applicable checks passes cleanly).§5 Domain-mapping gaps (ambiguous tables)
document_training_labels(V29)document(data lives on a Document) orocr(only consumer is the OCR training pipeline)ocr/package alongsideOcrTrainingRun? Today the Document entity carriesSet<String> trainingLabelsso domain ownership splits acrossdocument/andocr/.sender_models(V40)person(1:1 with Person) orocr(it's a per-sender HTR model)SenderModelinmodel/, not in anyocr/subpackage — but it only exists because of OCR.users(table) ↔AppUser(entity)app_users.audit_log↔AuditLog(entity)audit/package but is a single bare table — the package name implies a domain, the table name implies infrastructure. Acceptable, but worth one sentence indocs/SCHEMA.md.invite_token_group_ids@ElementCollection<UUID>style), but the column is actually a UUID FK. Confusing for first-time reader.§6 Cross-cutting candidates
N/A. Tables belong to exactly one domain (or one sub-package within a domain). The single shared table —
audit_log— already lives under the canonicalshared/auditumbrella.§7 Dead / suspicious-code register (C10 findings)
scripts/schema.sql:29-156scripts/CLAUDE.mdcalls it "complete database schema dump for reference".pg_dumpin a CI hook on every release tag, or delete and replace with a one-line note pointing at Flyway.backend/src/main/resources/db/migration/V37__*.sql(deleted)needsExpertflag), was reverted in commitca0cf490, leaving a hole in the sequence. Successor-X seesV36 → V38and assumes a missing file.V37__skipped_needs_expert_rolled_back.sqlcontaining only aSELECT 1;and a comment explaining the rollback. Same for V43 (silent gap with no git trace).backend/src/main/resources/db/migration/V46__add_audit_log.sql:25andV47__add_user_color.sql:8REVOKE UPDATE, DELETE ON audit_log FROM CURRENT_USERcalls are documented (V46:22-24) as no-ops because the role is the table owner. Two migrations that look protective but aren't.COMMENT ON TABLE audit_log IS 'Append-only at application layer only'.backend/src/main/resources/db/migration/V1__initial_schema.sql:202-263fkc99c5qjulwx9gru07yrhicgd2,fkl5xhww7es3b4um01vmly4y18m, etc.). V14 has to reference these unreadable names by hand.ALTER TABLE … RENAME CONSTRAINTmigration to give them speaking names (fk_documents_sender,fk_document_tags_document, …).scripts/large-data.sqlINSERTwith no header. Idempotency relies onON CONFLICT DO NOTHINGbut no FK preconditions — running it on an empty DB then on a populated DB will silently produce different results.backend/src/main/resources/db/migration/V35__backfill_fts_search_vector.sql:6UPDATE documents SET title = title;— looks like a typo. The header explains it's intentional (re-fires the BEFORE UPDATE trigger), but a stranger reading without context will rewrite it as a bug.UPDATE documents SET title = title /* trigger re-fire — see V34 */;for inline clarity.§8 Documentation gaps
The DB layer needs one of these (recommend the second):
Option A — per-table SQL comments. Add
COMMENT ON TABLE/COMMENT ON COLUMNto every table and to every column whose meaning is non-obvious (documents.script_type,documents.metadata_complete,transcription_blocks.source,audit_log.kind,audit_log.payload,notifications.type, etc.). Survivespg_dump. Cost: ~40 statements in a new migration.Option B —
docs/SCHEMA.mdoverview (recommended). Domain-grouped page with:users↔AppUserandtag(singular) ↔ entityTag,Option B is cheaper, evolves alongside
CLAUDE.md, and addresses C3.3 + C7.5 + half of C10.4 with one document.Per-domain README files (C7.1) would also pull weight here — one paragraph in each backend package README about which tables it owns.
§9 Prerequisites for the big-bang refactor
REFACTOR-1 reorganizes packages, not tables. Therefore the schema layer needs no changes for REFACTOR-1 to land safely, provided:
@Table(name=...)value (and adds one toTag.javato lock in the currenttagtable name even after the entity moves totag/Tag.java).TranscriptionBlock,DocumentAnnotation,DocumentCommentstay indocument.transcription,document.annotation,document.commentsub-packages (canonical D-OQ-2/3/4) — no table renames triggered.SenderModelandOcrTrainingRunend up under theocr/package (D-OQ-5) — table names stay.Independent prerequisite (not blocking REFACTOR-1): the
users↔AppUserrename should happen in a separate migration before or after REFACTOR-1, never bundled — it touches FKs in 6+ tables (comment_mentions,audit_log,notifications,users_groups,password_reset_tokens,document_versions.editor_id,transcription_blocks.created_by/updated_by, etc.).§10 Top 5 prioritized recommendations
Create
docs/SCHEMA.md(domain-grouped overview). Single highest-leverage win for C3.3 + C7.5 + C10.4. Resolves theusers/AppUserandtag/tagsgotchas with a glossary section; documents the 27 tables + 6 junctions + intentional V37/V43 gaps in one read.Fix or delete
scripts/schema.sql. Either regenerate it on every release tag viapg_dump(CI hook) or delete it and replace with a one-line README pointing readers atdb/migration/. Today it actively misleads — 19 of 27 tables are missing.Resolve the audit-log append-only contradiction. Either split DB roles so the V46/V47
REVOKEstatements bite, or remove them and add a single inlineCOMMENT ON TABLE audit_log IS 'append-only enforced at app layer only — see AuditLogRepository'. Today the migration claims protection it doesn't deliver.Patch the migration sequence holes. Add stub
V37__skipped_needs_expert_rolled_back.sqlandV43__skipped.sqlcontaining nothing butSELECT 1;and a 1-line--comment explaining the rollback / accidental skip. Cheap; eliminates "did I lose a file?" friction.Rename
tag→tagsin a dedicated migration and add@Table(name = "tags")toTag.java. Smallest surface-area renames in the schema (one table, one FK indocument_tags, one inline reference in V34 trigger function). Removes the only singular-vs-plural outlier and brings the table under the canonical naming convention.Top blocker:
userstable maps to entityAppUserand shares lexical space withpersons— Tobias will conflate them within 30 seconds of opening the schema.