Record ownership model
Record ownership model
Status: backend implemented, uncommitted. Internal normalization — the
per-object-table admin_id column is dropped; ownership lives only in
record_assignees. One breaking API change: the singular assignee key is
removed from record responses; ownership on the wire is assignees[]
(slug-first, primary first, [] when unassigned). Clients must react — see "What changed for readers" below for the full
wire contract.
The model
A record's ownership lives in the record_assignees membership set. Two
questions, two mechanisms:
| Question | Answered by |
|---|---|
| Who can see this record? (M/G visibility) | the whole set — any assignee sees it |
Who owns it? (the singular owner — list assignee, kanban lane, notification target, audit) | the one is_primary = true member |
is_primary is the successor to the dropped admin_id column: exactly one
primary per record, enforced by the partial-unique index
UQ_record_assignee_primary (object_id, record_id) WHERE is_primary.
Who becomes primary — one rule, one statement. promotePrimaryIfNone is the
only place that answers it, and every set-write (PATCH …/assignees and
bulk-assign) calls it: a record that still has its owner keeps them; a record
left ownerless gets the caller's first-listed admin, falling back to the
longest-standing member. Set-writes therefore delete only the members leaving —
sparing a survivor's row is what preserves is_primary and added_at. A write
that wiped the set first would silently reassign every record it touched.
The singular owner is derived in SQL by one shared expression,
ObjectAccessLevelService.primaryOwnerExpr(objectId, alias) — a scalar subquery
that returns the is_primary member. Every read-path SELECT exposes it as
… , <primaryOwnerExpr> AS admin_id, so the in-memory row.admin_id reads it
feeds (response mapping, admin enrichment, kanban, export, move) keep working
unchanged against the derived value.
The mutation-side finder (DynamicRecordRepository.findBySlug/findBySlugs,
plain SELECT t.*) does NOT alias it — it has a table name, not an object id.
Write paths must therefore read ownership from record_assignees
(listByRecords / listAdminIds), never from record.admin_id, which is
undefined there. bulkAssign does this for its event diff; the single-record
update's assignee delta (AssigneeResolverService.resolveOld) still reads the
absent field, so its old-side name is empty — open, tracked, harmless to the
write itself.
Visibility (M/G) — set-only
ObjectAccessLevelService.clauseForLevel is the ONE place the predicate lives:
- M →
EXISTS (SELECT 1 FROM record_assignees ra WHERE ra.object_id = <id> AND ra.record_id = t.id AND ra.admin_id = $me) - G → same
EXISTS, withra.admin_id IN (<my group members>) - A → no filter · D →
1 = 0 - No object context (can't scope the set) → deny (
1 = 0), never leak.
Assign a record to N admins → all N match the EXISTS → all N see it under M.
Backed by IDX_record_assignees_member (admin_id, object_id, record_id);
primaryOwnerExpr is backed by the partial-unique primary index. AccessFilterBuilder
and the Smart-Catalog picker delegate here, so the boundary can't drift.
The invariant: every ownership write seeds/syncs the primary membership
There is one sanctioned cross-module ownership writer — RecordsPublicService —
and every path that sets a record's owner routes its membership write through it
(or, inside the records module, through RecordAssigneesRepository directly).
No writer touches an admin_id column any more — there is none.
| Writer | How the set stays correct |
|---|---|
| record create | addOnManager(..., isPrimary: true) inside the insert tx |
| record update (owner change) | setSolePrimaryOnManager inside the update tx |
| bulk update (owner change) | setSolePrimaryOnManager per record inside the batch tx |
| bulk-assign | replaceAssigneesAcrossRecords — the requested SET on every selected record (frontend contract) |
| lead-distribution | reassignOwner → replaceWithSoleAssignee (the single-owner spelling of the same write; returns the prior primary for the caller's event diff) |
| import | seedPrimaryOnManager (batched, on the import runner) |
| record-move | syncPrimaryAssignee — seeds target from the source's primary, clears the orphaned source set |
| lead-ads auto-create | seedPrimaryAssignee after insert |
workflow __assignee | syncAssigneeMembership (raw column write removed — it previously threw column "admin_id" does not exist) |
Cross-module seeds are best-effort (logged, not fatal): the record is already committed, so a seed miss is recoverable by backfill rather than failing the write.
Ownership-change event signal (best-practice)
Ownership changes emit a first-class typed assignment: { previousOwnerId, newOwnerId } on DataEventPayload (stamped by record-update and bulk-assign).
The audit changelog listener reads THIS to detect an assignee change instead of
sniffing a synthesized admin_id field-diff. Resolved display names still travel
in oldAssigneeName/newAssigneeName. The legacy admin_id mirror in the event
payload is retained only for the notification's persisted-changelog read.
What changed for readers — one key, assignees[]
The storage normalization itself was invisible to clients: M/G returns the
same rows to a sole owner as before, and additionally to co-assignees.
The response shape did change, once, and deliberately. Shipping the set as
assignees[] next to the singular assignee left two keys for one concept and
every client guessing which one won, so the singular key was removed.
RecordResponseDto now emits assignees[] only — primary owner first
(ORDER BY is_primary DESC in listByRecords), [] when unassigned, in the
same key position the singular one occupied. The client contract is "What changed for readers" below.
One DTO rule carries this: the response emits data.assignees when the read path
hydrated the membership set, and [data.admin] — the primary owner, derived from
the same record_assignees rows via primaryOwnerExpr — when it did not. Kanban
cards, the task agenda, and the chat-profile read resolve only the owner, so they
contribute that owner alone: a subset of the set, never a different actor. The
single-record update path enriches the full set explicitly, because a PUT
response the FE patches a row from must not silently drop co-assignees.
The event payload (oldData/newData) is a change diff, not a record — its
admin_id + assignee pair is the internal audit/webhook contract and is
unchanged.
Deploy requirement — staged, backfill-gated (expand → migrate reads → contract)
The column drop is the contract step of a parallel change. Order matters:
1800000000033-CreateRecordAssignees—seedFromOwnerscopies each record'sadmin_idinto the set;…034marks the owner row primary + installs the partial-unique index. (These read the column — they run BEFORE the drop.)- Deploy this code first. It reads ownership from the set and stops writing
the column; the physical
admin_idcolumn goes stale but harmless (nothing reads it). Base SELECTs aliasprimaryOwnerExpr AS admin_id, appended LAST — during this bake window both the stale column and the alias are present and Postgres keeps the last (the set value wins on freshly-written rows). - Then run
1800000000035-DropAdminIdFromObjectTables(main +run-tenants) to drop the column and its indexes per object table (per-table try/catch skips a broken tenant table).downre-adds and backfills the column from the set. - Do not roll the code back after
035runs — old code references a dropped column and would 500. Standard migration discipline.
New object tables are provisioned WITHOUT the column (schema-builder, schema-generator, tenant-schema-initialization, data-migration rebuild), so they match the dropped legacy tables from day one.
Verification is Docker-gated: migrations and the integration/e2e suites need
DB + Redis (docker compose exec app npm run migration:run + the records e2e).
Host unit suites (green here) cover the SQL-shape and writer/event logic; the
row-level RLS + real-drop behavior must be verified on Docker before merge.