Story-1161: audit vocabulary registry + typed audit.emit single stamping point

Story 1161Epic None36 files · +1177/−213

PR #297 · pramaan-io/pramaan-functions · merged 2026-06-10

Why this matters

Eighty-five writers, twenty-five files, zero shared vocabulary.

When the audit trail is the only witness, the words must agree.

Constraining the schema solves today. Tomorrow's vocabulary still costs a migration.

The fix — One word book, one filing desk — extending vocabulary is a dict edit, never a migration.

How it flows

sequenceDiagram
  autonumber
  participant caller as Caller
  participant registry as Registry (word book)
  participant emitter as Emitter (filing desk)
  participant db as Audit log
  participant ci as CI gate
  caller->>registry: resolve(action)
  registry->>registry: returns ActionSpec — verb, category, area, snapshot_kind, excluded_fie
  caller->>emitter: emit(action, subject, scope, snapshot, request)
  emitter->>emitter: validates snapshot, truncates values, builds row
  emitter->>db: single row written
  ci->>ci: scans for direct AuditService.log() outside the audit module  build fa

src/shared/audit/{registry,emitter,snapshots}.py

Methodin → outwhat it does
resolve(action) → ActionSpeclooks up an action key in the word book and returns its full stamped attributes
emit(action, subject, scope, snapshot, secondary_subjects, request) → AuditLogthe single filing desk that validates the snapshot and writes one audit row
emit_legacyasync def emit_legacy( session: AsyncSession, *, action: str, resource_kind: str, resource_id: UUID | None = None, grant_id: UUID | None = None, event_metadata: dict[str, Any] | None = None, firm_id_override: UUID | None = None, ip_address:adapter for the eighty-five legacy call sites, rows stay byte-for-byte unchanged
DiffSnapshot(class)typed snapshot form with 512-char value caps and a truncated flag

What changed, file by file

Data-only registry of verbs, subjects, categories, areas, and snapshot kinds — extending vocabulary is a dict edit, not a migration.

new classsignature
VerbSpec(attributes, category, snapshot_kind)
ActionSpec(action, subject_kind, verb, category, area, snapshot_kind, scope_field, excluded_fields)
resolve(action) → ActionSpec
New fields: CATEGORIES : Final[frozenset[str]]AREAS : Final[frozenset[str]]SNAPSHOT_KINDS : Final[frozenset[str]]label : strdefault_area : strscope_field : str | None

Provides the single typed entry point plus a shim the eighty-five legacy call sites use, keeping written rows byte-for-byte unchanged.

new methodsignature
emit(action, subject, scope, snapshot, secondary_subjects, request) → AuditLog
emit_legacyasync def emit_legacy( session: AsyncSession, *, action: str, resource_kind: str, resource_id: UUID | None = None, grant_id: UUID | None = None, event_metadata: dict[str, Any] | None = None, firm_id_override: UUID | None = None, ip_address:

Defines the Pydantic typed snapshot union with 512-char value caps, fifty fields max, and a truncated flag for call-site validation.

new classsignature
_truncate(value) → tuple[str | None, bool]
_SnapshotBase(class)
DiffField(class)
_cap() → FactField
FactField(class)
DiffSnapshot(class)
SummarySnapshot(class)
TombstoneSnapshot(class)
FileSnapshot(class)
RunSnapshot(class)
SecondarySubject(class)
New fields: VALUE_CAP : intLABEL_CAP : intMAX_FIELDS : intMAX_SECONDARY_SUBJECTS : intv : intfield : strlabel : strold : str | Nonenew : str | Nonetruncated : boolvalue : str | Nonefilename : strsize_bytes : int | Nonecontent_type : str | Noneduration_ms : int | Nonekind : strid : UUID | Nonerole : str

Demoted the writer to a private path and added new audit columns (category, area, scope ids) for forward-compatible agent identity.

New fields: subject_label : str | Nonecategory : str | Nonearea : str | Nonematter_id : UUID | Noneproceeding_id : UUID | Nonefiling_id : UUID | Nonedocument_id : UUID | None

Typed Subject, Scope, and RequestContext models so callers can pass structured request data into the new emit entry point.

new classsignature
Subject(class)
Scope(class)
RequestContext(class)
New fields: kind : strid : UUID | Nonelabel : strmatter_id : UUID | Noneproceeding_id : UUID | Nonefiling_id : UUID | Nonedocument_id : UUID | Noneip_address : str | Noneuser_agent : str | None

Files changed (36)

File±
docs/walkthroughs/story-1161-walkthrough.html+147
src/app_api/firm_service.py+4 −4
src/app_api/seats_and_billing/service.py+2 −2
src/domains/adversary_read/service.py+5 −5
src/domains/annotation/service.py+4 −4
src/domains/audit_log/service.py+59 −25
src/domains/claim/service.py+4 −4
src/domains/claim_block_ref/service.py+4 −4
src/domains/claim_rbr_link/service.py+4 −4
src/domains/client/service.py+6 −6
src/domains/document/service.py+4 −4
src/domains/document_block/service.py+3 −3
src/domains/extraction/service.py+2 −2
src/domains/filing_type/service.py+4 −4
src/domains/firm/service.py+5 −5
src/domains/firm_membership/service.py+4 −4
src/domains/firm_subscription/service.py+4 −4
src/domains/firm_support_grant/service.py+5 −5
src/domains/firm_support_staff/service.py+3 −3
src/domains/matter/identity_service.py+9 −9
src/domains/matter/redesign_service.py+10 −10
src/domains/matter/service.py+3 −3
src/domains/platform_staff/csm_assignment_service.py+2 −2
src/domains/platform_staff/service.py+5 −5
src/domains/proceeding/service.py+7 −7
src/domains/rbr/service.py+2 −2
src/domains/validation_check/service.py+3 −3
src/shared/audit/__init__.py+44 −7
src/shared/audit/context.py+52
src/shared/audit/emitter.py ★+144 −63
src/shared/audit/registry.py ★+190
src/shared/audit/snapshots.py ★+134
tests/integration/test_app_firm_audit.py+9 −8
tests/integration/test_audit_log_schema.py+2 −2
tests/unit/test_audit_registry_emit.py+237
tests/unit/test_audit_single_writer.py+51