Skip to content

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 saidMeasured 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 cascade

NOT 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 ​

ConsumerWhat it assumesUnder 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-orderresolveWorkspaceBySlug() 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:

ChangeVerdict
(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 ​

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

  1. Add member_user_id uuid references public.users (id) on delete set null — nullable, matching the <role>_user_id convention measured in §0 and the SET NULL precedent already commented on catalog_items.created_by.
  2. Fix get_my_context() to project cards as an array, or to filter to the org card explicitly.
  3. Add .limit(1) to manage-media's helper; decide whether manage-item's single-tag purge is correct.
  4. Give SetuCardLivePanel a card id so it can use the card_id argument the EF already accepts.
  5. 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 typeVerdictWhy
Consumer❓ insufficient_evidenceThe consumer-surface measurement did not complete. consumer_vendor_json and get_consumer_item_feed inner-join cards, and neither was traced
Solo / SMB merchant🔴 unsafeThey 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_changesThe intended beneficiary
Enterprise❓ insufficient_evidenceOversight 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:

  1. The consumer read path — consumer_vendor_json, get_consumer_item_feed, VendorViewScreen, ItemViewScreen, and the seven unexamined apps/mobile/src/tiers/consumer/ screens.
  2. Whether oversight should surface member cards to an ancestor workspace.
  3. The member_cards separate-table alternative, which is currently unassessed and may dominate.
  4. 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.