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:

ProblemAudit feed's answerWhat 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

PieceReason
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

This endpoint never writes. It reads existing 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