Appearance
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
| Requirement | State |
|---|---|
| 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
| # | Gap | Severity |
|---|---|---|
| 1 | ⚠⚠ No employee card table. setu_cards is one-per-workspace with no user column | Blocking the whole vertical |
| 2 | 11 FKs to public.users are ON DELETE CASCADE | High, and see the nuance below |
| 3 | No left_at. status='removed' records that someone left, never when | Medium. "What did Employee A accomplish during their tenure" has no end date |
| 4 | assigned_to appears ZERO times in the whole schema | Expected: the tables are unwritten. Must be specified now |
| 5 | No assignment-history table anywhere | Same |
| 6 | leads · parties · visits · assets · schedules · targets · interaction_events do not exist | The requirement's whole surface area is unbuilt |
| 7 | No roles / role_assignments | The team-continuity half depends on it (QRS-863) |
| 8 | ⚠ A departed rep's card slug is immortal and unreassignable — slug is write-once by trigger | Medium, 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.usersrow is NEVER hard-deleted for an employee leaving. Departure isworkspace_members.status = 'removed'plusleft_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:
| Column | Mutability | Answers |
|---|---|---|
created_by_user_id | ⚠ Immutable, forever | Who generated this record |
assigned_user_id | Mutable | Whose job is it today |
| an append-only assignment history row | Insert only | Every 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 |
|---|---|
| R1 | Every 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 |
| R2 | Ownership 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 |
| R3 | Every reassignment writes an append-only row: record, from, to, by, at, reason. Never an UPDATE that loses the previous value |
| R4 | Every FK to users on an operational record is ON DELETE SET NULL, never CASCADE. The record outlives the person |
| R5 | users is never hard-deleted for a departure. workspace_members.status='removed' plus left_at. Erasure is a separate, deliberate, audited path |
| R6 | Add left_at to workspace_members, so tenure has an end and "during their tenure" is answerable |
| R7 | Revoking access and preserving attribution are two different writes. Suspension must not touch a single attribution column |
| R8 | Reporting 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
| # | Case | Verdict |
|---|---|---|
| 1 | Employee leaves | ✅ status='removed' + left_at (R6). Attribution untouched |
| 2 | Temporarily inactive | ✅ status='suspended' already exists. Access off, attribution intact |
| 3 | Sales Rep → Team Leader | 🟡 A new role_assignments row, old one valid_until. Needs RBAC |
| 4 | Team Leader → Sales Manager | 🟡 Same |
| 5 | Multiple roles at once | 🟡 Many-to-many assignments, UNION semantics (QRS-864) |
| 6 | Moves between branches | 🟡 New membership in the target workspace; the old membership is kept as removed, never edited, or the history moves with them |
| 7 | Moves between teams | 🟡 Assignment scope changes; historical rows keep the old scope |
| 8 | Returns 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 |
| 9 | Two employees share duties | ✅ Two assignments, one created_by per record |
| 10 | Lead changes owner repeatedly | ✅ R3's history table is the complete chain |
| 11 | Customer 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 |
| 12 | Team Leader leaves, team continues | 🟡 The team hangs off a subtree role assignment, so it survives. Needs RBAC |
| 13 | Manager 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 |
| 14 | History for an inactive employee | ✅ Readable via created_by + tenure dates, subject to oversight scope |
| 15 | Remove access, keep records | ✅ Exactly what R5 and R7 deliver. This is the requirement, and it is satisfiable today for the tables that exist |
| 16 | Teams merge or restructure | 🟡 Tree edit + reassignments. ⚠ Historical rows must keep the scope as it was, or last quarter's report silently changes |
| 17 | Multiple 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:
| Option | What the scanning customer sees | Cost |
|---|---|---|
| Redirect to the outlet card | The dealership, immediately useful | Loses the signal that they were looking for a person |
| A "moved on" page naming the team | Honest, and offers the next step | One 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.
Related
- Architecture validation — the wider schema assessment
- Admin control plane — RBAC, role scope and the audit correction
- Screen blueprint — every management screen that folds over one of the two columns
- Persona feature map — who holds a card, and who does not