Story 1984: the frame-status read seam — the matter page's window into the framer rail

RFD-EVENT-001 (EPIC-1973) built the framer rail write side: Story-1977's trigger claims ops.matter_frame_jobs atomically and the Story-1976 reconcile sweep fails expired leases. That table holds the honest frame truth — but nothing exposed it. The matter page (#1978) cannot show "frame building…" or "build failed" without a read seam. This Story is that seam: one read-only endpoint, no new tables, no new writes.

The endpoint

GET /app/matters/{matter_id}/frame-status   (matter_id = firm-local matter number)

200 {
  "runStatus": "idle" | "queued" | "running" | "failed",
  "rebuildNeeded": bool,
  "lastSkipReason": "consent_blocked" | null,   // closed machine vocabulary
  "requestedGeneration": int,
  "claimedGeneration": int,
  "claimedAt": timestamp | null,
  "lastSkippedAt": timestamp | null,
  "updatedAt": timestamp | null
}
404 pramaan.matter.not_found      // unknown OR cross-firm matter

What landed where

PieceWhy
src/domains/matter_frame_job/dtos.py — MatterFrameRunStatus (closed enum mirroring the DB CHECK) + MatterFrameStatusView HR-21 explicit contract. Thin by RFD-EVENT-001 doctrine: enums, counters, timestamps only — no matter titles, frame text, or any legal content rides the payload.
src/domains/matter_frame_job/service.py — MatterFrameJobService.frame_status Three fixed queries (firm context, matter-by-number, job row) — bounded per DR-1, no row-scaled fan-out. Reads use the ORM entity; the worker's raw-SQL one-statement claim stays write-only territory.
Route in src/app_api/router_matters.py Mirrors get_matter: AppDbDep RLS session, response_model=, canonical AUTH_NOT_FOUND_VALIDATION_SERVER_RESPONSES, clean appGetMatterFrameStatus operation id.
openapi/app-api.json + openapi/permission-map.json regenerated Producer snapshots stay the consumer contract; the app mirror picks the new operation up from here.
tests/factories.py — cleanup deletes ops.matter_frame_jobs before matters/firm The table FKs firm and matter with ON DELETE RESTRICT (Story-1977); without this, any test seeding a job row would wedge firm teardown.

Tenancy: 404, never 403, never leak

The service resolves the caller's firm from the RLS session (app.current_firm_id_or_null()) and looks the matter up by (firm_id, matter_number) — the path parameter is never trusted alone. RLS on both business.matters and ops.matter_frame_jobs (firm isolation policy, migration 0145) is the hard boundary; the explicit firm predicate keeps the read on the firm-leading index (DR-8). A cross-firm matter number returns the same pramaan.matter.not_found as a nonexistent one, so the endpoint cannot be used to probe another firm's matter numbers. The isolation test mirrors the EB-06 pattern: firm B's matter carries a failed job row, and firm A's caller still sees a bare 404.

The zero state is a state, not an error

A matter whose frame was never triggered has NO job row — that is the first-run truth for every matter created before the rail existed and for any future matter whose wake-up has not landed yet. The endpoint returns the DB defaults (idle, no rebuild, generation 0, null timestamps) instead of 404, and the integration test exercises exactly this empty-ledger request against a real DB — the system-boundary "done" bar from AGENTS.md, not a fixture-seeded unit pass.

Observability

Plain read route: the FastAPI route span from the shared app factory covers it — T1 auto-instrumentation floor per pramaan-docs ADR-124, no manual span, no bespoke attributes.

Verification

ruff check src/ tests/          clean
mypy src/domains/matter_frame_job/   clean (repo-wide mypy noise preexisting; CI does not enforce)
pytest tests/                   2143 passed, 2 skipped (full suite vs make test-db Postgres 16)
make openapi                    app-api.json snapshot committed
make gen-permissions            permission-map.json snapshot committed