Tasks: Database Schema Brush-Up & Auto-Generated Schema Diagram
Input: Design documents from /specs/016-db-schema-brushup/
Prerequisites: plan.md, spec.md, research.md, data-model.md, quickstart.md (all present)
Tests: Included — mandatory per constitution §III ("Mandatory test tasks in every tasks.md"). Test locations used: src/tests/unit/, src/tests/integration/ (@pytest.mark.integration), backend/tests/, scripts/tests/.
Organization: Tasks are grouped by user story (US1/US2/US3/US4, matching spec.md's priorities P1/P2/P3/P4) so each story can be implemented, tested, and delivered independently. US4 (Phase 6) was added in a later planning pass, after US1-US3 (Phases 3-5) and the original Polish pass (Final Phase) were already implemented — see spec.md's 2026-07-21 Clarifications session.
Format: [ID] [P?] [Story] Description
- [P]: Can run in parallel (different files, no dependencies on incomplete tasks)
- [Story]: Which user story this task belongs to
- File paths are exact and relative to the repository root
Phase 1: Setup
Purpose: Confirm a clean starting baseline before touching schema/model code
- [X] T001 Run
make migrateat the repo root to confirm the local Postgres is at Alembichead(revision23_article_recommendation_weekly_report) before starting — establishes the known-good pre-migration baseline referenced throughout quickstart.md
Phase 2: Foundational (Blocking Prerequisites)
Purpose: The one artifact every other phase in this feature reads or references
⚠️ CRITICAL: No user story work can begin until this phase is complete
- [X] T002 Create
models/db_schema.pywithDbSchema(str, Enum)— membersCORE = "core",COLLECTION = "collection",INTELLIGENCE = "intelligence",AI_INFRA = "ai_infra",USER_PREFS = "user_prefs"— per data-model.md §1
Checkpoint: DbSchema enum exists and is importable — US1 (model updates) and US2 (diagram generator, which statically parses this file) can now both proceed.
Phase 3: User Story 1 - Tables Organized by DDD Bounded Context (Priority: P1) 🎯 MVP
Goal: Move the 24 modeled public tables into core/collection/intelligence/ai_infra/user_prefs, matching the codebase's existing DDD bounded contexts, with zero behavior change.
Independent Test: Run make migrate, then query information_schema.tables and confirm the 3/5/10/3/3 distribution from data-model.md §2; run make test + make test-integration and confirm they still pass unmodified; run make migrate-down and confirm every table returns to public.
Tests for User Story 1
Write these first — T003 fails until the model updates below land; T004 fails until the migration (T005) lands.
- [X] T003 [P] [US1] Add schema-assertion tests to
src/tests/unit/infrastructure/persistence/test_orm_models.py— one assertion per moved model,<Model>.__table__.schema == DbSchema.<X>.value, covering all 24 tables per the data-model.md §2 mapping (no DB required — matches this file's existing style, e.g.test_topic_model_columns) - [X] T004 [P] [US1] Add
src/tests/integration/test_db_schema_migration.py(@pytest.mark.integration) — afteralembic upgrade headhas run (already guaranteed by the CI job order per.github/workflows/ci.yml), queryinformation_schema.tablesand assert: the 5 new schemas exist, contain exactly the tables listed in data-model.md §2, andpubliccontains onlydata_migrations(plusalembic_version)
Implementation for User Story 1
- [X] T005 [US1] Create
alembic/versions/24_reorganize_public_schema_into_ddd_schemas.py—upgrade(): 5×CREATE SCHEMA IF NOT EXISTS, thenALTER TABLE public.<table> SET SCHEMA <schema>for all 24 tables in data-model.md §2's mapping; plus (added during implementation, research.md §8)ALTER DATABASE ... SET search_path TO core, collection, intelligence, ai_infra, user_prefs, publicso pre-existing unqualified raw SQL keeps resolving;downgrade(): reverse every move back topublic,RESET search_path, thenDROP SCHEMA IF EXISTSthe 5 schemas — verified reversible (downgrade -1 → upgrade head) on both a fresh throwaway DB and the local dev DB - [X] T006 [P] [US1] Update
models/article.py—__table_args__→{'schema': DbSchema.CORE.value}; requalifyForeignKey('topics.id')→ForeignKey('core.topics.id') - [X] T007 [P] [US1] Update
models/article_translation.py—__table_args__→DbSchema.CORE; requalifyForeignKey('articles.id', ...)→'core.articles.id' - [X] T008 [P] [US1] Update
models/topic.py—__table_args__→{'schema': DbSchema.CORE.value}(no FKs to requalify) - [X] T009 [P] [US1] Update
models/scraper_setting.py—__table_args__→{'schema': DbSchema.COLLECTION.value}(no FKs to requalify) - [X] T010 [P] [US1] Update
models/scraper_keyword.py—__table_args__→DbSchema.COLLECTION; requalifyForeignKey('topics.id', ...)→'core.topics.id' - [X] T011 [P] [US1] Update
models/failed_task.py—__table_args__→DbSchema.COLLECTION; requalifyForeignKey('articles.id')→'core.articles.id'andForeignKey('analyses.id', ...)→'intelligence.analyses.id' - [X] T012 [P] [US1] Update
models/article_metrics.py—__table_args__→DbSchema.COLLECTION; requalifyForeignKey('articles.id', ...)→'core.articles.id' - [X] T013 [P] [US1] Update
models/article_metric_value.py—__table_args__→DbSchema.COLLECTION; requalifyForeignKey('articles.id', ...)→'core.articles.id' - [X] T014 [P] [US1] Update
models/analysis.py—__table_args__→{'schema': DbSchema.INTELLIGENCE.value}; requalifyForeignKey('articles.id')→'core.articles.id' - [X] T015 [P] [US1] Update
models/analyses_translation.py—__table_args__→DbSchema.INTELLIGENCE; requalifyForeignKey('analyses.id', ...)→'intelligence.analyses.id' - [X] T016 [P] [US1] Update
models/tag.py—Tag.__table_args__and the module-levelarticle_tagsassociationTable(...)both →DbSchema.INTELLIGENCE; requalifyForeignKey('tag_group_definitions.id', ...),ForeignKey('articles.id')→'core.articles.id',ForeignKey('tags.id')→'intelligence.tags.id' - [X] T017 [P] [US1] Update
models/tag_group.py—__table_args__→DbSchema.INTELLIGENCE; requalifyForeignKey('topics.id')→'core.topics.id' - [X] T018 [P] [US1] Update
models/tag_group_translation.py—__table_args__→DbSchema.INTELLIGENCE; requalifyForeignKey('tag_group_definitions.id')→'intelligence.tag_group_definitions.id' - [X] T019 [P] [US1] Update
models/tag_translation.py—__table_args__→DbSchema.INTELLIGENCE; requalifyForeignKey('tags.id')→'intelligence.tags.id' - [X] T020 [P] [US1] Update
models/tag_normalization_suggestion.py—__table_args__→DbSchema.INTELLIGENCE; requalify bothForeignKey('tags.id', ...)→'intelligence.tags.id'andForeignKey('articles.id', ...)→'core.articles.id' - [X] T021 [P] [US1] Update
models/weekly_report.py—__table_args__→DbSchema.INTELLIGENCE; requalifyForeignKey('topics.id', ...)→'core.topics.id' - [X] T022 [P] [US1] Update
models/weekly_report_translation.py—__table_args__→DbSchema.INTELLIGENCE; requalifyForeignKey('weekly_reports.id', ...)→'intelligence.weekly_reports.id' - [X] T023 [P] [US1] Update
models/llm_provider.py—__table_args__→{'schema': DbSchema.AI_INFRA.value}(no FKs to requalify) - [X] T024 [P] [US1] Update
models/metric_definition.py—__table_args__→{'schema': DbSchema.AI_INFRA.value}(no FKs to requalify) - [X] T025 [P] [US1] Update
models/metric_provider.py—__table_args__→DbSchema.AI_INFRA; requalifyForeignKey('metric_definitions.id', ...)→'ai_infra.metric_definitions.id' - [X] T026 [P] [US1] Update
models/user_subscription.py— all 3 classes' (UserTopicSubscription,UserNotificationSettings,UserArticleFavorite)__table_args__→DbSchema.USER_PREFS; requalifyForeignKey('topics.id', ...)→'core.topics.id'andForeignKey('articles.id', ...)→'core.articles.id'; leave theForeignKey('auth.users.id', ...)FKs unchanged - [X] T027 [P] [US1] Update
models/article_chunk.py— cross-boundary fix only: requalifyForeignKey("articles.id", ...)→"core.articles.id"; do not change its own__table_args__(staysvectors, out of scope) - [X] T028 [US1] Delete
models/arxiv_keyword.py; remove the now-obsolete# NOTE: models.arxiv_keyword is intentionally excluded...comment block frommodels/__init__.py - [X] T029 [P] [US1] Surveyed raw SQL in
src/entrypoints/cli/refresh_metrics.py— superseded, see research.md §8: initially schema-qualified (FROM core.articles), then reverted to unqualified (FROM articles) onceschema_translate_mapwas found to not apply to rawtext()SQL — resolves correctly via the new database-levelsearch_path(T005) instead - [X] T030 [P] [US1] Surveyed raw SQL in
src/infrastructure/persistence/intelligence/tag_repo_impl.py— same supersession as T029; left unqualified, relies onsearch_path - [X] T031 [P] [US1] Surveyed raw SQL in
src/infrastructure/persistence/intelligence/tag_group_definition_repo_impl.py— same supersession as T029 - [X] T032 [P] [US1] Surveyed raw SQL in
backend/routers/tags.py— same supersession as T029 - [X] T033 [P] [US1] Surveyed raw SQL in
backend/services/scraper_settings_service.py— same supersession as T029 - [X] T034 [P] [US1] Surveyed raw SQL in
backend/services/tag_service.py— same supersession as T029 - [X] T035 [P] [US1] Surveyed raw SQL in
backend/services/article_service.py— same supersession as T029 - [X] T036 [US1]
src/tests/integration/conftest.py— expanded beyond the original task description (research.md §8): addedschema_translate_map={schema: TEST_SCHEMA for schema in DDD_SCHEMAS}on the test engine (not just aFIXED_SCHEMASset addition — a plain exclusion broke isolation for the ~35 tests exercising these tables, since several moved tables carry migration-seeded rows even on a fresh DB);FIXED_SCHEMASitself stays{"auth", "vectors"}, its original pre-feature value - [X] T037 [US1]
backend/tests/integration/conftest.py— sameschema_translate_mapapproach as T036 - [X] (unplanned, found via full-suite verification) Fixed 2 test files with raw SQL against moved tables to match the unqualified convention:
src/tests/integration/test_metrics_refresh_pipeline.py(also scoped_STALE_ARTICLES_QUERYassertions byarticle_idinstead of relying onLIMIT 200over what is, outside full isolation, a much larger real table; changed_make_article'scommit()toflush()to stay within the session's rollback boundary) andsrc/tests/integration/intelligence/test_tag_constraints_integration.py
Checkpoint: ✅ Verified against a disposable freshly-migrated throwaway Postgres container AND the local dev DB: src/tests/unit/ 701 passed, backend/tests/ (unit) 332 passed, src/tests/integration/ 80 passed, backend/tests/integration/ 224 passed (304 total integration, 1033 total unit — 1337 tests green). make migrate-down → make migrate round-trip verified reversible. Quickstart.md §1–2 succeed end to end. User Story 1 is independently shippable here.
Phase 4: User Story 2 - Auto-Generated Database Diagram in the Docs Site (Priority: P2)
Goal: A new VitePress page showing every table/column/FK/schema, generated by static AST analysis of models/, wired into the existing docs pipeline.
Independent Test: Run scripts/generate_db_schema.py locally, confirm it emits an SVG under site/public/guide/architecture/; build the site (npm run build in site/) and confirm the new page renders and is reachable from nav.
Tests for User Story 2
- [X] T038 [P] [US2] Added
scripts/tests/test_generate_db_schema.py— 9 tests covering dict-form/tuple-form__table_args__, literal-string schema (auth-style),DbSchema.<MEMBER>.valueresolution, cross-schema FK detection, associationTable()parsing, theColumn('db_name', Type)name-override edge case (found while dogfooding against realmodels/article.py), and fail-loud on unparseable__table_args__— all 9 pass
Implementation for User Story 2
- [X] T039 [US2] Implemented
scripts/generate_db_schema.py— staticast-parsesmodels/*.py, emitssite/public/guide/architecture/db-schema.dot— design change from the plan: outputs only.dot(no server-side.svgrender step); discoveredsite/guide/architecture/viewer.htmlalready renders.dotclient-side via@viz-js/viz(loaded from CDN) rather than pre-rendering SVG server-side, so this reuses that exact established pattern instead of introducing a second one — nodotCLI/Graphviz dependency needed for this script at all (confirmed: runs with plain stdlib-onlypython, nouv syncrequired). Verified against realmodels/: discovers all 26 tables (24 moved +auth.users+vectors.article_chunks) across 7 schema clusters, 27 FK edges, cross-schema edges correctly flagged (e.g.vectors.article_chunks → core.articles) - [X] T040 [US2] Added "Generate DB schema diagram" step to
.github/workflows/speckit-github-pages.ymlafter "Generate backend UML (pyreverse)" - [X] T041 [US2] Created
site/guide/architecture/db-schema.md+site/.vitepress/theme/DbSchemaViewer.vue(registered globally insite/.vitepress/theme/index.js, matching the existingUmlViewer/DepGraphViewerpattern) — fetches./db-schema.dotand renders client-side via the same@viz-js/vizCDN buildviewer.htmlalready uses - [X] T042 [US2] Added "DB Schema" nav/sidebar entry — in
site/scripts/generate-config.mjs(the actual source of truth:site/.vitepress/config.jsis auto-generated bynpm run generate, so hand-editing onlyconfig.jswould be overwritten) and reflected in the currentconfig.jsvia running that generator
Checkpoint: ✅ npm run generate && npm run build (production build, the stricter one per constitution VII) succeeds with zero errors; dist/guide/architecture/db-schema.html and db-schema.dot both present in build output. Independently shippable — does not require User Story 3.
Phase 5: User Story 3 - Centralized Backend Configuration (Priority: P3)
Goal: backend/config.py becomes the single place backend/ reads environment variables, closing the pre-existing constitution IX compliance gap.
Independent Test: grep -rn "os\.environ\|os\.getenv" backend/ --include="*.py" | grep -v backend/tests | grep -v backend/config.py returns nothing; backend/tests/ passes unmodified.
Tests for User Story 3
- [X] T043 [P] [US3] Added
backend/tests/test_config.py— 8 tests covering every constant
Implementation for User Story 3
- [X] T044 [US3] Created
backend/config.pyper data-model.md §4 — all 15 vars, matchingsrc/config/settings.py's pure-reads style - [X] T045 [P] [US3] Migrated
backend/database.py - [X] T046 [P] [US3] Migrated
backend/main.py(also dropped the now-unusedimport os) - [X] T047 [P] [US3] Migrated
backend/auth/guards.py(3 call sites) - [X] T048 [P] [US3] Migrated
backend/middleware/logging.py - [X] T049 [P] [US3] Migrated
backend/services/article_service.py - [X] T050 [P] [US3] Migrated
backend/services/chat_service.py - [X] T051 [P] [US3] Migrated
backend/services/tag_service.py - [X] T052 [P] [US3] Migrated
backend/routers/chat.py - [X] T053 [P] [US3] Migrated
backend/routers/articles.py - [X] T054 [US3] Migrated
backend/routers/grafana.py(7 vars × 6 call sites) - [X] T055 [US3] Cross-checked all 15 vars against
.env.example— all already present, no additions needed - [X] (unplanned, found via full-suite verification) Frozen-constant test regression, fixed:
backend/config.pyreads env vars once at import time (by design, matchingsrc/config/settings.pyand constitution IX) — several existing tests relied on the old per-requestos.environ.get()behavior viapatch.dict(os.environ, ...)around individual requests, expecting live reads. Fixed by reloading the affected module chain (backend.config→ the router/module that imports from it →backend.main, sinceapp.include_router()bakes in handler closures that only refresh on abackend.mainreload) inside a test-local context manager, mirroring the reload patternbackend/tests/test_cors.pyhad already established pre-this-feature forFRONTEND_ORIGIN. Fixed 3 files:backend/tests/test_cors.py(extended its existing reload fixture to also reloadbackend.configfirst),backend/tests/test_grafana.py(new_grafana_env()helper, all 21patch.dictcall sites),backend/tests/integration/test_grafana.py(new_grafana_env()helper that also re-applies theget_dbdependency override to the rebuilt app, all 16 call sites)
Checkpoint: ✅ Verified against both a disposable freshly-migrated throwaway Postgres container and the local dev DB: full suite (src/tests/unit/ + src/tests/integration/ + backend/tests/ unit + backend/tests/integration/) = 1345 passed, 0 failed on both databases. grep -rn "os\.environ\|os\.getenv" backend/ --include="*.py" | grep -v backend/tests | grep -v backend/config.py returns nothing. Independently shippable — touches none of the files from US1/US2.
Phase 6: User Story 4 - Read-Only API Docs & Exception Catalog in the Docs Site (Priority: P4)
Goal: Two new auto-generated docs-site pages — a read-only Swagger/API-docs embed (with "Try it out" disabled at the backend source, toggleable via SWAGGER_TRY_IT_OUT_ENABLED) and an exception catalog (AST-derived index of every exception type raised in backend/, src/, models/, shared/ and where each is raised from).
Independent Test: Deploy/run the backend with SWAGGER_TRY_IT_OUT_ENABLED unset; confirm /docs has no "Try it out" controls directly and via the docs-site embed. Run scripts/generate_exceptions.py; confirm exceptions-data.json lists every currently-raised exception type with accurate raise sites; build the docs site and confirm both new pages render and are reachable from nav.
Tests for User Story 4
- [X] T059 [P] [US4] Add
SWAGGER_TRY_IT_OUT_ENABLEDassertions tobackend/tests/test_config.py(extends the file T043 created) — defaultFalsewhen unset,Truefor"true"/"True"/etc. - [X] T060 [P] [US4] Add a test to
backend/tests/(newtest_main.py) assertingapp.swagger_ui_parametersis{"supportedSubmitMethods": []}by default andNonewhenSWAGGER_TRY_IT_OUT_ENABLED=true— reloadbackend.config→backend.maininside the test, matching the reload patternbackend/tests/test_cors.pyalready established (see research.md §8's frozen-constant note from US3) - [X] T061 [P] [US4] Add
scripts/tests/test_generate_exceptions.py— matchingscripts/tests/test_generate_db_schema.py's pattern (synthetic.pyfixtures undertmp_path), covering: custom exception class detection (with/without a custom base),raise X(...),raise X(...) from e, bareraise/raise eresolved via the nearestexcept ExcType as e:, the unresolvable-re-raise exclusion (multi-typeexcepttuple and no-enclosing-handler cases),HTTPException(status_code=...)literal extraction (present/absent),builtinvsframeworkvscustomclassification, and fail-loud (raised exception, non-zero exit) on an unparseable file — 16 tests, all pass; found and fixed two real bugs while dogfooding against the actual codebase (see T065's note)
Implementation for User Story 4 — Swagger / API Docs
- [X] T062 [P] [US4] Add
SWAGGER_TRY_IT_OUT_ENABLED: bool = os.environ.get("SWAGGER_TRY_IT_OUT_ENABLED", "false").lower() == "true"tobackend/config.pyper data-model.md §4's Story 4 addition - [X] T063 [US4] Update
backend/main.py'sFastAPI(...)constructor:swagger_ui_parameters=None if SWAGGER_TRY_IT_OUT_ENABLED else {"supportedSubmitMethods": []}(depends on T062) - [X] T064 [P] [US4] Add
SWAGGER_TRY_IT_OUT_ENABLED=to.env.examplenearFRONTEND_ORIGIN/ADMIN_PASSWORD, with a comment noting the default-disabled/safe-by-default behavior and thatBACKEND_URL(already present) is reused for the docs-site embed target
Implementation for User Story 4 — Exception Catalog
- [X] T065 [P] [US4] Implement
scripts/generate_exceptions.pyper data-model.md §5 / research.md §11 — AST-parsesbackend/,src/,models/,shared/(excluding any path containing atests/segment), outputssite/public/guide/architecture/exceptions-data.json. Two bugs found and fixed while dogfooding against real source: (1) the original per-node recursive walker broke when passed a single statement (e.g. anexcepthandler's body items) rather than a container, silently missing raises insidetry/except/else/finallyblocks — rewritten as a consistent_walk_body(stmts)/_walk_stmt(stmt)pair that also generically recurses intoIf/For/While/With/Matchbodies. (2)raise last_403_exc(a lowercase variable holding a stored exception instance under a name other than the enclosingexcept ... as e:binding, found insrc/infrastructure/shared/http/http_client.py) was being misresolved as iflast_403_excwere itself an exception class — fixed by only treating a bareraise <Name>as a class reference when the name is UpperCamelCase (PEP 8 / this project's own "Handler classes MUST be UpperCamelCase" convention), otherwise excluding it as unresolvable, consistent with the "exclude rather than misattribute" principle. Verified against the real codebase: 11 exception types, ~93 raise sites,last_403_exccorrectly absent. - [X] T066 [US4] Add
uml-exceptionsMakefile target (docker compose run --rm -v "$(CURDIR)/site:/app/site" job_service sh -c "python /app/scripts/generate_exceptions.py"), fold into the existingumlcombo target alongsideuml-backend/uml-db-schema/uml-frontend(depends on T065)
Implementation for User Story 4 — Docs Site Wiring (shared by both sub-features)
- [X] T067 [P] [US4] Update
site/scripts/generate-config.mjs— addbackendUrl: process.env.BACKEND_URL || ''tothemeConfig; add "API Docs" and "Exceptions" entries to the Architecture sidebar section - [X] T068 [US4] Regenerate
site/.vitepress/config.jsby runningnpm run generateinsite/(depends on T067) - [X] T069 [P] [US4] Create
site/.vitepress/theme/SwaggerViewer.vue— iframe-embeds${backendUrl}/docs, empty-state message whenbackendUrlis unset (matchingDepGraphViewer.vue'sstorybook-emptypattern), fullscreen toggle matching sibling viewers - [X] T070 [P] [US4] Create
site/.vitepress/theme/ExceptionViewer.vue— flat searchable card grid overexceptions-data.jsonper data-model.md §5's shape; card = exception type with category-color badge + raise-site count; click opens the existing dialog pattern (GitHub-linkeddefined_at, docstring, full raise-site list with GitHub links + snippets + status-code badges) - [X] T071 [US4] Register
SwaggerViewerandExceptionViewerinsite/.vitepress/theme/index.js(depends on T069, T070) - [X] T072 [P] [US4] Create
site/guide/architecture/api-docs.mdembedding<SwaggerViewer /> - [X] T073 [P] [US4] Create
site/guide/architecture/exceptions.mdembedding<ExceptionViewer />, with a doc section modeled ondb-schema.mdexplaining generation mechanics and the scope caveat (only exceptions the project's own code explicitlyraises are covered) - [X] T074 [US4] Update
.github/workflows/speckit-github-pages.yml— pass theBACKEND_URLrepo variable as an env var to the "Build VitePress" step, alongside the existingSTORYBOOK_URL(not "Generate VitePress config" —config.js'sprocess.env.BACKEND_URLread is only evaluated when VitePress loads the config, i.e. at build/dev/preview time, see research.md's corrected §10 note); add a "Generate exception catalog" step runningscripts/generate_exceptions.py, after the "Generate DB schema diagram" step (depends on T065). Found during implementation:specs/016-db-schema-brushup/tasks.md(this file) originally documented this task using literal${{ vars.BACKEND_URL }}GitHub Actions syntax inside single-backtick inline code — VitePress does not wrap inline (single-backtick) code spans inv-prethe way it does fenced code blocks, so the literal{{ vars.BACKEND_URL }}was compiled as a real Vue mustache interpolation and brokenpm run buildwithCannot read properties of undefined (reading 'BACKEND_URL'). Reworded to avoid the raw${{ }}sequence — same class of issue as constitution VII's existing "no bare<...>outside code fences" rule, just the{{ }}variant.
Verification for User Story 4
- [X] T075 [US4] Walk through quickstart.md §6–7 end to end:
backend/tests/test_main.py's two tests confirm the try-it-out toggle flipsapp.swagger_ui_parametersin both directions (the same attribute FastAPI's/docsrenders from, so this is the app-level equivalent of the manual curl/browser check);exceptions-data.jsonregenerated against the real codebase and spot-checked —RateLimitExhausted,ArxivRateLimitedError,OpenAlexRateLimitedError,SemanticScholarRateLimitedError,HTTPException,ValueError,RuntimeErrorall present with accurate raise-site counts;npm run build(production) succeeds withapi-docs.htmlandexceptions.htmlboth present insite/.vitepress/dist/guide/architecture/. Confirms SC-007/SC-008 from spec.md. Full manual browser walkthrough (live/docs+ docs-site preview) left for the user to do viamake site-preview, per their request.
Checkpoint: Backend's live /docs has no execute capability by default (and gains it only via explicit opt-in); docs site has two new pages reachable from nav; exception catalog regenerates automatically from source changes with no manual step. Independently shippable — touches no files from US1/US2/US3 except adding one constant to the already-existing backend/config.py (T062) and one method call to the already-existing backend/main.py (T063).
Final Phase: Polish & Cross-Cutting Concerns
Purpose: Whole-feature verification across all three stories
- [X] T056 [P] Ran
make test-src test-backend test-src-integration test-backend-integration(the actual Makefile target names —test/test-integrationin the original task description were approximate) — 1345 passed, 0 failed, confirmed against both the local dev DB and a disposable freshly-migrated throwaway Postgres container - [X] T057 Walked through quickstart.md §1–§5 end to end during implementation (migration round-trip verified,
arxiv_keyword.pydeletion verified, diagram generator +npm run buildverified,backend/config.pyzero-os.environ-outside-config verified) - [X] T058 [P] Added a pointer in
CLAUDE.md's "### ORM Models" section (also fixed a now-stale line:ArxivKeywordwas documented as "legacy" but is now fully deleted, not just superseded)
Dependencies & Execution Order
Phase Dependencies
- Setup (Phase 1): No dependencies
- Foundational (Phase 2): Depends on Setup — BLOCKS all user stories (both US1's model edits and US2's diagram-generator design reference the
DbSchemaenum) - User Story 1 (Phase 3): Depends on Foundational only
- User Story 2 (Phase 4): Depends on Foundational only, technically — but is only meaningful once US1 has landed (spec.md's own stated priority rationale: the diagram's value is showing the new grouping). No hard file-level dependency exists between the two, so they can proceed in parallel if needed.
- User Story 3 (Phase 5): Depends on Foundational only — fully independent of US1 and US2 (disjoint file sets:
backend/config.py+ call sites vs.models//alembic//scripts//site/) - User Story 4 (Phase 6): Depends on Foundational only — fully independent of US1, US2, US3 (disjoint file sets:
backend/config.py's single new constant +backend/main.py's single constructor kwarg vs.models//alembic/; newscripts/generate_exceptions.pyvs.scripts/generate_db_schema.py; newsite/.vitepress/theme/{SwaggerViewer,ExceptionViewer}.vue+site/guide/architecture/{api-docs,exceptions}.mdvs.DbSchemaViewer.vue/db-schema.md). Can be staffed and shipped independently of, and in any order relative to, the other three stories. - Polish (Final Phase): Depends on User Stories 1-3 being complete (already the case) — Story 4 was added later and is verified independently via its own Phase 6 checkpoint rather than folded into this pre-existing Polish pass
Within User Story 1
- T003/T004 (tests) should be written first and observed failing, then T005 (migration) and T006–T027 (model updates) make them pass
- T005 (migration) has no code dependency on T006–T027 (models) or vice versa — they can be authored in parallel, but T004's integration test needs T005 applied to a real database to pass
- T028 (delete arxiv_keyword) is independent of everything else in this phase
- T029–T035 (raw SQL fixes) are independent of each other and of the model-file tasks — same schema-naming facts, different files
- T036/T037 (conftest fixes) should land after the model tasks are understood but have no code dependency on them; they must land before re-running
make test-integrationin the Checkpoint
Parallel Opportunities
- All of T006–T027 (22 model files) are mutually independent — full-team parallel batch
- All of T029–T035 (7 raw-SQL files) are mutually independent
- T036 and T037 (the two
conftest.pyfiles) are independent of each other - T045–T053 (9 of the 10 backend call-site migrations) are mutually independent; only T054 (
grafana.py, denser) is separated out as its own task - US2 and US3 can be staffed entirely in parallel with each other and (after Foundational) with US1
- Within US4: T059–T061 (tests) are mutually independent; T062/T064/T065 are mutually independent (different files); T069/T070 (the two new Vue components) are mutually independent; T072/T073 (the two new markdown pages) are mutually independent. T063 depends on T062; T066 depends on T065; T068 depends on T067; T071 depends on T069+T070; T074 depends on T065. US4 as a whole can be staffed entirely in parallel with US1/US2/US3 (after Foundational) — zero file overlap.
Parallel Example: User Story 1
# After T002 (DbSchema enum) and T005 (migration) exist, launch the model-file batch together:
Task: "Update models/article.py — schema + FK requalification"
Task: "Update models/topic.py — schema"
Task: "Update models/scraper_setting.py — schema"
Task: "Update models/analysis.py — schema + FK requalification"
# ... (all of T006-T027)
# Independently, launch the raw-SQL batch together:
Task: "Fix raw SQL in src/entrypoints/cli/refresh_metrics.py"
Task: "Fix raw SQL in backend/services/scraper_settings_service.py"
Task: "Fix raw SQL in backend/services/article_service.py"
# ... (all of T029-T035)Parallel Example: User Story 4
# Tests batch (T059-T061) — mutually independent, different files:
Task: "backend/tests/test_config.py — SWAGGER_TRY_IT_OUT_ENABLED assertions"
Task: "backend/tests/test_main.py — swagger_ui_parameters toggle test"
Task: "scripts/tests/test_generate_exceptions.py — full generator test suite"
# Independently, the two sub-feature implementations can proceed in parallel:
Task: "backend/config.py + backend/main.py — SWAGGER_TRY_IT_OUT_ENABLED wiring (T062-T064)"
Task: "scripts/generate_exceptions.py + Makefile uml-exceptions target (T065-T066)"
# Once both are far enough along, the shared docs-site wiring batch (T069, T070, T072, T073) is
# mutually independent — two new Vue components, two new markdown pages, different files each.Implementation Strategy
MVP First (User Story 1 Only)
- Phase 1 (Setup) → Phase 2 (Foundational) → Phase 3 (US1)
- STOP and VALIDATE: quickstart.md §1–2,
make test+make test-integrationgreen - This alone satisfies issue #91's core ask — schemas are reorganized, app behavior unchanged
Incremental Delivery
- Setup + Foundational → foundation ready
- User Story 1 → validate independently → the schemas exist, everything still works (MVP)
- User Story 2 → validate independently → the diagram is live and accurate
- User Story 3 → validate independently →
backend/config.pycloses the constitution IX gap - Polish → whole-feature regression pass + doc pointer
- User Story 4 (added later, independent of 1-5) → validate independently → the live
/docsexecute capability is off by default everywhere it's reachable, and the exception catalog stays current with zero manual regeneration
Parallel Team Strategy
Once Foundational (T002) is merged: one contributor can take US1 (by far the largest phase — 35 tasks, but 29 of them are mutually [P]), a second can take US2 (5 tasks), a third can take US3 (13 tasks, 9 of them [P]), and a fourth can take US4 (17 tasks, 11 of them [P]). None of the four touch overlapping files.