Story 2020: the frame versions read endpoint
Why this exists (the catastrophe it prevents)
Every Matter Frame edit APPENDS a new version - agent runs and counsel
edits alike never overwrite, they stack (append-only, pramaan-docs
ADR-153 D1; the migration REVOKEs UPDATE/DELETE from the app role). So the
history already exists in business.matter_frames. Until now
there was no door to read it: the Brief page could show only the current
(highest) version. Counsel could not see what a prior version said, could
not tell whether a field was set by a human or by Vega, and had nothing to
undo/redo or restore from. The stack was there; the window was welded shut.
This endpoint is that window.
Satellite: the endpoint
GET /app/matters/{matter_id}/frame/versions (matter_id = firm-local matter number)
?cursor=<version> // optional, opaque; from a prior page's nextCursor
?limit=50 // 1..200
200 {
"versions": [ newest-first frame versions ], // highest version first
"nextCursor": "<version>" | null // null at the end
}
404 pramaan.matter.not_found // unknown OR cross-firm matter
400 pramaan.matter_frame.cursor_invalid // non-integer cursor
Airplane: one analogy - the audit feed's twin
Think of the audit feed (GET /app/audit/matters/{id}) as the
blueprint and this endpoint as a tracing of it. Two problems the audit feed
already solved, we solve the same way rather than inventing new machinery:
| Problem | Audit feed's answer | What we copied |
|---|---|---|
| Who is the author? | One LEFT JOIN to identity.users on the actor's id, resolved in the same SELECT so the page never needs a second round-trip. |
The same LEFT JOIN, keyed on created_by_user_id. Counsel rows resolve to a name (first+last, then name, then email); agent rows have a null user, so the name collapses to null. |
| How to paginate a growing, newest-first list without clock drift? | A strictly-monotonic seq cursor: ORDER BY seq DESC, seq < cursor, fetch limit+1 to detect the next page. |
The per-matter version is already strictly monotonic, so it doubles as the cursor. Same shape, no new column. |
Microscopic: the agent-vs-counsel split
The row itself tells you the actor kind. source is a closed
vocabulary - 'agent' or 'counsel' - and the D1
CHECK constraints pair it with exactly one attribution column:
source = 'agent' <=> agent_run_id IS NOT NULL (created_by_user_id NULL) source = 'counsel' <=> created_by_user_id IS NOT NULL (agent_run_id NULL)
So the endpoint does not resolve an agent's display name at all - the app
renders Ag. <agent> from agent_run_id,
exactly as the audit feed renders agents from its stamped label rather than
a join. The backend's job is the one thing the app cannot cheaply do:
resolve the counsel identity. Everything else is passed through.
What landed where
| Piece | Reason |
|---|---|
src/app_api/router_matters.py |
Adds GET /app/matters/{matter_id}/frame/versions beside the sibling frame routes, with response_model, canonical auth/not-found/error responses, and cursor/limit query params. |
src/domains/matter_frame/service.py |
MatterFrameBriefService.version_history: resolves the matter by number under the firm predicate (404 on unknown/cross-firm), then one bounded SELECT - the author LEFT JOIN, ORDER BY version DESC, version < cursor, limit+1. No N+1: the join is part of the single query (pramaan-docs ADR-130 DR-1). |
src/domains/matter_frame/dtos.py |
Adds MatterFrameVersionHistoryItem (envelope, created_at, source, resolved author_display_name, agent_run_id/created_by_user_id) and MatterFrameVersionHistoryResponse (versions + next_cursor). |
tests/integration/test_app_matter_frame_versions.py |
Real ASGI route against the isolated Postgres test DB: newest-first ordering, the counsel-vs-agent author split, cursor pagination (limit + nextCursor follow), the empty first-run state, and cross-firm 404. |
openapi/app-api.json, openapi/permission-map.json |
Regenerated producer contract + permission map for the new route. |
Read-only over history
matter_frames
rows and touches no part of the append path - the version allocator, the
counsel edit, and the agent ingest boundary are all untouched. A matter
with no frame yet is a valid state: the query simply returns
versions: [] with nextCursor: null.
Carry-through note
This branch also syncs cause_title into the vendored
FrameOutput mirror (both schemas/frame_output.v1.*
re-copied byte-identical from pramaan-agents main, field added between
case_ref and our_counsel). That is the downstream
carry of pramaan-agents Story-2017 / PR #177, which merged after this branch
forked and was blocking the frame-schema-parity gate for every functions PR.
Verification
make test-db-reset && make test-db set -a; source .env.test-db; set +a uv run pytest tests/integration/test_app_matter_frame_versions.py tests/unit/test_matter_frame_envelope.py -q uv run ruff check src/app_api/router_matters.py src/domains/matter_frame/service.py src/domains/matter_frame/dtos.py make openapi && make gen-permissions