Story 2116: Document annotations substrate - span-anchored notes (counsel + agent-proposed)

WHY. The founder-approved document viewer (pramaan-desk discussions/document-viewer/document-view.dc.html) puts a margin note beside a paragraph and a "Notes" section in the document card - counsel notes land active, and a note Mira (an agent) leaves while reading waits for counsel's Keep or Dismiss. That UI needs a durable, tenant-isolated home for a note that is anchored to a place in a document, not just a document. The existential risk is an agent note that "quotes" a paragraph the document does not contain: a hallucinated anchor. This story lands the substrate and makes that anchor byte-provable with the SAME V-CITE gate the registrar already uses - no new verification semantic.

What a note is

A document annotation = a source_span (span.v1: page + block + verbatim quote) + an author (counsel|agent) + a body + a status. Two orthogonal lifecycle axes, per the repo lifecycle danger-zone:

status        active | proposed | dismissed   (the NOTE state; dismissed stays on record)
availability  live | deleted                    (STORAGE; counsel soft-delete, never session.delete)

What changed

SurfaceChange
Migration 0152_document_annotations New documents.document_annotations: firm/matter/document refs, source_span JSONB, author_kind + author (user_id | agent_key), body, status, resolution audit fields, soft-delete axis. Tenant-safe composite FKs tie matter and document to the same firm. RLS mirrors documents.document_saved_views (firm isolation FOR ALL + staff-with-grant SELECT), FORCE ROW LEVEL SECURITY, app role gets SELECT/INSERT/UPDATE only (soft-delete, never row DELETE). CHECK constraints encode the lifecycle invariants (proposed ⇒ agent; a resolution stamps who+when together and only on an agent note). up / down / up + alembic check clean.
DocumentAnnotation entity SQLModel row matching the migration by hand (autogenerate cannot see the RLS policies, partial-index predicates, or table/column comments). Registered in alembic/env.py and src/shared/db/entities.py.
Byte-gate (agent notes) Reuses the V-CITE primitive quote_is_byte_present (src/domains/document_page/vcite.py): the anchor quote must be byte-present on the cited segment of the canonical documents.document_pages text. A missing page, out-of-range block, or non-present quote is a typed PramaanValidationError (...anchor_not_byte_present) raised inside the transaction - the note never lands. NO new verification semantic; the same invariant Blake's citations obey.
Routes - /app/documents/{document_id}/annotations Counsel POST (create active), GET (list live, newest first), PATCH /{id} (edit own body), DELETE /{id} (soft-delete own), and POST /{id}/keep · /{id}/dismiss for a proposed agent note. Agent creation is a service method (create_agent_annotation) driven by the agent rail, not a counsel HTTP route.
Audit + events (registry-additive) New audit subject document_annotation (matter area; body/quote/span excluded from snapshots - client content never rides a snapshot or the bus) and one new verb resolved. Every mutation writes its audit row and its ops.domain_event_outbox row in the same transaction. Create emits document_annotation.created; Keep/Dismiss both emit document_annotation.resolved (metadata carries kept|dismissed). The audit route-coverage manifest registers all five mutating routes.
OpenAPI make openapi - openapi/app-api.json only, purely additive. Admin surface untouched. The app src/gen mirror rides this snapshot.

Counsel is trusted; the agent is gated

Counsel notes are not byte-gated: the quote is the paragraph counsel selected, and the OCR/rendered text can legitimately drift by whitespace or ligatures - hard-gating a counsel note would false-reject a real note. The byte-gate exists to reject a hallucinated agent anchor, so it runs on the agent path only (the explicit Story-2116 scope). Counsel Keeps or Dismisses an agent proposal; it is never rewritten, and only a proposed note can be resolved (a second resolve is a typed 409).

Out of scope (named limitation)

Table-cell anchoring is deliberately excluded - tables degrade to text and a note anchors to the block. A future extraction upgrade that yields real cell coordinates is the trigger to revisit.

Verification (system boundary, not component boundary)

tests/integration/test_app_document_annotations.py drives real HTTP requests through the router against a real, freshly provisioned (empty first-run) firm:

Migration up/down/up + alembic check report no drift; the audit route-coverage, watchdog, and domain-event gate tests stay green with the additive registry entries.

One-sentence recall

A note is a byte-provable span plus a body: counsel notes land active and trusted, agent notes land proposed and must survive the same V-CITE byte-gate the registrar uses before counsel Keeps or Dismisses them - all firm-isolated, soft-deletable, and audited with a registry-additive event on every mutation.