Story-722 * functions lane * Size M * Walkthrough

Matter Types V1 backend read API

The first backend shelf for PRAMAAN's matter-type catalog.

Pull Request
Built by
Conductor agent on 2026-05-20

The 30-second version

Story-722 added the backend catalog for matter types: a new business.matter_types table, five system matter types every firm can read, RLS rules for future firm-specific variants, and two read-only app endpoints. The key idea is simple: PRAMAAN now has one trusted place to ask "what kind of matter is this?" before the Add Matter flow starts using that answer.

PART ONE - WHAT WE PLANNED TO DO AND WHY

1 Why does this exist?

Before this Story, the app could talk about matters, participants, and forums, but it did not have a backend object that said: "Arbitration looks like this, Civil Suit looks like that, and these are the participant labels and filing types we should show first."

That matters because legal work is not one flat shape. An arbitration has claimants, respondents, a tribunal, and awards. A writ petition has petitioners, respondents, a court, and counter affidavits. If the backend does not own these shapes, the frontend has to guess, copy hard-coded lists, or invent product truth in the wrong place.

Story-722 was the first move toward a backend-owned matter-type catalog. V1 only needed reads: list the visible matter types and fetch detail for one matter type. Writes, firm customization, and the actual Add Matter flow were intentionally left for later Stories.

[ ]

Think of a courthouse registry shelf

A courthouse does not ask each clerk to invent forms from memory. It keeps official shelves: civil suit forms here, arbitration forms there, criminal complaint forms over there. Everyone can read the official shelf, but a law firm may later keep its own local checklist beside it. Story-722 built that first official shelf in the database.

Ask yourself

Why not just let the frontend keep five hard-coded cards for Arbitration, Civil Suit, Writ Petition, Criminal Complaint, and NCLT Petition?

Answer: because those cards become product contracts. Once participant labels, filing types, and future firm variants matter, the backend must own the truth. The frontend can display the catalog, but it should not be the source of the catalog.

2 The plan

The spec locked the main architecture before code started. The table would live in the business schema. System rows would have firm_id IS NULL. Firm rows would carry a firm_id. RLS would let every firm read system rows while keeping firm variants scoped to their owner.

The seed list was also locked from design: Arbitration, Civil Suit, Writ Petition, Criminal Complaint, and NCLT Petition. Participant labels would be JSONB for V1. Seed filing types would also be JSONB, but explicitly temporary. Counts would be computed at read time with jsonb_array_length(), not stored as columns.

The plan deliberately rejected extra columns that had no V1 reader yet: no forum_type, no status, no proceedings_count, and no denormalized count fields. That is ADR-068's "no schema fiction" idea in practice: do not build columns just because they sound plausible.

App client needs catalog FastAPI routes list / detail Matter Type code STORY-722 DTOs, service, repository, seeds API-safe read shape business.matter_types STORY-722 system rows + future firm rows System seeds 5 official types RLS firm scope 1 2 3 4 5 6 DTO response

The green boxes are the new Story-722 ownership: domain code plus table/migration. The routes expose the catalog. The repository applies the visibility rule. The database enforces the same rule with RLS, so a missed filter in Python is not the only lock on the door.

What the spec said would ship

The V1 scope was narrow: create the entity, seed five system types, store participant labels, store temporary filing type data, add list/detail read endpoints, update OpenAPI, and cover RLS and contract behavior with tests.

Out of scope

Create/update/delete endpoints, firm-variant authoring, the Add Matter flow, forum entities, canonical filing type tables, procedure templates, and practice-area cleanup were all deferred. That restraint is the point: this Story made the shelf, not the whole courthouse intake department.

PART TWO - HOW WE ACTUALLY DID IT

3 What got built

PR #185 added 1,866 lines across 15 files. The change included one Alembic migration, a new matter type domain package, read-only app routes, app registration, OpenAPI snapshot updates, and integration tests.

Reading order

  1. alembic/versions/5fd4c8e9a722_0043_matter_types.py - start with the table, RLS policies, and frozen seed payload.
  2. src/domains/matter_type/entity.py - see how the SQLModel entity mirrors the migration.
  3. src/domains/matter_type/seeds/system_matter_types.py - read the canonical Python seed list and the forum classification kept out of the DB.
  4. src/domains/matter_type/dtos/matter_type.py - inspect the app-facing API contract.
  5. src/domains/matter_type/repository.py - understand visible reads, counts, and ordering.
  6. src/domains/matter_type/service.py - see how DB rows become DTOs and 404s.
  7. src/app_api/router_matter_types.py - see the trust-boundary route shape.
  8. src/app_api/app.py, src/shared/db/entities.py, and alembic/env.py - confirm the new router and entity are wired into the app and metadata.
  9. tests/integration/test_app_matter_types.py - read the behavior contract the team actually protects.
  10. openapi/app-api.json - check the generated consumer-facing snapshot.
alembic/versions/5fd4c8e9a722_0043_matter_types.pyDatabase contract

This migration creates business.matter_types. It owns the durable contract: UUID primary key, optional firm_id, source, name, subtitle, JSONB participant labels, JSONB seeded filing types, timestamps, constraints, partial unique indexes, grants, comments, and RLS policies.

The seed data is frozen directly inside the migration. That looks duplicate, because the domain package also has seed data, but it is intentional. A migration is a historical receipt. If the Python seed file changes next month, revision 0043 must still mean exactly what it meant on 2026-05-20.

The RLS rule is the real safety feature: system rows are readable to every firm; firm rows are visible only to the current firm. App inserts cannot create system rows, because system rows are migration-managed.

Ask yourself

Why put tenant visibility in Postgres instead of only filtering in the repository?

Because: the repository filter is useful, but RLS is the locked cabinet. If a future query forgets the Python filter, Postgres still knows which rows the current firm is allowed to see.

src/domains/matter_type/entity.pySQLModel entity

The entity is the Python shape of the table. Its most important rule is the pair: source = system means firm_id must be null, and source = firm means firm_id must be present.

That rule is not just pretty modeling. It prevents a half-system, half-firm row from existing. Without it, a row could look global to one part of the code and firm-owned to another part of the code.

The partial unique indexes match the product model: only one system "Arbitration"; only one firm-local "Arbitration" per firm. Different firms can later create the same firm variant name without colliding.

src/domains/matter_type/seeds/system_matter_types.pyCatalog seeds

This file holds the canonical Python seed list for runtime code: Arbitration, Civil Suit, Writ Petition, Criminal Complaint, and NCLT Petition. Each seed carries participant labels and filing type seeds.

It also keeps forum_type in Python data, not in the database. That is one of the most important Story-722 decisions. The team knew forum classification would matter later, but there was no V1 backend reader. Keeping it in seed data avoids pretending the DB has a stable contract that no shipped feature uses yet.

Gotcha

Do not add forum_type to business.matter_types just because this seed file has it. The file is a holding shelf for a future matter-creation story; the database column arrives only when a real reader arrives.

src/domains/matter_type/dtos/matter_type.pyAPI contract

The DTOs define what the app is allowed to see. List items expose id, name, subtitle, source, participantsCount, and filingTypesCount. Detail responses add participant labels and filing types.

Notice what is absent: no firmId, no status, and no proceedingsCount. Those omissions are part of the contract. A smaller response is not less complete; it is more honest about what V1 actually owns.

The participant label validator checks that display orders are unique. It is a small guardrail, but it protects the UI from receiving two labels that both claim to be first in line.

Ask yourself

Why does the response show counts but not return every detail in the list endpoint?

Because: the list page needs enough to draw cards or rows. The detail page needs the full setup. That is like reading a restaurant menu first, then asking for the ingredient card only for the dish you picked.

src/domains/matter_type/repository.pyVisible reads

The repository asks Postgres for rows visible to the current app firm: all system rows plus firm rows where firm_id = app.current_firm_id_or_null(). It computes participant and filing type counts with jsonb_array_length().

The ordering is deliberate. System rows appear first in the locked seed order, then firm rows appear by lowercase name. A new engineer should not "simplify" this to alphabetical order unless product has changed the expected display order.

Gotcha

The counts are runtime calculations, not columns. If you add denormalized count columns later, you also accept update bugs whenever labels or filing types change.

src/domains/matter_type/service.pyDTO assembly

The service is the small bridge from database rows to API DTOs. It builds list items, sorts participant labels by order, validates filing type views, and raises PramaanNotFoundError when detail lookup finds nothing.

The service does not re-decide tenant visibility. That belongs in the repository and database policy. Its job is cleaner: take a visible row and make the response shape boringly predictable.

src/app_api/router_matter_types.pyTrust boundary

The router exposes GET /app/matter-types and GET /app/matter-types/{matter_type_id}. Both routes use explicit response_model declarations and shared error response constants.

This is where backend truth crosses into the app API. The router stays thin: dependency-injected app DB session in, service method out. That keeps auth and RLS perimeter behavior aligned with the rest of the app API.

Ask yourself

Why is a thin route a feature, not a missed abstraction opportunity?

Because: route handlers are border guards. They should check papers and send the request to the right desk. If they start doing catalog logic inline, behavior becomes harder to test and easier to fork by accident.

tests/integration/test_app_matter_types.pyBehavior proof

The integration tests prove the visible contract: authenticated firms see the five system seeds, list/detail DTO round trips match the actual JSON, seed ordering and counts are stable, private firm variants stay private, unknown or cross-firm detail returns 404, and unauthenticated calls return 401.

These tests also assert absence. They check that list items do not include proceedingsCount, status, or firmId. That matters because accidental fields can become accidental product promises.

Gotcha

A response-shape test should not only spot-check one field. Story-722 validates full DTO round trips so the OpenAPI contract, Pydantic aliases, and returned JSON stay aligned.

4 Deviations from the plan

No major product deviation landed. The shipped work matches the Story scope: table, seeds, RLS, list/detail routes, DTOs, tests, and OpenAPI.

There were two implementation choices worth naming. First, the migration froze seed payloads instead of importing runtime seed helpers. That keeps the migration historical and repeatable. Second, forum_type stayed in Python seed classification rather than becoming a database column. That follows the locked plan: future matter creation can add the column when it has a real reader.

The PR also kept seeded_filing_types as temporary JSONB, despite knowing Story-731 would replace it. That was not a mistake. It was a short bridge so the V1 read API could give the frontend useful detail while the canonical filing type entity waited for its own Story.

5 Errors hit and how we fixed them

The PR body does not record a code-level bug that changed the design. The main local problem was environment, not implementation: full DB-backed integration checks could not run in the workspace because local Postgres / DATABASE_URL was missing. The implementer ran targeted local checks that did not need that database and relied on GitHub Actions for the full DB-backed suite.

There were also repo-wide static-check baselines outside this Story: full ruff and full mypy had unrelated historical findings. The PR handled that by running narrow checks over the changed matter type files, routes, and tests, then recording the limitation instead of pretending the entire repo was clean.

CI passed for commit 9d3b8a2, including Alembic round-trip, full tests, contract test, governance, security, OpenAPI generation, and OpenAPI diff check. In plain English: local had a missing database socket; CI had the right room set up and proved the furniture fit.

6 Gotchas and surprises

Gotcha

seeded_filing_types is throwaway JSONB. Treat it like a temporary paper checklist taped to the shelf, not the final filing-type system.

Gotcha

System rows have firm_id IS NULL. Firm rows must have firm_id. Do not add a third shape unless you are ready to rewrite the RLS and product model.

Gotcha

The five system type IDs are fixed seed IDs. Changing them later would break links from any future matter rows that point at these types.

Gotcha

The app response intentionally hides firmId. The caller already has firm context from auth and RLS; leaking tenant identifiers in every payload is not needed for V1.

Gotcha

Do not read the OpenAPI snapshot first. It tells you the outside shape, but not the why. Read migration, entity, seeds, repository, service, router, tests, then the snapshot.

7 What's still open

Story-722 was foundation work. The next layers still need their own Stories and reviews.

8 Check yourself

Can you answer these?

  1. Why do system matter types use firm_id IS NULL?
  2. What stops Firm A from reading Firm B's custom matter type rows?
  3. Why are participant labels JSONB in V1 instead of a normalized table?
  4. Why is forum_type present in seed data but absent from the table?
  5. Why does the list endpoint return counts but not full participant labels?
  6. What would break if the API started returning proceedingsCount before a real source exists?
  7. Why should you update OpenAPI snapshots after changing these DTOs or routes?

If those answers feel fuzzy, reread the migration and repository together. The core model is there: official system shelf, future firm shelf, and Postgres guarding who can see what.