Skip to content

Foundation — Audit standards (proposal)

Status: proposal — surfaced from item-catalog UX exploration on 2026-05-21. Not yet ticketed in Linear.

Scope note: Foundation owns the cross-cutting plumbing that every feature module's audit display plugs into — the BE audit-event model, the FE <ActivityFeed> primitive, and the contract between them. This doc proposes both halves so they ship coherent and the BE/FE split is one Linear epic with two child tickets (BE + FE), not two disconnected backlogs.

The trigger for this proposal: the item-catalog detail page wants an Activity panel on the side rail, but today there is no audit event source — only the created_* / updated_* columns on each aggregate. The panel renders two derived rows and a "more arrives when audit log ships" note. This is the ticket that ships the log.

Authoritative landing spots once accepted: - BE: ../grinsystem-api/docs/architecture/conventions.md § Audit (new section) - FE: ../../standards.md § Audit Display (new section) - This doc moves to docs/modules/foundation/audit.md (canonical reference) once tickets land.


Problem

Every aggregate in every module carries created_by, created_at, updated_by, updated_at (the BE convention). That is enough to tell that something was last touched but not what was touched, by which transition, or how many times. The questions actual users will ask of GrinSystem:

  • "Who deactivated this item, and when?"
  • "Who moved this item out of the Mąki group last month?"
  • "Show me every edit to this recipe over the past quarter."
  • "Why was this production order cancelled?" (compliance / traceability adjacent)
  • "Did someone change the EAN on this product? When?"

None of those are answerable from updated_*. We need an append-only event log per aggregate plus a consistent FE display.

Non-goals

  • Full event sourcing. The audit log is derived from domain mutations, not the source of truth for the aggregate. Aggregates remain CRUD-shaped on the BE.
  • Field-level rollback / time-travel UI. Logging diffs is in scope; restoring from a diff is not.
  • Real-time push to the FE. Polling-on-load is fine for v1. (Server-Sent Events / websockets can come later.)
  • Audit for reads. We log writes only.
  • GDPR right-to-be-forgotten erasure. Anonymisation of actor_id is a follow-up.

BE responsibilities

What gets logged

For each non-GET BE operation on a domain aggregate, exactly one audit event row is appended. Operations that touch multiple aggregates emit one event per aggregate touched.

Event categories (vocabulary fixed up front so FE rendering is deterministic):

Category When Examples
created First persisted state Item created, ItemGroup created, ProductionOrder drafted
updated Mutable fields changed Item name / tags / shelf_life updated
deactivated Soft-delete / archive transition Item deactivated, Recipe archived
reactivated Inverse of deactivated Item reactivated
relationship_added Aggregate gained a child / membership Item assigned to group
relationship_removed Aggregate lost a child / membership Item removed from group
imported Bulk creation from import Item rows from Excel import (1 event per row)
status_changed Workflow transition ProductionOrder: draft → planned → in-progress

Modules can extend the vocabulary, but only via this list. No free-form action strings.

Trigger points

Audit events are emitted by the BE application service layer, not the controller and not the domain entity. Why this layer:

  • Controller layer is too thin — repeated boilerplate per endpoint, easy to forget.
  • Domain layer is too pure — domain entities should not know about audit logging.
  • Application services already know the actor (from auth context), the operation intent, and the resulting aggregate state.

Implementation: a record_audit_event(...) helper that every command handler calls right after a successful aggregate save and before the transaction commits. Same SQLAlchemy session — audit row commits atomically with the aggregate change.

DTO contract

Single canonical event shape:

class AuditEvent(BaseModel):
    audit_event_id: UUID
    occurred_at: datetime  # UTC, microsecond precision
    actor_id: UUID  # FK to users
    actor_display_name: str  # denormalised at write time so deletes don't break history
    actor_role: str  # denormalised, e.g. "admin", "technologist"

    module: str  # "item_catalog", "manufacturing", ...
    aggregate_type: str  # "item", "item_group", "production_order"
    aggregate_id: UUID
    aggregate_display_label: str  # denormalised — e.g. "MAK-650 Mąka pszenna typ 650"

    category: AuditCategory  # the enum from the table above
    summary_key: str  # i18n key for the FE, e.g. "audit.item.deactivated"
    summary_params: dict[str, str]  # interpolation values, e.g. {"reason": "..."}

    # Optional — only set for updated / status_changed
    diff: list[FieldDiff] | None
    # Optional — set when one logical operation touches multiple aggregates;
    # binds them together for the FE
    correlation_id: UUID | None

class FieldDiff(BaseModel):
    field: str  # dot-path, e.g. "name", "group.id"
    before: Any | None  # JSON-serialisable
    after: Any | None

Key design points:

  1. Denormalise actor + aggregate display labels at write time. If a user is later deleted or renamed, the audit history still reads sensibly. Same for an aggregate that gets renamed.
  2. i18n keys, not pre-translated strings. BE stores summary_key + summary_params; FE owns translation. Lets a Polish session and an English session see the same event log in their own language.
  3. diff is optional and only populated when meaningful. A created event doesn't need a diff (the aggregate IS the diff vs nothing). An updated event sets diff to the changed fields only — not the full aggregate snapshot.
  4. correlation_id ties multi-aggregate operations together. Example: Excel import of 200 items → 200 imported events all sharing one correlation_id. FE collapses them visually.

Storage

One table per tenant database (or one shared with tenant_id discriminator — match the existing BE multitenancy convention).

CREATE TABLE audit_events (
  audit_event_id UUID PRIMARY KEY,
  occurred_at TIMESTAMPTZ NOT NULL,
  actor_id UUID NOT NULL,
  actor_display_name TEXT NOT NULL,
  actor_role TEXT NOT NULL,
  module TEXT NOT NULL,
  aggregate_type TEXT NOT NULL,
  aggregate_id UUID NOT NULL,
  aggregate_display_label TEXT NOT NULL,
  category TEXT NOT NULL,
  summary_key TEXT NOT NULL,
  summary_params JSONB NOT NULL DEFAULT '{}',
  diff JSONB,
  correlation_id UUID
);

CREATE INDEX audit_events_aggregate_idx
  ON audit_events (aggregate_type, aggregate_id, occurred_at DESC);
CREATE INDEX audit_events_actor_idx
  ON audit_events (actor_id, occurred_at DESC);
CREATE INDEX audit_events_correlation_idx
  ON audit_events (correlation_id)
  WHERE correlation_id IS NOT NULL;

Append-only. No UPDATE, no DELETE. A separate retention job (out of scope here) archives rows older than N years.

Query endpoint

GET /api/v1/audit/events

Mirrors the existing list-endpoint conventions (docs/architecture/conventions.md § List Endpoints): - Pagination: ?page=1&page_size=25, snapshot pinning - Filters: ?aggregate_type=item&aggregate_id={uuid} for the detail-page case, ?actor_id={uuid} for "what did this user do", ?module=item_catalog for module-wide audit, ?category=deactivated for narrowed queries, ?from={iso}&to={iso} for date ranges - Sorting: occurred_at desc by default; no other sorts needed in v1 - Response: standard PaginatedResponse[AuditEvent]

Authorisation: a Permission.AUDIT_READ (subject to BE's permission model) gates the endpoint; for detail-page consumption a narrower permission Permission.AUDIT_READ_OWN_AGGREGATE lets non-admins see audit on aggregates they can already read.

Kafka

Out of scope for v1. If the BE later builds analytics consumers, emit the same AuditEvent shape onto a audit-events topic. No frontend impact.

What this means concretely for item_catalog (the first consumer)

The item_catalog module's command handlers each call record_audit_event(...):

Command Category Notes
create_item created No diff
edit_item updated Diff = changed fields only
deactivate_item deactivated No diff, summary_params: {"reason": "..."} if user supplied one
reactivate_item reactivated No diff
assign_item_to_group relationship_added summary_params: {"group_name": "Mąki"}
remove_item_from_group relationship_removed summary_params: {"group_name": "Mąki"}
import_items (per row) imported All rows share one correlation_id
create_item_group created aggregate_type = "item_group"
edit_item_group updated Diff
delete_item_group category extension needed — deleted Add to the enum

(The deleted category was missing from the initial vocabulary — flag for review during ticketing.)

FE responsibilities

New display primitive: <ActivityFeed>

Proposed home: src/components/display/ActivityFeed/

Props:

interface ActivityFeedProps {
  /**
   * Bound to one aggregate. The FE issues GET /audit/events?aggregate_type=...&aggregate_id=...
   * and renders the result. Pagination + "load more" handled internally.
   */
  aggregateType: string
  aggregateId: string
  /**
   * Page size. Defaults to 20 — small enough to feel snappy on a side rail,
   * large enough to cover typical activity bursts (e.g. an import + several edits).
   */
  pageSize?: number
  /**
   * Optional category filter — narrows what's rendered. Useful on detail
   * pages that want to suppress noise (e.g. "I only care about status
   * changes on this production order").
   */
  filterCategories?: AuditCategory[]
  /**
   * Optional empty-state override. Default: <EmptyState icon={ActivityIcon} title={t('audit.empty')} />.
   */
  emptyState?: ReactNode
}

Internal: - Uses the Orval-generated useListAuditEvents hook (no bespoke wrapper). - isLoading → skeleton list (3 rows). isFetching → subtle overlay. - Each event renders via <ActivityRow> (one component, branches on category for icon + accent colour). - Translates summary_key + summary_params via t(key, params). - Renders diff (when present) as a click-to-expand "details" section showing before → after per field. - Correlated events (same correlation_id) collapse into a single row with a "see all N events" toggle that expands inline.

Detail-page integration pattern

Every aggregate's detail page mounts <ActivityFeed> on the right rail (or below the main content on narrow detail pages). One line of code per page:

<ActivityFeed aggregateType="item" aggregateId={item.item_id} />

The component owns its data fetching, skeleton, empty state, and pagination. Pages do nothing beyond placement.

Pages keep the existing Audit info-card (created_by / created_at / updated_by / updated_at) — it answers who last touched this at a glance without scrolling through events.

Visual treatment

  • Timeline pattern: small bullet (bg-accent) on a vertical hairline (border-l border-border).
  • One row = title (translated summary), eyebrow (relative time, 2 hours ago) + tooltip (absolute time), actor name, optional category badge.
  • Diff details collapse closed by default. When open: <DiffRow before={...} after={...} /> per field.
  • Tokens only — bg-bg-card, text-fg, text-fg-muted, border-border, bg-accent per design-system rules.

Modelled on the prototype 03-production-order-detail.html info-row aesthetic but vertical (one row per event) rather than two-column.

i18n keys

All summary_key values live under the consuming module's i18n namespace, prefixed audit.. Keys are owned by the module that emits the event.

// src/locales/en/item-catalog.json
{
  "audit": {
    "item": {
      "created": "Item created",
      "updated": "Item edited",
      "deactivated": "Item deactivated{reason, select, undefined {} other { — {reason}}}",
      "reactivated": "Item reactivated",
      "groupAdded": "Moved to group {groupName}",
      "groupRemoved": "Removed from group {groupName}",
      "imported": "Imported from spreadsheet"
    }
  }
}

A common.audit.unknownEvent fallback ("Unknown event ({key})") covers BE/FE version skew.

Permissions

<ActivityFeed> is wrapped with <PermissionGate permission={Permission.AUDIT_READ_OWN_AGGREGATE} hideWhenDenied> so non-permitted users simply don't see the panel rather than seeing an error.

Acceptance criteria

BE:

  • [ ] audit_events table migration lands, with indexes above.
  • [ ] AuditCategory enum (incl. deleted) and AuditEvent DTO in shared schemas.
  • [ ] record_audit_event(...) helper in the application-services layer; documented in conventions.md § Audit.
  • [ ] item_catalog module wires every command handler from the table above; one event per command, atomic with the aggregate save.
  • [ ] GET /api/v1/audit/events endpoint with filters + pagination; mirrors list-endpoint conventions.
  • [ ] Permission gates: AUDIT_READ, AUDIT_READ_OWN_AGGREGATE.
  • [ ] OpenAPI regenerated; FE Orval picks up useListAuditEvents.
  • [ ] BE unit tests: each command emits exactly one event with the correct category and diff.
  • [ ] BE integration test: an aborted transaction does NOT leave an audit row.

FE:

  • [ ] <ActivityFeed> primitive at src/components/display/ActivityFeed/.
  • [ ] <ActivityRow> renders all category icons + colours.
  • [ ] Translation of summary_key + summary_params works for at least one Polish + one English event.
  • [ ] Diff expand/collapse renders before → after per field.
  • [ ] Correlated events collapse into a single row with expansion.
  • [ ] Permission-gated via <PermissionGate hideWhenDenied>.
  • [ ] docs/standards.md § Audit Display section written.
  • [ ] item-catalog detail page (item + item_group) drops the placeholder Activity panel and mounts <ActivityFeed>; the existing Audit info-card stays.

Dependencies + sequencing

The BE side has no FE dependencies. The FE side cannot ship until OpenAPI exposes the audit endpoint and the Orval regeneration includes useListAuditEvents. Recommended sequencing as two child tickets under one foundation epic:

  1. BE-AUDIT-1 (BE-only) — schema + DTO + helper + endpoint + item_catalog wiring. Self-contained; ships independently.
  2. FE-AUDIT-1 (FE-only)<ActivityFeed> + standards doc + item-catalog detail-page integration. Blocked on BE-AUDIT-1 OpenAPI publish.

The BE ticket can in principle be parallelised across modules (manufacturing, warehouse, etc.) once foundation lands. v1 only requires item_catalog wired through.

Open questions

  • Retention policy. What's "long enough" for a small bakery / food production company? GDPR considerations for actor anonymisation? — punt to a follow-up legal/compliance ticket.
  • before / after JSON serialisation of complex value-objects (e.g. tags is a list, group is a nested object). The current FieldDiff.before/after: Any may need stricter typing per aggregate.
  • Should correlation_id be exposed in the API response or kept BE-internal? Current proposal: expose it so the FE can group; revisit if it leaks anything sensitive.
  • Multi-tenant isolation: confirm the existing tenant scoping (likely a tenant_id column on every table) extends to audit_events and the /audit/events endpoint enforces it. Out of scope to design, in scope to validate during BE-AUDIT-1.

Sizing hint for the planner

This is one foundation epic with two child tickets. Rough effort budget (not authoritative — re-estimate in Linear):

  • BE-AUDIT-1: medium — table + DTO + helper + endpoint + module-1 wiring. ~3–4 days of focused work.
  • FE-AUDIT-1: medium — primitive + i18n plumbing + integration in 2 detail pages + standards doc. ~3 days.

Total: roughly one sprint if sequenced. Less if BE-AUDIT-1 starts while the rest of item-catalog FE is still in flight.