Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesAudit LoggingAudit Logging (org-scoped)

Audit Logging (org-scoped)

Linear: FIT-20 Status: Shipped. Capture is always-on, and so is the owner-facing viewer — the audit-logging PostHog flag that used to gate the read surface was merged permanently ON and deleted on 2026-08-22. Last reviewed: 2026-08-22

What

An append-only trail of every consequential change inside one gym: who did it, what changed, to which record, and from where. Written by a single service, read by the gym owner through a filterable table with CSV export, and aged out to R2 after 24 months.

The trail spans six categories:

CategoryCovers
authSign-in, sign-out, account changes made in Clerk — staff only (owner/admin/coach); members’ own sessions are never recorded
membershipRoster: activations, role changes, status changes, removals, invitation revocations
billingSubscription lifecycle: created, cancelled, paused, resumed, renewed, plan changes
paymentMoney movement + the plan catalog + payment methods + provider config
settingsOrganization configuration
operationsDestructive staff actions on operating data: sessions, bookings, workouts, programs, templates, class types, forms, leads, announcements

Why

Three separate pressures converge on the same table:

  1. Disputes. “I cancelled last month” / “I never changed her price” are the two most common member-versus-owner arguments in a gym, and neither is settleable today. The subscription row shows the current state, never who moved it.
  2. Staff accountability. An owner with admins and coaches has no way to answer “who archived that plan?” A gym is a small business where the owner’s trust in staff is the operating model, and a trail is what makes delegation safe.
  3. Compliance. Israeli privacy law and the GDPR both expect a record of who accessed and changed personal data. The lead-consent ledger (ADR-0013) already established the append-only pattern for one narrow slice; this generalizes it.

Who (personas)

PersonaSurfaceCapabilities
Org owner/dashboard/audit-log (web)Read the whole org trail, filter, expand before/after diffs, export CSV
Admin / coach / membernoneNo access. audit is none for every non-owner role in both permission matrices
Taikan staffaudit_logs (the separate platform table)Unaffected — see “Two audit tables” below

Two audit tables, on purpose

There were already audit_logs rows before this feature. They are not the same thing and were deliberately not merged:

audit_logs (schema/admin.ts)audit_events (schema/audit-events.ts)
Whose actionsTaikan platform staffGym staff and members
ScopeCross-org, no organization_idOrg-scoped, indexed on it
Who reads itThe Taikan admin appThe gym owner
Actor idClerk id (actor_clerk_id)users.id FK, nullable for system rows

Member erasure writes to both: the platform ledger keeps the operational record, the org trail keeps the owner’s record.

Capabilities (current)

  • Always-on capture. AuditService.record() is the only writer, and no feature flag guards it — which is why the viewer, unflagged since 2026-08-22, shows the full history rather than starting the trail from the rollout.
  • Every charge attempt, from every path, exactly once. Nine code paths move money; all nine resolve through PaymentTransactionService, which is where payment.charge_succeeded / payment.charge_failed are emitted. Declines are recorded too — they answer the owner’s “why wasn’t this member billed” and a run of them is a reg. 11 signal. Failures carry a bounded errorClass, never the gateway’s own text.
  • Every refund, from every path. Automatic, manual (both halves), provider-portal and platform-admin refunds all write payment.refund_issued, distinguished by capability and metadata.stage.
  • Staff destruction is on the record. Cancelling and deleting sessions, cancelling someone else’s booking, and deleting workouts, programs, templates, class types, forms, leads and announcements all land in operations. Member self-service does not.
  • Bulk destruction is one row, not N. A 300-session sweep or a 100-id lead erase writes a single row carrying the count, the ids and the filter.
  • Never breaks a request. record() swallows its own failures into Sentry plus a warn. A member’s role change succeeding while the audit write 500s would be strictly worse than a gap in the log.
  • Actor resolution without plumbing. A native AsyncLocalStorage request context (no nestjs-cls dependency) carries IP, user-agent, request id and — once AuthGuard has verified the token — the actor. Service code deep in the call stack records without any method growing an actor parameter.
  • Honest system attribution. No request context, or a context with no authenticated actor, yields actor_type = 'system'. Crons, provider webhooks and queue workers land there rather than being credited to whoever happened to trigger them.
  • Before/after diffs. Every instrumented site that already holds the prior row records only the fields that actually moved. A plan edit that leaves the price alone does not read like a repricing.
  • Owner-only read. requireMembershipPermissionsService.enforce(orgId, role, 'audit', 'view'). Unconditional for every org since the audit-logging flag was merged ON (2026-08-22).
  • CSV export, same filters as the list, capped at 50,000 rows, with spreadsheet-formula cells neutralized so a member-chosen name can’t execute when the owner opens the file.
  • 24-month retention with archive-before-delete to the R2 compliance bucket as NDJSON, one object per (org, month).
  • The trail audits itself. Every export writes audit.exported; list access writes audit.viewed (coalesced per reader per 15 minutes); a refused request writes audit.access_denied.
  • No secrets, by construction. Credentials, ciphertext, card tokens and join tokens never reach a row. The one free-form bag the API accepts — config on a payment provider — is redacted key-by-key: names kept, values masked.

Capabilities (gaps + tracking)

GapNotes
No archive restore pathRows older than 24 months live in R2 as NDJSON with no UI or endpoint to read them back
The R2 archive has no expiryArchived rows are retained indefinitely. Under-retention is fixed; over-retention is not. Wants a bucket lifecycle rule — see § Compliance
Read access to member data generally is not loggedOnly mutations, auth events, and access to the audit log itself. See § Compliance for why this is the biggest open question
Members’ own sign-ins are not recordedDeliberate: the auth category tracks staff access, and member sessions would dominate the trail by volume while persisting members’ IPs for the owner to read. If a member’s account takeover ever needs reconstructing, the trail won’t have it
Denied access is recorded only for members of the orgA non-member probing /organizations/<any-id>/audit-events leaves no trace, deliberately — see § Compliance
Nobody reviews the trailNo periodic review, no alerting on audit.access_denied. Reg. 10(c) expects a documented review procedure
AuditService is injected @Optional() everywhereA wiring mistake stops capture silently rather than failing to boot. Mitigated by audit.module.unit.spec.ts (composition + boot guard); see the deviation note in behavior.md
Bulk membership operations record one row per entityA 200-member bulk status change writes 200 rows. The operations bulk deletes (sessions, leads) use the one-row-per-batch shape instead; membership has not been converted
operations is curated, not exhaustive~30 other staff-facing DELETE endpoints (courses, exercises, locations, automations, tasks, body metrics, pipeline stages, minisite events, chat messages, …) are not instrumented. Each is either low-consequence, low-volume, or trivially re-creatable; the line was drawn at “irreversible loss of something the gym operates on”
Read access to a charge failure’s provider text is not in the trailBy design: errorClass only. The verbatim message is on payment_transactions.error_message for support
Member self-service actions are attributed but not visually distinguished in the viewerThe row says who; the UI doesn’t badge “member did this to themselves”
No per-entity drill-down UIThe (org, target_type, target_id) index exists and the API filters on it, but nothing links a member/subscription page to its history

Code map

ConcernPath
Schemalibs/db/src/lib/schema/audit-events.ts, enums in libs/db/src/lib/schema/enums.ts
Migrationslibs/db/drizzle/0102_glossy_domino.sql (table + both enums)
Request context (ALS + middleware)apps/api/src/common/request-context.ts
Actor stampingapps/api/src/auth/auth.guard.ts (attributeRequestContext)
Write pathapps/api/src/audit/audit.service.ts
Action catalogapps/api/src/audit/audit-actions.ts
Domain-event bridgeapps/api/src/audit/audit-events.listener.ts
Clerk auth fan-outapps/api/src/audit/audit-auth.service.ts, apps/api/src/webhooks/clerk-webhook.controller.ts
Read pathapps/api/src/audit/audit-query.service.ts, audit.controller.ts
Self-auditing (viewed / exported / denied)apps/api/src/audit/audit-access.service.ts
Metadata redactionapps/api/src/audit/redact.ts
Charge choke pointapps/api/src/payments/services/payment-transaction.service.ts
Charge failure classificationapps/api/src/payments/charge-error-class.ts
Refund sitesapps/api/src/payments/services/payment.service.ts, webhook-processing.service.ts, apps/api/src/admin/services/admin-actions.service.ts
operations sitesclass-sessions/, bookings/, workouts/, programs/, program-templates/, class-types/, forms/, organization-leads/, announcements/ services
Retention cronapps/api/src/audit/audit-retention.service.ts
Module composition + boot guardapps/api/src/audit/audit.module.unit.spec.ts
Permissionslibs/shared/src/lib/permissions/matrix.ts (audit module)
Web viewerapps/web/src/app/[lang]/(protected)/dashboard/audit-log/
Sidebar entryapps/web/src/components/overview/sidebar-nav.tsx
Copylibs/shared/src/lib/i18n/dictionaries/{en,he,ru}.jsonauditLog, nav.auditLog

Compliance

This is engineering’s mapping, not legal advice. It records how the implementation lines up with the duties we believe apply, so that counsel can confirm or correct it. Every item under “Requires counsel confirmation” is an open question, not a settled position. Nobody on this side is a lawyer.

Which duties this feature is built against

Privacy Protection Regulations (Data Security) 5777-2017 — the operative Israeli instrument. Two regulations matter here, and they are easy to transpose:

  • Reg. 10 — תיעוד גישה (access recording). Requires an automatic mechanism recording each access to the database, with a defined field set; retention of those records for at least 24 months; protection of the records against tampering; and a documented procedure for periodic review. Applies at the medium and high security levels.
  • Reg. 11 — תיעוד אירועי אבטחה (security-event documentation). Requires documenting security incidents, discussing them, and (at the high level) notifying the Privacy Protection Authority.

Privacy Protection Law Amendment 13 (in force 2025-08-14) did not rewrite these duties; it changed the consequences — administrative fines, an expanded Authority, a DPO (ממונה על הגנת הפרטיות) requirement for some controllers, and breach-notification obligations. Its practical effect on this feature is that reg. 10 is now worth implementing properly rather than approximately.

Which security level applies to Taikan is the first question for counsel. A gym SaaS holds names, phones, emails and payment records for many data subjects; where an org collects health or medical information, that is מידע רגיש and pushes toward the high level. We have built to the medium/high bar on the assumption that at least some orgs land there. If Taikan were only ever at the basic level, reg. 10 would not bite at all.

Industry frameworks pointing the same way, used as the cross-check for the design: ISO/IEC 27001:2022 A.8.15 (logging: what to log, protect logs against tampering and unauthorized access) and A.8.17 (clock synchronization); SOC 2 CC6.1 / CC7.2; NIST SP 800-53 AU-2 / AU-3 / AU-9 / AU-11; OWASP Logging Cheat Sheet and ASVS V7 for what must never be logged.

Field mapping — reg. 10 required field → our column

Reg. 10 field (Hebrew)RenderingOur columnNotes
זהות המשתמשIdentity of the useractor_user_id (+ actor_type)FK to users, no cascade. system rows carry NULL and say so rather than guessing
תאריך ושעהDate and time of the accesscreated_attimestamptz, server-side defaultNow(). Never client-supplied
רכיב המערכת שאליו בוצעה הגישהThe database component accessedtarget_type + target_idPolymorphic pointer at the record that changed
סוג הגישהType of accessaction (+ category)Dot-namespaced <entity>.<verb>
היקף הגישהScope / extent of accessmetadata.before / .after, and metadata.filters on audit.viewed / audit.exportedFor a mutation the scope is the diff; for a read of the trail it is the filter set and row count
אם הגישה אושרה או נדחתהWhether access was permitted or deniedaction = audit.access_denied + metadata.reason / metadata.outcomeOnly for the audit endpoints. See the gap below

Beyond the required set we also keep ip_address, user_agent and request_id — the last correlating a row with the pino request log line. NIST AU-3’s “where / source of the event” is covered by those.

Retention design vs the 24-month duty

24 months is a floor, so the boundary is deliberately conservative: the sweep uses a strict lt(created_at, cutoff), meaning a row exactly on the boundary survives, and computeRetentionCutoff is exported so that arithmetic is unit-tested rather than asserted in a comment. Beyond 24 months rows are archived to the R2 compliance bucket before deletion — upload first, delete only that bucket’s exact ids, and a failed upload aborts the delete. Records therefore remain available past the floor rather than being destroyed at it.

The opposite risk is now the live one: the archive has no expiry, so archived rows persist indefinitely. Under GDPR storage limitation that is its own problem, and it is on the counsel list below.

Erasure interplay — the lawful-basis position

Member erasure (MemberErasureService.eraseMember) does not delete audit_events rows, and this is deliberate:

  • Audit rows survive erasure intact. actor_user_id is a plain FK with no cascade, and users are soft-deleted and scrubbed in place rather than hard-deleted — the row stays, the PII in it is replaced (email becomes a deleted+<uuid>@deleted.taikan.fit tombstone, names null). So the trail keeps referential integrity while the identity behind it is anonymized at source, which is close to the best available outcome: the record of what was done survives, the identifiability does not.
  • The erasure event itself writes an audit row carrying no PII — erasure is the one action whose own audit record must not resurrect what it erased.

The position we believe applies, for counsel to confirm: retention of security/audit records against an erasure request rests on legal obligation (reg. 10 mandates the records and sets a 24-month floor — GDPR Art. 17(3)(b) is the analogous carve-out) and, secondarily, on legitimate interest in security documentation and in establishing or defending legal claims (Art. 17(3)(e)). In plain terms: a member cannot erase the evidence that their subscription was cancelled, because keeping that evidence is what the regulator requires of the gym.

Known gaps / requires counsel confirmation

#GapOur readingWhat we’d need confirmed
1Read access to member data is not logged. We record mutations, auth events, and access to the audit log — not “coach opened a member’s profile”Reg. 10 says “access”, not “modification”, so a literal reading demands read logging for medium/high databases. We did not implement it: instrumenting every read is invasive, high-volume, and a much larger change than this PRWhether reg. 10 requires read-access logging for our security level, and if so whether it can be scoped to sensitive fields rather than all reads
2Denied access is recorded only for verified members of the orgA non-member hitting /organizations/<victim-org>/audit-events leaves no trace. Recording it would let any authenticated user write unbounded rows into any gym’s trail by looping over org ids — an injection/exhaustion vector against the table it protects. Global denied-access capture belongs in a guard, not hereWhether the recording duty extends to attempts by people with no relationship to the database, and if so what rate-limited shape satisfies it
3audit.viewed is coalesced to one row per reader per 15 minutesA burst of filter/pagination/search requests is one act of looking; recording each would make the trail mostly a record of itself. The window is one constant from “record everything”Whether per-access granularity is required, or whether a documented session-coalescing rule is acceptable
4”Append-only” is a code property, not a database oneNo code path updates or deletes a row except the retention cron. But the app’s DB role holds DELETE because that cron runs in-process, so a bug or an attacker with app-level access could delete. Reg. 10 and ISO A.8.15 both expect tamper protectionWhether app-level append-only suffices, or whether we need a separate restricted role / WORM archival / hash-chaining
5Nobody reviews the trail, and nothing alertsReg. 10(c) expects a documented periodic review procedure; SOC 2 CC7.2 and ISO A.8.16 expect monitoring. audit.access_denied is exactly the row that should page someone and currently does notWhat review cadence and documentation the Authority expects for an org of this size
6The R2 archive has no expiryRows are retained indefinitely once archived. Fine for the 24-month floor, questionable under storage limitationThe maximum retention period, so we can set an R2 lifecycle rule
7Free-text fields are recorded verbatimCancellation reasons and staff resolver notes land in metadata. They are operationally the point of the record, but a staffer can type anything into them, including special-category dataWhether these need minimization, or whether the existing “no description, no providerResponse” line is drawn correctly
8Capture completeness depends on instrumentationApp-level capture (ADR-0018) means anything bypassing an instrumented service — a manual psql, a migration, a future uninstrumented service — leaves no traceWhether the duty implies a completeness guarantee that only DB-level capture provides
9Auth events depend on a Clerk webhookauth.login / auth.logout arrive via session.* webhooks. A dropped webhook is a silently missing sign-in recordWhether sign-in recording needs a delivery guarantee
10Which security level appliesWe built to the medium/high bar on the assumption some orgs hold health dataThe actual classification, per org type — it determines whether any of this is mandatory or merely good practice
  • behavior.md — invariants, actor resolution, the full capture catalog
  • data-model.md — table, enums, indexes
  • qa-plan.md — what to exercise before rollout
  • ADR-0018 — why app-level capture, and the four other structural calls
  • ADR-0004 — why every query is org-scoped in code
  • ADR-0007 — why the archive goes to the compliance bucket
  • ADR-0013 — the append-only ledger precedent