Corteksa

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:

QuestionAnswered 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:

  • MEXISTS (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, with ra.admin_id IN (<my group members>)
  • A → no filter · D1 = 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 writerRecordsPublicService — 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.

WriterHow the set stays correct
record createaddOnManager(..., 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-assignreplaceAssigneesAcrossRecords — the requested SET on every selected record (frontend contract)
lead-distributionreassignOwnerreplaceWithSoleAssignee (the single-owner spelling of the same write; returns the prior primary for the caller's event diff)
importseedPrimaryOnManager (batched, on the import runner)
record-movesyncPrimaryAssignee — seeds target from the source's primary, clears the orphaned source set
lead-ads auto-createseedPrimaryAssignee after insert
workflow __assigneesyncAssigneeMembership (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: &#123; previousOwnerId, newOwnerId &#125; 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:

  1. 1800000000033-CreateRecordAssigneesseedFromOwners copies each record's admin_id into the set; …034 marks the owner row primary + installs the partial-unique index. (These read the column — they run BEFORE the drop.)
  2. Deploy this code first. It reads ownership from the set and stops writing the column; the physical admin_id column goes stale but harmless (nothing reads it). Base SELECTs alias primaryOwnerExpr 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).
  3. 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). down re-adds and backfills the column from the set.
  4. Do not roll the code back after 035 runs — 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.

On this page