Appearance
Employee Setu Card: linking a card to a member, not only a workspace
Raised 2026-08-24. First document under the architecture change protocol. It supersedes the D1 recommendation in user lifecycle, which was made before any of the evidence below existed.
🔴 Final recommendation: DO NOT IMPLEMENT — evidence is insufficient
Two of the four customer types were never measured (the consumer-surface slice did not complete), so P6 binds this to do_not_implement regardless of how good the rest of the assessment looks.
⚠ And that is not a formality here — the measurement already refuted the shape I recommended. What follows is worth reading precisely because a smaller, safer change emerged from it.
0 · What the measurement changed about my own prior claims
🧮 Measured against all 69 live migrations and against a live Postgres built from them (container supabase_db_qrsetu, 55 public tables), including a begin; … rollback; mutation test that actually dropped the constraint and re-ran every dependent function.
| I previously said | Measured truth |
|---|---|
"11 CASCADE, 5 SET NULL, 6 unspecified" FKs to users | ⚠ Wrong in all three numbers. There are 27 FKs to a users table: 12 CASCADE, 8 SET NULL, 7 NO ACTION. My grep pattern missed five |
| L9: "flip every operational CASCADE to SET NULL" | ⚠⚠ For five columns this is not a change of meaning, it is STRUCTURALLY IMPOSSIBLE — they are PRIMARY KEY components and Postgres refuses outright (ERROR: column "user_id" is in a primary key), proven by rolled-back mutation test on each: users.id · workspace_members.user_id · conversation_states.user_id · message_states.user_id · merchant_payment_alert_reads.user_id |
L7: add created_by_user_id | ⚠ Conflicts with a measured convention. The schema has exactly 7 actor FK columns following three shapes: an ACTION on the row is <past-participle>_by; a ROLE in the row's domain is <role>_user_id; the SUBJECT of a per-user row is bare user_id. And where a table already has two user columns it keeps them in one family (workspace_members: user_id + invited_by). created_by_user_id + assigned_user_id mixes two families on one row |
"the public card is at /:slug" | ⚠ It is at /:slug/setu-card. /:slug is a 301-only redirect module with no default export |
| "no redirect layer exists" (my touchpoint premise) | 🔎 Half wrong: a redirect module exists and it is a 301. What does not exist is anything re-pointable — see §5 |
| "the departed-employee attribution principle needs deciding" | ✅ It was already decided and commented in the schema. catalog_items.created_by's own COMMENT ON reads "ON DELETE SET NULL so removing a departed agent never deletes their catalog" |
1 · Existing architecture
🧮 public.setu_cards is created once, at 20260808160000_v2_cards.sql:39. Its ownership column is:
sql
workspace_id uuid not null unique references public.workspaces (id) on delete cascadeNOT NULL and UNIQUE — exactly one card per workspace, and no card without a workspace. 🧮 No migration in the 69 ever alters it: seven ALTER-shaped hits on setu_cards exist and none touches workspace_id.
🧮 RLS: exactly two policies, both to authenticated — SELECT over own workspaces plus oversight descendants, UPDATE over own workspaces. No INSERT policy, no DELETE policy, no anon policy.
2 · Actual data model and relationships
✅ setu_cards already carries created_by uuid references public.users (id) on delete set null (20260808210000_v2_production_hardening.sql:146). So a user pointer on this table is not a new idea.
⚠ But it is DEAD, and that is the finding. 🧮 It is written by no INSERT — provision_merchant_workspace supplies created_by on the workspaces insert and omits it on the setu_cards insert — read by no SQL function, and referenced by no client code. It cannot be leaned on as an existing seam.
3 · Current implementation: who reads this table
| Consumer | What it assumes | Under two cards per workspace |
|---|---|---|
get_my_context() | LEFT JOINs one card per workspace and projects card as a SCALAR inside a per-workspace jsonb_agg | ⚠ Duplicates the workspace row. The merchant sees their business twice; a workspace-less card never appears at all. This is the shape both consoles read |
place-public-order | resolveWorkspaceBySlug() returns card.workspace_id; orders.workspace_id is NOT NULL | 🔴 A workspace-less card cannot take an order, and fails as unknown_workspace — indistinguishable from a nonexistent slug |
manage-media workspaceCardSlug() | .maybeSingle() with no .limit(1) | ⚠ Multiple-rows error → media confirm/delete throws |
manage-item (same helper) | .maybeSingle() with .limit(1) | ⚠ Silently purges one of two cache tags. Worse than the throw, because it looks fine |
SetuCardLivePanel (web publish) | publishes via setSetuCardPublished({workspaceId}) and holds no card id | ⚠ resolveWritableCard throws "You have more than one card — pass card_id." — a message the UI has no way to satisfy |
✅ Note what that last row also proves: the Edge Function ALREADY anticipates multiple cards per workspace. The write path has a card_id argument and a real error for ambiguity. The database constraint is stricter than the API that guards it.
4 · Impact analysis
🔴 Dropping NOT NULL is the part that breaks the money path, and it is not recoverable by fixing a caller: orders.workspace_id is not null, so a card with no workspace is a card that cannot transact.
🔎 That single measurement splits the proposal in two, and the smaller half is safe:
| Change | Verdict | |
|---|---|---|
| (a) | Drop NOT NULL on workspace_id (a card belonging to a person, not a business) | 🔴 UNSAFE. Breaks orders, payment readiness, the catalogue join and the consumer feed |
| (b) | Drop only the UNIQUE, keep NOT NULL, add a nullable member_user_id | 🟡 SAFE WITH CHANGES. Every card still has a workspace, so the money path is untouched. Breakage confines to three readers named in §3 |
5 · Gaps
Search space stated first, because an absence proves nothing without it: all 69 live migrations, 13 name variants (qr_codes, qr_code, qrcodes, touchpoints, touchpoint, short_codes, shortcodes, short_links, links, redirects, redirect_targets, scan_targets, link_targets), across three trees (live migrations, _archive_pre_v2/, source).
🧮 Result: none of them exists in the live schema. The 55 live tables contain nothing QR-shaped, touchpoint-shaped or short-code-shaped. setu_card_links is a per-card list of outbound hyperlinks constrained to ^https?:// — nothing resolves an incoming path through it. qr_codes (with its scans column) exists only in the archived pre-v2 baseline.
⚠ So the touchpoint mechanism I described is a NEW SUBSYSTEM, not a refinement. That defect stands exactly as reported, and this is the evidence that would have prevented it being described the other way.
6 · Alternatives evaluated
| Option | Verdict |
|---|---|
| 26 workspaces per outlet (one per employee) | 🔴 Breaks workspace = a business with its own P&L, which the tree, oversight and sharing semantics all rest on. Also 26× the seat and card accounting |
| Drop NOT NULL + UNIQUE (my original D1) | 🔴 Breaks the money path irrecoverably — §4 |
Drop UNIQUE only, add nullable member_user_id | 🟡 The measured-best option. Recommended shape, pending the two unmeasured customer types |
A separate member_cards table | ❓ Not assessed. It avoids touching setu_cards at all, but duplicates slug governance, the write-once trigger, the template resolver and the cache tag. Needs assessment before this proposal can be finalised |
7 · Proposed change
Expand-contract, and the UNIQUE is relaxed LAST:
- Add
member_user_id uuid references public.users (id) on delete set null— nullable, matching the<role>_user_idconvention measured in §0 and theSET NULLprecedent already commented oncatalog_items.created_by. - Fix
get_my_context()to project cards as an array, or to filter to the org card explicitly. - Add
.limit(1)tomanage-media's helper; decide whethermanage-item's single-tag purge is correct. - Give
SetuCardLivePanela card id so it can use thecard_idargument the EF already accepts. - Only then drop the UNIQUE, replacing it with a partial unique index on
(workspace_id) where member_user_id is null— so the one-org-card-per-workspace invariant survives.
8 · Validation against all four customer types
| Customer type | Verdict | Why |
|---|---|---|
| Consumer | ❓ insufficient_evidence | The consumer-surface measurement did not complete. consumer_vendor_json and get_consumer_item_feed inner-join cards, and neither was traced |
| Solo / SMB merchant | 🔴 unsafe | They have exactly one card and it is their business. Step 2 changes get_my_context, the shape every merchant console reads — so a solo merchant with no employees pays the entire regression risk of a feature they will never use |
| Car dealership | 🟡 safe_with_changes | The intended beneficiary |
| Enterprise | ❓ insufficient_evidence | Oversight reads cards across a subtree; whether member cards should appear in an ancestor's view is undecided and unmeasured |
9 · Production-risk assessment
Severe, and the reason is step 5 specifically: once a second row exists under one workspace the UNIQUE cannot be restored, so there is no rollback — only a compensating migration. Steps 1-4 are individually reversible; step 5 is not.
10 · Final recommendation
🔴 Architecture evidence is insufficient to make this change recommendation yet.
What must be inspected first:
- The consumer read path —
consumer_vendor_json,get_consumer_item_feed,VendorViewScreen,ItemViewScreen, and the seven unexaminedapps/mobile/src/tiers/consumer/screens. - Whether oversight should surface member cards to an ancestor workspace.
- The
member_cardsseparate-table alternative, which is currently unassessed and may dominate. - Whether the live Dev project matches this repo-built database — every measurement above came from a local container built from
supabase/migrations, and per QRS-693 a migration in the repo is not evidence it ran.
Not verified
❓ All schema measurement is from a local Postgres built from the repo, not from Dev. ❓ The four validation agents that were to score all twelve items died on a session limit, so §8's verdicts are mine over their dossier rather than independently reviewed. ❓ Test bodies were enumerated but not read: 37 non-pgTAP files reference cards, and which would fail versus merely need new cases is unknown.