Status: 🟢 Live · 🟡 partial coverage — all four surfaces below are shipped and running in production. But "who did what, when" is spread across three overlapping in-hub history surfaces plus a separate control-plane log, none of which is complete on its own, and there is no retention policy anywhere. · Audience: GoBuild staff only (internal). Not customer-facing.
GoBuild has no single audit log. History is recorded four different ways, each built for a different job, with real overlaps and gaps between them. This page maps all four honestly so you know which one to trust for a given question.
| Surface | Scope | Written how | Captures | Badge |
|---|---|---|---|---|
| AuditEvent | Per-record forensic trail | Explicit — endpoints call audit.log |
That a record changed (created/updated/status/…), by whom | 🟡 |
| JobEvent | Per-Job office actions | Explicit — capacity planner only, today | Structured before/after of specific office moves | 🟡 |
| EntityRevision | Point-in-time content snapshots | Explicit — savables opt-in | The content of a state, so it can be restored | 🟢 |
| Activity feed | Per-Job / org timeline | Synthesized — read-only, derived at read time | A human timeline of what already happened | 🟢 |
| CP AuditLog | God-mode / cross-org ops | Explicit — control-plane app | Every operator action against a hub | 🟡 |
AuditEvent (app/models/audit.py:18) is the closest thing to a classic audit log. One row per meaningful action on one entity, written explicitly by write endpoints — nothing is captured automatically.
entity_type is the model name (e.g. "PurchaseOrder", "Submittal", "ScheduleItem") and entity_id its PK (audit.py:26-27). It also carries an optional job_id so a whole Job can be replayed (audit.py:22).action (created|updated|status_changed|deleted|released|approved|signed|received|…), a human summary, the actor (actor_user_id + actor_name, defaulting to "System" when null), and an optional structured detail JSON for before/after (audit.py:29-37).audit.for_entity (app/services/audit.py:57); audit.for_job gives a job-wide forensic feed capped at 200 rows (audit.py:72).Why 🟡 — coverage is only as good as the call sites. Every row exists because someone remembered to call audit.log / audit.record (app/services/audit.py:30, :47) in a write path. There is no model-level hook, so any endpoint that forgets to log simply produces no audit trail for that action. Worse, logging failures are swallowed on purpose — audit.log wraps the write in a bare except so a logging error never breaks the underlying action (audit.py:42-44). That is the right call for reliability, but it means a silently-dropped row leaves no error the user ever sees. Treat AuditEvent as best-effort, not guaranteed.
JobEvent (app/models/job_event.py:14) is a real, queryable record of office-side actions on a Job — explicitly contrasted in its own docstring against the synthesized activity feed (job_event.py:1-5).
job_id (FK, ON DELETE CASCADE), a kind (e.g. workload_rebalance), a summary, a detail JSON list of per-change rows, and a free-text actor (job_event.py:17-23).job_event.py:2-4). It is surfaced on the Job's Activity tab and never shown to the Client.Why 🟡 — narrow producer, overlaps AuditEvent. JobEvent and AuditEvent answer nearly the same question ("what office action happened on this Job, with before/after detail"). JobEvent stores richer per-change rows for the one flow it covers; AuditEvent is the general trail. There is no shared abstraction — two tables, two write paths, one conceptual job.
EntityRevision (app/models/entity_revision.py:19) is a different axis entirely. Where AuditEvent records that something changed, EntityRevision stores the actual content of a point-in-time state so it can be listed and restored — the spine of the unified save system (Phase 5) (entity_revision.py:1-9).
entity_type is a logical key (e.g. "org_settings", "proposal"), entity_id the PK, payload the JSON snapshot. An entity opts in by registering an apply-fn in app.services.savables — no per-entity tables (entity_revision.py:8-9, app/services/savables.py).kind distinguishes a committed version from an in-progress draft / autosave (entity_revision.py:25), with a seq counter, a label, and the same actor fields as AuditEvent.Why 🟢 — clean single-purpose design. It's the one surface with a clear contract (register a savable, get versioning/restore for free). Its overlap with AuditEvent is intentional and complementary: AuditEvent = the "what happened" log entry, EntityRevision = the restorable "what it looked like."
app/services/activity.py powers the Job "Activity" tab and the org dashboard "live feed." Crucially, it is not a table — it is synthesized at read time from records that already exist, so there's nothing new to keep in sync (activity.py:1-7).
job_activity (activity.py:22) fans out across a Job's daily logs, documents/photos, clock shifts, estimates, invoices, change orders, messages, field captures, client updates, schedule baselines — and JobEvent rows — then sorts newest-first and returns [{ts, icon, kind, text, who}] (activity.py:36-110).org_activity (activity.py:113) does the same org-wide for the dashboard, adding job names and photo thumbnails.Implications of "synthesized":
job_activity limit 50, org_activity limit 14) and not paginated — old activity beyond the cap is simply not shown."who": None (photos, estimates, invoices, change orders — activity.py:46-71) because the source rows don't carry an actor. The feed can tell you what happened but often not who did it.Note the layering: JobEvent rows appear both as their own queryable table and inside the synthesized feed (activity.py:104-107, :212-215). AuditEvent, by contrast, does not feed the activity timeline — it lives only on the per-record History panes. So the activity feed and the audit trail are two different windows onto the same Job, and neither is a superset of the other.
The Control Plane (ops.gobuild.ca) keeps its own append-only AuditLog (controlplane/models.py:96), completely separate from every hub's AuditEvent. It records every operator (god-mode) action taken against a client hub over the signed Admin API.
admin_email, action, target_instance, target_org, a detail JSON (org edits store {"before", "after"}), the operator ip, and created_at (models.py:99-108).user.create, user.update, user.reset_password, integration.set/clear, org.edit, org.features, org.purge, org.export, provision.create, admin.*, server.*, and impersonate.start (controlplane/main.py:424–:1284)./audit, capped at the 200 most recent rows (main.py:894); the dashboard shows the latest 7 (main.py:319).When an operator "Logs in as" a Client user, the CP writes an impersonate.start row with the target user and org (controlplane/main.py:741-744) and hands the browser off to the hub's /_impersonate/consume.
There is no matching end event — anywhere. Ending impersonation is /_impersonate/exit, which merely deletes the session cookie and redirects to login (app/routers/impersonation.py:54-59). It writes no hub AuditEvent and calls no CP endpoint. So the audit trail can tell you an operator entered a Client account, but not when (or whether) they left, nor for how long, nor what they did inside. This is tracked as known risk H5 — see Known Risks (also relevant: C1 password-only god-mode, C2 the CP audit log itself has no offsite backup, M7 CP DB may default to SQLite). Impersonation also happens with no Client consent and no customer notification.
None of the four surfaces has a retention, pruning, or rollover policy in code. There is no scheduled purge, no row cap on writes (only read-time LIMITs), and no TTL:
AuditEvent, JobEvent, EntityRevision, and the CP AuditLog grow unbounded. The only deletion path is a cascade — JobEvent is ON DELETE CASCADE from its Job (job_event.py:18), so deleting a Job also erases its office-action history.EntityRevision accumulates every version and every autosave snapshot indefinitely — no "keep last N" trim in savables.AuditLog — the authoritative record of god-mode actions — is on cp_data, which per Known Risks C2 is not backed up offsite, so a droplet loss takes the operator audit trail with it.| Question | Look at |
|---|---|
| Did this PO/submittal/RFI change, and who changed it? | AuditEvent History pane (audit.for_entity) — if the endpoint logged it |
| What office moves (reassign/reschedule) happened on this Job? | JobEvent (today: capacity-planner rebalances only) |
| What did this proposal / org-settings look like last Tuesday? Can I restore it? | EntityRevision |
| What's been going on with this Job / across the org lately? | Activity feed (job_activity / org_activity) — a view, capped, often no actor |
| Did a GoBuild operator touch this Client's data? | CP AuditLog at /audit — but only start-of-impersonation, no end |
Bottom line: for a full "who did what, when" reconstruction you often have to join across all four, and even then you may be missing the actor (activity feed), the action (any endpoint that skipped audit.log), or the end boundary (impersonation).