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.
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)
| Surface | Change |
|---|---|
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 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).
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.
tests/integration/test_app_document_annotations.py drives real HTTP
requests through the router against a real, freshly provisioned (empty first-run) firm:
[] (the door is not welded shut); create/list/edit/soft-delete round-trip
with a full DTO round-trip assertion; the deleted row is retained and a further
mutation is a 404....anchor_not_byte_present) and zero rows land;
a byte-present anchor lands proposed.409; counsel cannot edit/delete an
agent note (409). Two document_annotation.created + two
document_annotation.resolved rows exist in BOTH ops.audit_log
and ops.domain_event_outbox.404 and every mutation is invisible (404), never a
leak.
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.
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.