Skip to content

feat: data model and migrations - #2

Merged
div0rce merged 11 commits into
mainfrom
feat/m01-data-model
May 28, 2026
Merged

feat: data model and migrations#2
div0rce merged 11 commits into
mainfrom
feat/m01-data-model

Conversation

@div0rce

@div0rce div0rce commented May 28, 2026

Copy link
Copy Markdown
Owner

Milestone

M1 — Data model + migrations

Summary

Persistent layer for Sentinel: SQLAlchemy 2.x typed Mapped[] models for the five
aggregates (documents, chunks, extractions, workflow_items, audit_events),
the chunks.embedding column as Vector(1536) via pgvector, the workflow status as a
native PG enum, an Alembic config + initial migration that creates the vector
extension and all tables in dependency order, functional repositories with an
intentionally append-only audit_events module, and a DB-backed test suite (25 tests)
that runs against the CI Postgres + pgvector service container.

Definition of Done

  • All DoD items from MILESTONES.md met
  • make check passes (ruff + ruff-format + mypy strict + pytest)
  • Tests added/updated for new logic — 25 tests across schema, models, repos, and
    audit-events append-only invariant
  • PROGRESS.md updated — M1 marked complete on branch; M0 row updated to ☑ merged with
    PR chore: scaffold project, tooling, and CI #1 link; decision log seeded
  • No secrets committed; sample data is synthetic
  • Guardrails intact (citation-or-refuse, PII redaction, confidence gating, audit logging) — N/A; M5+

M1 DoD verification (from MILESTONES.md)

  • make migrate applies cleanly on a fresh DB; pgvector extension enabled.
    Verified locally against Postgres.app (pgvector 0.8.1) on a dedicated sentinel_m1_local
    DB: alembic upgrade head is clean, idempotent (re-run is a no-op), and fully reversible
    (downgrade base drops tables, the workflow_status enum, and the vector extension;
    re-upgrade head restores everything). CI re-verifies this via an explicit
    uv run alembic upgrade head step against the pgvector/pgvector:pg16 service container,
    immediately before pytest.
  • Models + repositories unit-tested against the CI Postgres service.
    backend/tests/conftest.py opens a session-scoped engine and yields a per-test session
    using join_transaction_mode="create_savepoint" so each test runs inside a SAVEPOINT and
    is rolled back at the end — even if test code commits. The schema is at head before the
    first test runs (CI does so explicitly; local dev does so via make migrate).
  • audit_events has no update/delete path in the repository layer. Two-pronged
    enforcement:
    • The module (backend/app/repositories/audit_events.py) exposes only append,
      list_for_target, list_by_request_id, and get.
    • test_audit_events_append_only.py::test_module_public_surface_has_no_mutators fails
      if any future change adds a public symbol matching update*, delete*, remove*,
      set*, purge*, etc.

Notes / decisions

See PROGRESS.md "Decision log" — three entries logged for M1:

  • Embedding column locked to vector(1536) (matches OpenAI text-embedding-3-small,
    the planned M2 default). Switching providers requires a new migration.
  • WorkflowItem.status persists the enum value (needs_review), not the Python name,
    via SAEnum(values_callable=…). Caught immediately by the test suite when missing.
  • Repositories are functional (one module per aggregate, plain functions taking a
    Session); the caller owns transaction boundaries.

Other deliberate choices:

  • Base.metadata uses a constraint-naming convention so future Alembic autogenerate
    diffs are deterministic.
  • The pgvector type adapter is registered via a SQLAlchemy connect event on the engine,
    guarded by a pg_type probe so the engine remains importable against a fresh DB before
    migrations have created the vector extension.
  • Mypy override added for pgvector.* (no py.typed marker upstream).

Test coverage

  • test_schema.py (5 tests): five tables present; vector extension installed;
    workflow_status enum has values [auto_approved, needs_review, rejected];
    chunks.embedding is vector(1536); alembic_version is at 0001_initial_schema.
  • test_models.py (8 tests): Document round-trip + unique hash, Chunk requires document
    • unique (document_id, ord) + cascade delete, Extraction JSONB round-trip,
      WorkflowItem enum + idempotency_key constraint, AuditEvent JSONB before/after round-trip.
  • test_repositories.py (7 tests): every public function in each repo (documents, chunks,
    extractions, workflow_items).
  • test_audit_events_append_only.py (4 tests): append + JSONB round-trip; oldest-first
    ordering by target and by request_id; introspection test against forbidden mutators.

Reminder

Please squash-merge this PR (Golden Rule #2: one milestone = one squash-merge commit
on main).

div0rce added 10 commits May 28, 2026 16:16
…c-settings

M1 dependencies for the data layer:
- sqlalchemy>=2.0 (typed Mapped[] models)
- psycopg[binary]>=3.2 (driver matching the postgresql+psycopg URL in CI)
- alembic>=1.13 (schema migrations)
- pgvector>=0.3 (Vector column type for embeddings)
- pydantic-settings>=2.6 (config loaded from env)
…ing fields

Settings is loaded from env vars (and a local .env if present) and exposed via a
cached get_settings() accessor. Includes the M1 database URL, the pgvector embedding
dimension (1536 by default, matching text-embedding-3-small), and forward-looking
fields for embedding/LLM keys and retrieval thresholds consumed from M2+.
Engine and session factory are built lazily so test fixtures can override env vars
before construction. The pgvector type adapter is registered on every new connection,
guarded by a pg_type probe so the engine works against a fresh DB before migrations
have created the vector extension. reset_engine_for_tests() lets fixtures dispose and
rebuild the engine between cases.

Adds an mypy override for pgvector.* (no py.typed marker upstream).
…low_items, audit_events

Five aggregate models with typed Mapped[] columns and a stable constraint-naming
convention on Base.metadata so Alembic autogenerate diffs are deterministic.

- Document: id, hash (unique), source, title, mime_type, timestamps.
- Chunk: id, document_id (FK ON DELETE CASCADE), ord, text, token_count, embedding
  (pgvector Vector(EMBEDDING_DIM); nullable so M1 can insert chunks without vectors).
  Unique index on (document_id, ord).
- Extraction: id, document_id, schema_name, payload (JSONB), per-field confidence and
  citations as JSONB sidecars, model_name, created_at.
- WorkflowItem: id, extraction_id, status (StrEnum: auto_approved/needs_review/rejected,
  native SQL enum 'workflow_status'), reason, idempotency_key (unique), timestamps.
- AuditEvent: id, ts (indexed), actor, action, target_type/id, before/after JSONB,
  request_id (indexed). Composite index on (target_type, target_id) for per-target queries.

EMBEDDING_DIM is captured at import time from settings.embedding_dim. Future changes to
the dimension require a new migration to alter the column type.
alembic.ini sits at the repo root; script_location=backend/alembic and prepend_sys_path=.
so env.py can import backend.app.* without packaging tricks.

env.py reads the SQLAlchemy URL from the same Settings the app uses (single source of
truth for DATABASE_URL) and runs against the engine in backend/app/db.py.

The initial migration (0001_initial_schema) creates the vector extension *first*, then
the workflow_status enum, then the five M1 tables in FK-dependency order with all
naming-convention-compliant indexes and constraints. Verified locally:
- alembic upgrade head: applies cleanly on a fresh DB
- alembic upgrade head (re-run): no-op
- alembic downgrade base: drops tables, enum, and extension
- alembic upgrade head (after downgrade): restores everything
One module per aggregate (documents, chunks, extractions, workflow_items, audit_events)
exposing thin functions that take an SA Session. Transaction boundaries are owned by
the caller (FastAPI dep, ingestion pipeline, etc.).

audit_events.py exposes append() and read helpers only — there is intentionally no
update or delete path, satisfying the M1 invariant. An introspection test (added next)
enforces this surface so it cannot be quietly broken later.
…ma, append-only tests

conftest.py supplies a session-scoped engine fixture (with reset_engine_for_tests so
DATABASE_URL changes between processes are picked up) and a per-test session that
joins the outer connection's transaction via 'create_savepoint', so any commits
inside a test are rolled back at the end and tests are fully isolated.

- test_schema.py: introspects the migrated DB and asserts five tables, the vector
  extension, the workflow_status enum's value order, the chunks.embedding column type
  is vector(1536), and alembic_version is at 0001_initial_schema. This satisfies the
  test side of DoD #1.
- test_models.py: round-trip + constraint coverage (unique hash, unique (doc_id, ord),
  cascade delete from documents, JSONB round-trip on extractions and audit_events,
  enum + idempotency_key constraint on workflow_items).
- test_repositories.py: exercises every public function in each repo module.
- test_audit_events_append_only.py: behavioural tests for append/list ordering plus
  an introspection test that fails if any future change adds a public name matching
  forbidden mutator patterns (update*, delete*, remove*, set*, etc.).

Also fixes WorkflowStatus persistence: SAEnum now uses values_callable so the DB
stores the enum *value* (e.g. 'needs_review'), not the Python *name*
('NEEDS_REVIEW') — the latter was rejected by the workflow_status PG enum type.
Adds 'uv run alembic upgrade head' between mypy and pytest. The Postgres service
container starts empty, so this step explicitly verifies M1 DoD #1 ('make migrate
applies cleanly on a fresh DB; pgvector extension enabled') in CI on every PR.
@div0rce

div0rce commented May 28, 2026

Copy link
Copy Markdown
Owner Author

@codex review

Review this PR strictly as M1 — Data model + migrations.

PR:
#2

Do not modify files.
Do not create commits.
Do not suggest implementing future milestones.
Only review whether this PR correctly satisfies M1.

Read first:

  • CLAUDE.md
  • MILESTONES.md
  • PROGRESS.md
  • HANDOFF.md

Review against M1 scope only:

  • SQLAlchemy 2.x typed models
  • Alembic configuration and migration setup
  • pgvector extension enablement
  • PostgreSQL enum handling
  • config.py using pydantic-settings
  • db.py engine/session management
  • repository helpers
  • CI migration flow
  • tests against Postgres + pgvector
  • append-only enforcement for audit_events
  • migration reversibility/idempotency
  • transaction/session boundaries
  • constraint naming conventions
  • type safety and mypy compatibility

Check for:

  1. Any blocker preventing M1 from being considered complete.
  2. Any schema/migration flaw.
  3. Any Alembic autogenerate or metadata issue.
  4. Any pgvector misuse or portability issue.
  5. Any repository-layer design flaw.
  6. Any append-only invariant weakness.
  7. Any transactional integrity issue.
  8. Any test gap that makes M2 unsafe to begin.
  9. Any overbuild beyond M1.
  10. Any hidden technical debt likely to break M2.

Output only:

  • BLOCKING
  • NON-BLOCKING
  • CLEANUP
  • FINAL VERDICT: safe to proceed to M2 or not

Be strict. No praise. No generic review comments.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 520db844f1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread backend/app/models.py Outdated

# Pin the embedding dimension at import time. Changing EMBEDDING_DIM later requires a
# migration to alter the column type — the value baked into the M1 migration is canonical.
EMBEDDING_DIM: int = get_settings().embedding_dim

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the ORM vector dimension fixed to the migrated schema

When EMBEDDING_DIM is set to a non-default value such as 1024 for the documented voyage option, Base.metadata is built with Vector(1024) while the M1 migration always creates chunks.embedding as vector(1536). That creates Alembic autogenerate drift and lets M2 produce embeddings that the migrated database will reject, so the ORM dimension needs to be tied to the migration or the config must fail early unless it matches the schema.

Useful? React with 👍 / 👎.

Comment on lines +75 to +77
def get(session: Session, event_id: int) -> AuditEvent | None:
"""Look up a single event by id (read-only)."""
return session.get(AuditEvent, event_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent repository reads from exposing mutable audit rows

This read helper returns a live, session-tracked AuditEvent, so any caller that fetched an event through the repository can mutate fields or call session.delete(event) and commit, which is still an update/delete path despite the absence of mutator-named functions. For the M1 append-only audit invariant, repository reads should not hand out mutable persistent audit rows without additional enforcement.

Useful? React with 👍 / 👎.

@div0rce

div0rce commented May 28, 2026

Copy link
Copy Markdown
Owner Author

@codex review

Re-review PR #2 after commit 92a078c.

Focus ONLY on the two previously reported findings and their fixes.

Previously reported issues:

  1. ORM vector dimension drift between runtime config and migrated schema.
  2. audit_events read helpers exposing mutable session-tracked ORM rows.

Verify:

  • ORM metadata is now schema-canonical and migration-aligned.
  • No runtime config can silently change Vector(...) metadata.
  • No Alembic drift was introduced.
  • audit_events read helpers now return immutable detached values.
  • append-only invariant is materially enforced.
  • New tests meaningfully cover the regression paths.
  • No new architectural or transactional flaw was introduced by the patch.
  • PR scope remains M1 only.

Output only:

  • BLOCKING
  • NON-BLOCKING
  • CLEANUP
  • FINAL VERDICT

Be strict.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@div0rce
div0rce merged commit 0e62341 into main May 28, 2026
1 check passed
@div0rce
div0rce deleted the feat/m01-data-model branch May 28, 2026 21:08
div0rce added a commit that referenced this pull request May 28, 2026
* docs(progress): record M1 merged (PR #2) and mark M2 in progress

* chore(deps): add tiktoken for token-based chunking

tiktoken supplies the cl100k_base BPE encoder that text-embedding-3-* uses, so chunk
boundaries align with the embedding tokenizer.

* feat(config): add 'fake' embeddings provider and chunking knobs

- embeddings_provider Literal extended to {openai, voyage, fake}; CI/tests use 'fake'.
- openai_embedding_model exposed for explicit pinning (default text-embedding-3-small).
- chunk_size_tokens (default 512) and chunk_overlap_tokens (default 64) configure the
  sliding-window chunker arriving in this milestone.
- .env.example mirrors the additions.

* feat(embeddings): provider Protocol, FakeEmbedder, OpenAI provider, factory

- base.py: EmbeddingProvider Protocol with .dim and .embed(texts).
- fake.py: deterministic SHA-256-stretched-then-L2-normalized vectors of
  SCHEMA_EMBEDDING_DIM length. Same input → same vector. No API calls.
- openai_provider.py: thin httpx POST against /v1/embeddings, no SDK dep. Defensively
  sorts results by index and validates output dim against the configured dim.
- __init__.py: get_embedder(settings) factory dispatches to fake|openai (voyage raises
  NotImplementedError for now). Raises before any work if runtime EMBEDDING_DIM does
  not match SCHEMA_EMBEDDING_DIM, so dimension drift is loud and deterministic.

httpx promoted from dev to runtime deps for the OpenAI provider; FakeEmbedder
itself does not need it.

* feat(chunking): tiktoken-based sliding-window chunker

chunk_text(text, *, chunk_size_tokens, chunk_overlap_tokens) returns deterministic
ChunkOut(ord, text, token_count) records using the cl100k_base encoder (matches
text-embedding-3-*). Window advances by (size - overlap); overlap >= size raises.
Empty input produces no chunks; short input produces one. The encoder is cached
via lru_cache so the BPE table loads once per process.

* feat(ingest): hash-keyed idempotent document ingestion + CLI

ingest_document(session, text, source, ...) is the unit operation:
- Compute SHA-256 of canonical UTF-8 text → content hash.
- If a document with that hash exists → return status='skipped', no chunks added.
- Else: insert document, chunk via tiktoken, embed via the injected provider,
  bulk-insert chunks. Returns status='ingested' with chunk_count.

ingest_path(path, embedder) walks supported files (.txt, .md) in sorted order,
opens a session per file, and commits after each so a partial failure mid-corpus
does not lose completed documents.

CLI: python -m backend.app.ingest --path <dir|file> reads settings.embeddings_provider
to pick fake|openai, prints per-file +/= flags and a totals line, exits 0 on success.
Wired by 'make seed'.

* feat(corpus): synthetic sample corpus + deterministic generator

scripts/gen_synthetic_corpus.py uses a seeded random.Random to produce 14 fictional
markdown documents (5 invoices, 5 incident reports, 4 policy memos) under data/sample/.
Re-running the script on a clean tree produces byte-identical output, so the committed
corpus is reproducible.

All content is synthetic — fictional vendors, IDs, dates, and dollar values. data/sample/
README.md and the in-document boilerplate make this explicit.

* test(m2): chunking determinism, FakeEmbedder, ingestion idempotency

- test_chunking.py: determinism across runs, advance-by-(size-overlap), overlap
  yields shared content, overlap=0 yields disjoint token coverage (verified by total
  token count, not by decoded text — repeating inputs can decode identically), invalid
  config (overlap >= size, size <= 0, overlap < 0) raises, ChunkOut is frozen.
- test_embeddings_fake.py: Protocol satisfaction, default dim matches schema, identical
  inputs yield identical vectors, vectors are L2-normalized, factory dispatch for fake
  and openai, factory raises early on dim mismatch and missing API key, voyage raises
  NotImplementedError.
- test_ingest.py: SHA-256 hash stability and lack of whitespace normalization;
  ingest_document creates a doc and chunks with embeddings of SCHEMA_EMBEDDING_DIM
  length; **re-ingesting the same content is a no-op** (status='skipped', no new chunks)
  even from a different source path; different content creates separate documents;
  empty input creates a document with zero chunks.

* ci: set EMBEDDINGS_PROVIDER=fake and run `make seed` after pytest

EMBEDDINGS_PROVIDER=fake at the job level guarantees no live API calls anywhere
in CI (DoD #3 of M2). The `make seed` step runs after pytest against the empty
Postgres service container, exercising the full ingest pipeline end-to-end:
sample corpus → token chunker → FakeEmbedder → INSERT into chunks with vectors.
That satisfies M2 DoD #1 (`make seed` ingests the synthetic corpus; chunks
populated with embeddings) on every PR.

* docs(progress): mark M2 complete on branch with DoD verification and decision log

* fix: preserve unicode provenance in chunking
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant