Skip to content

Employees are replaceable, dealership data is not ​

⚠ SUPERSEDED IN PART — the rules moved to a platform page on 2026-08-23

📘 The owner extended the requirement the same day: "this needs to work consistently across all QR Setu customer types, not just dealerships." So rules R1-R8 below are superseded by L1-L14 in user lifecycle, which is platform-wide, researched against the SCIM and DPDP positions, and carries three measurements this page did not have — including that users.status already declares a soft delete that NOTHING reads.

✅ What remains valid here: the schema measurements, the seat assessment, the 17 edge cases, and the employee-card finding. Read this page as the dealership application of the platform rules.

Part of car_sales — the dealership operating layer. 🧮 Measured against all 69 live migrations on 2026-08-23. Every verdict below was scripted, not read.

⚠ Two corrections to things this section has previously stated, and one finding that is larger than the question asked

1 · public.audit_log EXISTS, and I said it did not. 📘 QRS-863 gap and the admin control plane both recorded "no tenant-scoped audit trail." 🧮 Wrong: it has been there since 20260808200000_v2_audit. My grep was create table public.audit and the table is audit_log — a prefix search reporting an absence. Corrected below and in that page.

2 · ⚠⚠ THE EMPLOYEE SETU CARD IS UNREPRESENTABLE IN THE CURRENT SCHEMA. 🧮 setu_cards.workspace_id uuid not null UNIQUE — one card per workspace, and there is no user column at all. The dealership model assumes ~26 employee cards per outlet, and the product thesis of the entire vertical has no table. QRS-869.

1 · ✅ What the architecture already gets right ​

RequirementState
Departure without deletion✅ workspace_members.status in ('invited','active','suspended','removed'). The lifecycle already exists; a leaver is a status transition
Audit survives the actor✅ audit_log.actor_user_id … on delete SET NULL, plus actor_role denormalised — so the role at the time survives even when the pointer is nulled. That is the correct design and somebody already made it
Audit is generic, not per-feature✅ action constrained to ^[a-z_]+\.[a-z_]+$, scope_kind/scope_id, target_table/target_id, before/after jsonb, reason, request_id, ip_hash, occurred_at
A message outlives its author✅ messages.sender_user_id … on delete set null, with the reason in the migration: "the message stays, its history does not become a dangling reference"
An anonymous buyer's order survives✅ orders.buyer_user_id nullable and SET NULL
created_by as a concept✅ 9 uses across the schema
Tenure start✅ workspace_members.joined_at

🔎 So the pattern is understood and applied where the tables exist. The problem is not a wrong model. It is that the tables this requirement mostly applies to have not been written yet, which is the cheapest possible moment to state the rule.

2 · ⚠ The measured gaps ​

#GapSeverity
1⚠⚠ No employee card table. setu_cards is one-per-workspace with no user columnBlocking the whole vertical
211 FKs to public.users are ON DELETE CASCADEHigh, and see the nuance below
3No left_at. status='removed' records that someone left, never whenMedium. "What did Employee A accomplish during their tenure" has no end date
4assigned_to appears ZERO times in the whole schemaExpected: the tables are unwritten. Must be specified now
5No assignment-history table anywhereSame
6leads · parties · visits · assets · schedules · targets · interaction_events do not existThe requirement's whole surface area is unbuilt
7No roles / role_assignmentsThe team-continuity half depends on it (QRS-863)
8⚠ A departed rep's card slug is immortal and unreassignable — slug is write-once by triggerMedium, and customer facing

⚠ The CASCADE count needs its nuance, or it reads as a bigger problem than it is

🧮 11 CASCADE, 5 SET NULL, 6 unspecified. But a CASCADE only fires if a users row is actually deleted, and the correct rule makes that never happen for a departure:

A public.users row is NEVER hard-deleted for an employee leaving. Departure is workspace_members.status = 'removed' plus left_at. Deletion is reserved for a DPDP erasure request, which is a different event with different consequences.

⚠ Two CASCADEs still deserve a second look even under that rule, because they fire on the consumer side where erasure requests are real:

  • conversations.consumer_user_id … on delete cascade → deleting a consumer deletes the conversation and, through it, every message including the dealership's own. A buyer exercising deletion rights would erase the merchant's side of the record.
  • feature_grants.member_user_id / user_id … cascade → a negotiated per-person override vanishes silently. Defensible, but it should be a decision rather than a default.

3 · ⚠ The "seat" proposal, assessed rather than accepted ​

📘 The owner proposes modelling a Sales Representative seat that employees occupy. 🔎 I recommend against it as a new entity, for two specific reasons, and the thing being asked for is delivered anyway.

Reason 1: "seat" is already taken, and a second meaning is this repo's most expensive recurring defect

📘 A seat in QR setu is a licence unit: "SEATS LICENSE USERS, not workspaces. 8 showrooms + 30 agents = 39 cards but 30 seats" (QRS-397). Introducing seat = a position a person occupies gives one word two meanings in one product.

🧮 This has already cost four sweeps: cards (five planned card products), plans (meal plans versus platform plans), primitives (three simultaneous meanings), templates (email versus card manifests, and it reached 254 code and 358 portal occurrences before anyone said the word aloud). 📘 The feature-scoped-naming rule exists precisely to stop the fifth.

Reason 2: a seat entity LOSES the thing the requirement is about

🔎 The requirement is "what did Employee A accomplish." That is a question about a person. If a lead is owned by Sales Rep Seat 3, the person is one join away at best and gone at worst.

And the owner's own framing already names the right answer:

"Historical attribution → original employee. Operational responsibility → current employee. Those are two different concepts."

🔎 Two concepts want two columns, not a third entity:

ColumnMutabilityAnswers
created_by_user_id⚠ Immutable, foreverWho generated this record
assigned_user_idMutableWhose job is it today
an append-only assignment history rowInsert onlyEvery hand-over, with who and when and why

That delivers the audit trail in the owner's own example (original owner · previous · current · history) with no seat entity, no extra join, and no lifecycle to maintain.

✅ But there IS one case a position abstraction genuinely earns, and we have already specified it

🔎 Team structure continuity. When a Team Leader leaves, their team must keep reporting to the Baner team lead position, not to a deactivated person. A team hanging off a user_id breaks the moment that user is suspended.

📘 That abstraction is RBAC's role_assignments scoped to a subtree, which QRS-863 already specifies as (user_id, role_id, scope_workspace_id, includes_subtree, valid_from, valid_until). Replacing the person is a new assignment row; the subtree does not move, and valid_until on the old row is the tenure record.

⚠ So the position abstraction is RBAC, not a new seat table — and that is another reason to take the RBAC model decision now (QRS-868) rather than after the screens.

4 · The rules, stated so every unwritten table inherits them ​

These are cheap now and expensive later. Six of the seven tables they govern do not exist yet

#Rule
R1Every operational record carries created_by_user_id, and it is NEVER updated. A trigger should refuse an update to it, because a well-meaning reassignment routine is exactly what would overwrite it
R2Ownership is assigned_user_id, separate from R1, and nullable — an unassigned lead is a real state and the queue that finds it is the point
R3Every reassignment writes an append-only row: record, from, to, by, at, reason. Never an UPDATE that loses the previous value
R4Every FK to users on an operational record is ON DELETE SET NULL, never CASCADE. The record outlives the person
R5users is never hard-deleted for a departure. workspace_members.status='removed' plus left_at. Erasure is a separate, deliberate, audited path
R6Add left_at to workspace_members, so tenure has an end and "during their tenure" is answerable
R7Revoking access and preserving attribution are two different writes. Suspension must not touch a single attribution column
R8Reporting reads the ASSIGNEE for a workload question and the CREATOR for a performance question. ⚠ A dashboard that folds over assigned_user_id will credit Employee B with Employee A's conversions the day after a hand-over

⚠ R8 is the one that will actually bite, because it is invisible: the query runs, the number is plausible, and the wrong person gets the credit. 📘 Every management screen in the blueprint folds over one of these two columns and must name which.

5 · The seventeen edge cases ​

#CaseVerdict
1Employee leaves✅ status='removed' + left_at (R6). Attribution untouched
2Temporarily inactive✅ status='suspended' already exists. Access off, attribution intact
3Sales Rep → Team Leader🟡 A new role_assignments row, old one valid_until. Needs RBAC
4Team Leader → Sales Manager🟡 Same
5Multiple roles at once🟡 Many-to-many assignments, UNION semantics (QRS-864)
6Moves between branches🟡 New membership in the target workspace; the old membership is kept as removed, never edited, or the history moves with them
7Moves between teams🟡 Assignment scope changes; historical rows keep the old scope
8Returns after leaving⚠ The trap. Re-activate the SAME users row, never create a second. A second identity silently splits their history in two, and nobody notices until a report is short
9Two employees share duties✅ Two assignments, one created_by per record
10Lead changes owner repeatedly✅ R3's history table is the complete chain
11Customer met several reps✅ Per-interaction created_by; the party is shared. ⚠ "Whose customer is it" is then a policy question, not a data one, and the dealership answers it
12Team Leader leaves, team continues🟡 The team hangs off a subtree role assignment, so it survives. Needs RBAC
13Manager leaves with open approvals🔴 Nothing models an approval queue. Open items would be orphaned. Needs an escalation rule: reassign to the role, not the person
14History for an inactive employee✅ Readable via created_by + tenure dates, subject to oversight scope
15Remove access, keep records✅ Exactly what R5 and R7 deliver. This is the requirement, and it is satisfiable today for the tables that exist
16Teams merge or restructure🟡 Tree edit + reassignments. ⚠ Historical rows must keep the scope as it was, or last quarter's report silently changes
17Multiple branches, one org✅ Already the tenancy model: organisation + workspace tree with a materialized path

🧮 6 supported today · 8 need RBAC or an unwritten table · 1 has no model at all (#13) · 2 are traps (#8, #16).

6 · ⚠ The card question nobody has answered ​

A departed rep's card URL is immortal, unreassignable, and printed on things

🧮 setu_cards.slug is write-once by trigger (12 references). So Rep A's slug can never be re-pointed at Rep B. And the URL may be on a standee, in a hundred WhatsApp threads and on a business card.

Three options, and this is a product decision with a customer-facing consequence:

OptionWhat the scanning customer seesCost
Redirect to the outlet cardThe dealership, immediately usefulLoses the signal that they were looking for a person
A "moved on" page naming the teamHonest, and offers the next stepOne more surface to design
410 Gone⚠ A dead end. The dealer's brand takes the damage, not ours

🔎 Recommended: redirect to the outlet card, with one line naming that the consultant has changed and a link to the new owner of that relationship if there is one. It preserves the enquiry, which is the only thing that matters commercially.

⚠ And it must be decided before the first standee ships, because a standee carrying a rep's slug rather than the outlet's would make this permanent and physical.