Skip to content

Commit 4fb347e

Browse files
huangyiireneclaude
andauthored
fix(showcase): seed assignees/owners that resolve to a real user (#7746) (#7789)
The showcase seeded `showcase_task.assignee` and `showcase_project.owner` as bare emails — `ada@`/`linus@`/`grace@`/`sam@example.com` — that are no `sys_user`. Both fields are read as notify RECIPIENTS by the flows, and `RecipientResolver` (ADR-0030 P1) resolves an email-shaped recipient against `sys_user` and, on a MISS, keeps the string VERBATIM as the recipient id. So the stock reassignment demo did not fail loudly: it persisted a `sys_inbox_message` whose `user_id` was a literal email address — a row no authenticated user can read. The QA run that found this (#7690) had to sign up its own personas to test notify at all. Those fields now name the identities the app actually provisions on a dev boot: the `plugin-auth` dev admin plus the two personas `seed-approval-demo.ts` creates on `kernel:bootstrapped`. The persona constants move to `src/security/demo-personas.ts` so the seed and the bootstrap that creates the rows read one registry and cannot drift. Three distinct values keep the Kanban/gallery/list spread intact. `showcase_invoice.owner` deliberately keeps its `ada@`-style emails: it is the fixture for the ADR-0055 controlled-by-parent isolation demo, where an operator SIGNS UP as those emails. The provisioned personas hold no credential, so repointing invoices at them would delete that demo rather than fix it — reported on the issue instead of guessed at here. Guarded by `test/inert-wirings.test.ts` §5, which derives the recipient fields from the flows themselves (so a new notify node is covered automatically) and fails when a seeded recipient is an email the app never provisions. Verified on a real `pnpm dev` boot of the showcase: all 10 task assignees and 5 project owners resolve to `sys_user` rows, and a reassignment writes `sys_inbox_message.user_id = usr_showcase_auditor_demo` (a real id). The old value still reproduces the defect — `WARN [recipients] no 'sys_user' matched email 'ada@example.com'; keeping verbatim`, row keyed to the raw address. Refs #7690 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 60b672e commit 4fb347e

4 files changed

Lines changed: 288 additions & 37 deletions

File tree

‎examples/app-showcase/src/data/seed/index.ts‎

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,34 @@ import { Contact } from '../objects/contact.object.js';
1515
import { Inquiry } from '../objects/inquiry.object.js';
1616
import { FieldZoo } from '../objects/field-zoo.object.js';
1717
import { Announcement } from '../objects/announcement.object.js';
18+
import { ADMIN_EMAIL, PHONE_DEMO_USER, AUDITOR_DEMO_USER } from '../../security/demo-personas.js';
19+
20+
/**
21+
* ## Why assignees/owners are addressed to the PROVISIONED personas (#7746)
22+
*
23+
* `showcase_task.assignee` and `showcase_project.owner` are `Field.text`, but
24+
* they are not free text: the notify nodes in `automation/flows` read them as
25+
* RECIPIENTS (`recipients: ['{record.assignee}']`, `['{record.owner}']`). An
26+
* email-shaped recipient is resolved against `sys_user`, and on a MISS the
27+
* resolver keeps it VERBATIM as the recipient id — so a made-up address does
28+
* not fail loudly, it persists a `sys_inbox_message` whose `user_id` is a
29+
* string no authenticated user can ever match.
30+
*
31+
* The seed used to write `ada@` / `linus@` / `grace@` / `sam@example.com`
32+
* here, none of which is a `sys_user`. Reassigning a seeded task — the stock
33+
* demo of `showcase_task_assigned_notify` — therefore delivered into a void,
34+
* and the QA run that found this (#7690) had to sign up its own personas to
35+
* exercise the notify path at all.
36+
*
37+
* The three constants below are the identities this app actually provisions on
38+
* a dev boot. `demo-personas.ts` documents that set, its dev-only limits, and
39+
* why `invoice.owner` / `team.lead` deliberately KEEP their `ada@`-style
40+
* emails. Three distinct values still spread across every Kanban column,
41+
* gallery card and list, so the "feed every view" principle below is intact.
42+
*/
43+
const ADMIN = ADMIN_EMAIL;
44+
const MEI = PHONE_DEMO_USER.email;
45+
const ADA = AUDITOR_DEMO_USER.email;
1846

1947
/**
2048
* Seed data sized to "feed every view": every Kanban column is populated,
@@ -143,11 +171,11 @@ const projects = defineSeed(Project, {
143171
mode: 'upsert',
144172
externalId: 'name',
145173
records: [
146-
{ name: 'Website Relaunch', account: 'Northwind', status: 'active', health: 'green', budget: 150_000, spent: 60_000, owner: 'ada@example.com', start_date: cel`daysAgo(30)`, end_date: cel`daysFromNow(60)` },
147-
{ name: 'Data Platform', account: 'Contoso', status: 'active', health: 'yellow', budget: 600_000, spent: 420_000, owner: 'linus@example.com', start_date: cel`daysAgo(90)`, end_date: cel`daysFromNow(120)` },
148-
{ name: 'Compliance Audit', account: 'Fabrikam', status: 'on_hold', health: 'red', budget: 90_000, spent: 88_000, owner: 'grace@example.com', start_date: cel`daysAgo(15)`, end_date: cel`daysFromNow(30)` },
149-
{ name: 'Mobile App', account: 'Contoso', status: 'planned', health: 'green', budget: 200_000, spent: 0, owner: 'ada@example.com', start_date: cel`daysFromNow(14)`, end_date: cel`daysFromNow(140)` },
150-
{ name: 'Legacy Sunset', account: 'Northwind', status: 'completed', health: 'green', budget: 50_000, spent: 48_000, owner: 'linus@example.com', start_date: cel`daysAgo(180)`, end_date: cel`daysAgo(20)` },
174+
{ name: 'Website Relaunch', account: 'Northwind', status: 'active', health: 'green', budget: 150_000, spent: 60_000, owner: ADA, start_date: cel`daysAgo(30)`, end_date: cel`daysFromNow(60)` },
175+
{ name: 'Data Platform', account: 'Contoso', status: 'active', health: 'yellow', budget: 600_000, spent: 420_000, owner: ADMIN, start_date: cel`daysAgo(90)`, end_date: cel`daysFromNow(120)` },
176+
{ name: 'Compliance Audit', account: 'Fabrikam', status: 'on_hold', health: 'red', budget: 90_000, spent: 88_000, owner: MEI, start_date: cel`daysAgo(15)`, end_date: cel`daysFromNow(30)` },
177+
{ name: 'Mobile App', account: 'Contoso', status: 'planned', health: 'green', budget: 200_000, spent: 0, owner: ADA, start_date: cel`daysFromNow(14)`, end_date: cel`daysFromNow(140)` },
178+
{ name: 'Legacy Sunset', account: 'Northwind', status: 'completed', health: 'green', budget: 50_000, spent: 48_000, owner: ADMIN, start_date: cel`daysAgo(180)`, end_date: cel`daysAgo(20)` },
151179
],
152180
});
153181

@@ -156,16 +184,16 @@ const tasks = defineSeed(Task, {
156184
mode: 'upsert',
157185
externalId: 'title',
158186
records: [
159-
{ title: 'Audit current IA', project: 'Website Relaunch', assignee: 'ada@example.com', status: 'done', priority: 'medium', estimate_hours: 8, progress: 100, done: true, created_at: cel`daysAgo(20)`, start_date: cel`daysAgo(20)`, end_date: cel`daysAgo(18)`, due_date: cel`daysAgo(18)`, location: { lat: 47.6062, lng: -122.3321 } },
160-
{ title: 'Design system', project: 'Website Relaunch', assignee: 'ada@example.com', status: 'in_review', priority: 'high', estimate_hours: 24, progress: 80, done: false, created_at: cel`daysAgo(14)`, start_date: cel`daysAgo(12)`, end_date: cel`daysFromNow(2)`, due_date: cel`daysFromNow(2)`, location: { lat: 37.7749, lng: -122.4194 } },
161-
{ title: 'Build homepage', project: 'Website Relaunch', assignee: 'sam@example.com', status: 'in_progress', priority: 'high', estimate_hours: 40, progress: 45, done: false, created_at: cel`daysAgo(8)`, start_date: cel`daysAgo(6)`, end_date: cel`daysFromNow(10)`, due_date: cel`daysFromNow(10)`, location: { lat: 40.7128, lng: -74.0060 } },
162-
{ title: 'SEO migration plan', project: 'Website Relaunch', assignee: 'sam@example.com', status: 'todo', priority: 'medium', estimate_hours: 16, progress: 0, done: false, created_at: cel`daysAgo(3)`, start_date: cel`daysFromNow(5)`, end_date: cel`daysFromNow(15)`, due_date: cel`daysFromNow(15)`, location: { lat: 30.2672, lng: -97.7431 } },
163-
{ title: 'Content backlog', project: 'Website Relaunch', assignee: 'grace@example.com', status: 'backlog', priority: 'low', estimate_hours: 12, progress: 0, done: false, created_at: cel`daysAgo(2)`, due_date: cel`daysFromNow(30)`, location: { lat: 41.8781, lng: -87.6298 } },
164-
{ title: 'Ingest pipeline', project: 'Data Platform', assignee: 'linus@example.com', status: 'in_progress', priority: 'urgent', estimate_hours: 60, progress: 55, done: false, created_at: cel`daysAgo(40)`, start_date: cel`daysAgo(35)`, end_date: cel`daysFromNow(20)`, due_date: cel`daysFromNow(20)`, location: { lat: 39.7392, lng: -104.9903 } },
165-
{ title: 'Warehouse schema', project: 'Data Platform', assignee: 'linus@example.com', status: 'in_review', priority: 'high', estimate_hours: 30, progress: 90, done: false, created_at: cel`daysAgo(25)`, start_date: cel`daysAgo(22)`, end_date: cel`daysFromNow(3)`, due_date: cel`daysFromNow(3)`, location: { lat: 42.3601, lng: -71.0589 } },
166-
{ title: 'PII access review', project: 'Compliance Audit', assignee: 'grace@example.com', status: 'todo', priority: 'urgent', estimate_hours: 20, progress: 0, done: false, created_at: cel`daysAgo(5)`, start_date: cel`daysFromNow(2)`, end_date: cel`daysFromNow(12)`, due_date: cel`daysFromNow(12)`, location: { lat: 38.9072, lng: -77.0369 } },
167-
{ title: 'Evidence collection', project: 'Compliance Audit', assignee: 'grace@example.com', status: 'backlog', priority: 'medium', estimate_hours: 18, progress: 0, done: false, created_at: cel`daysAgo(1)`, due_date: cel`daysFromNow(25)`, location: { lat: 34.0522, lng: -118.2437 } },
168-
{ title: 'App wireframes', project: 'Mobile App', assignee: 'ada@example.com', status: 'done', priority: 'medium', estimate_hours: 16, progress: 100, done: true, created_at: cel`daysAgo(10)`, start_date: cel`daysAgo(10)`, end_date: cel`daysAgo(6)`, due_date: cel`daysAgo(6)`, location: { lat: 45.5152, lng: -122.6784 } },
187+
{ title: 'Audit current IA', project: 'Website Relaunch', assignee: ADA, status: 'done', priority: 'medium', estimate_hours: 8, progress: 100, done: true, created_at: cel`daysAgo(20)`, start_date: cel`daysAgo(20)`, end_date: cel`daysAgo(18)`, due_date: cel`daysAgo(18)`, location: { lat: 47.6062, lng: -122.3321 } },
188+
{ title: 'Design system', project: 'Website Relaunch', assignee: ADA, status: 'in_review', priority: 'high', estimate_hours: 24, progress: 80, done: false, created_at: cel`daysAgo(14)`, start_date: cel`daysAgo(12)`, end_date: cel`daysFromNow(2)`, due_date: cel`daysFromNow(2)`, location: { lat: 37.7749, lng: -122.4194 } },
189+
{ title: 'Build homepage', project: 'Website Relaunch', assignee: ADMIN, status: 'in_progress', priority: 'high', estimate_hours: 40, progress: 45, done: false, created_at: cel`daysAgo(8)`, start_date: cel`daysAgo(6)`, end_date: cel`daysFromNow(10)`, due_date: cel`daysFromNow(10)`, location: { lat: 40.7128, lng: -74.0060 } },
190+
{ title: 'SEO migration plan', project: 'Website Relaunch', assignee: ADMIN, status: 'todo', priority: 'medium', estimate_hours: 16, progress: 0, done: false, created_at: cel`daysAgo(3)`, start_date: cel`daysFromNow(5)`, end_date: cel`daysFromNow(15)`, due_date: cel`daysFromNow(15)`, location: { lat: 30.2672, lng: -97.7431 } },
191+
{ title: 'Content backlog', project: 'Website Relaunch', assignee: MEI, status: 'backlog', priority: 'low', estimate_hours: 12, progress: 0, done: false, created_at: cel`daysAgo(2)`, due_date: cel`daysFromNow(30)`, location: { lat: 41.8781, lng: -87.6298 } },
192+
{ title: 'Ingest pipeline', project: 'Data Platform', assignee: ADMIN, status: 'in_progress', priority: 'urgent', estimate_hours: 60, progress: 55, done: false, created_at: cel`daysAgo(40)`, start_date: cel`daysAgo(35)`, end_date: cel`daysFromNow(20)`, due_date: cel`daysFromNow(20)`, location: { lat: 39.7392, lng: -104.9903 } },
193+
{ title: 'Warehouse schema', project: 'Data Platform', assignee: ADMIN, status: 'in_review', priority: 'high', estimate_hours: 30, progress: 90, done: false, created_at: cel`daysAgo(25)`, start_date: cel`daysAgo(22)`, end_date: cel`daysFromNow(3)`, due_date: cel`daysFromNow(3)`, location: { lat: 42.3601, lng: -71.0589 } },
194+
{ title: 'PII access review', project: 'Compliance Audit', assignee: MEI, status: 'todo', priority: 'urgent', estimate_hours: 20, progress: 0, done: false, created_at: cel`daysAgo(5)`, start_date: cel`daysFromNow(2)`, end_date: cel`daysFromNow(12)`, due_date: cel`daysFromNow(12)`, location: { lat: 38.9072, lng: -77.0369 } },
195+
{ title: 'Evidence collection', project: 'Compliance Audit', assignee: MEI, status: 'backlog', priority: 'medium', estimate_hours: 18, progress: 0, done: false, created_at: cel`daysAgo(1)`, due_date: cel`daysFromNow(25)`, location: { lat: 34.0522, lng: -118.2437 } },
196+
{ title: 'App wireframes', project: 'Mobile App', assignee: ADA, status: 'done', priority: 'medium', estimate_hours: 16, progress: 100, done: true, created_at: cel`daysAgo(10)`, start_date: cel`daysAgo(10)`, end_date: cel`daysAgo(6)`, due_date: cel`daysAgo(6)`, location: { lat: 45.5152, lng: -122.6784 } },
169197
],
170198
});
171199

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The identities the showcase actually PROVISIONS — the one place that answers
5+
* "which `sys_user` rows exist on a fresh boot of this app?".
6+
*
7+
* ## Why this module exists (#7746)
8+
*
9+
* `sys_user` rows cannot be seeded — they come from sign-up — so for a long time
10+
* the seed simply wrote plausible-looking emails (`ada@example.com`,
11+
* `linus@example.com`, `sam@example.com`) into the fields that FEED THE NOTIFY
12+
* PATH: `showcase_task.assignee` and `showcase_project.owner`. Those fields are
13+
* `Field.text`, so nothing rejected the values, and every view rendered happily.
14+
*
15+
* The cost showed up one layer down. `RecipientResolver` (ADR-0030 P1) resolves
16+
* an email-shaped recipient by looking it up in `sys_user` and, on a MISS,
17+
* **keeps the string verbatim** as the recipient id
18+
* (`service-messaging/src/recipient-resolver.ts` — "no 'sys_user' matched email
19+
* '…'; keeping verbatim"). So the stock reassignment demo
20+
* (`showcase_task_assigned_notify`) persisted a `sys_inbox_message` whose
21+
* `user_id` was the literal text `ada@example.com`: a row addressed to nobody,
22+
* which no authenticated user can ever read. The reference app's marquee
23+
* "reassign a task and watch the inbox" story quietly delivered into a void.
24+
*
25+
* The fix is to address those fields to identities that REALLY EXIST. On a fresh
26+
* dev boot that set is exactly three rows, and this module is their registry:
27+
*
28+
* 1. {@link ADMIN_EMAIL} — the dev admin seeded by `plugin-auth`
29+
* (`OS_SEED_ADMIN_EMAIL`, default below). The only LOGINABLE one.
30+
* 2. {@link PHONE_DEMO_USER} / 3. {@link AUDITOR_DEMO_USER} — the personas
31+
* `seed-approval-demo.ts` provisions on `kernel:bootstrapped`.
32+
*
33+
* ## The honest limits of that set
34+
*
35+
* Both persona rows are **display/routing identities, not accounts**: they carry
36+
* no better-auth credential, so they cannot sign in (see `ensureDemoUser`). They
37+
* are enough to make a recipient RESOLVE — which is the whole of this defect —
38+
* and enough to make the inbox row addressable to a real user id, but reading
39+
* that row as that persona still needs the sign-up the showcase deliberately
40+
* leaves to the operator.
41+
*
42+
* And all three are DEV-ONLY: the dev admin is gated on
43+
* `NODE_ENV === 'development'`, and the two personas are provisioned only when
44+
* that admin exists. In a real deployment a fresh showcase has NO users at all,
45+
* so nothing here could resolve — that is a property of the environment, not a
46+
* defect in the seed, and no seed value can repair it.
47+
*
48+
* ## What is deliberately NOT addressed to these, and why
49+
*
50+
* `showcase_team.lead` is display-only — it reaches no notify recipient, so it
51+
* carries none of this defect and keeps its `ada@`-style email.
52+
*
53+
* `showcase_invoice.owner` is the harder case, and it is left alone KNOWINGLY
54+
* rather than because it is safe. It genuinely does reach a notify recipient
55+
* (`showcase_invoice_lifecycle` sends to `{record.owner}`), so it is the same
56+
* defect class as the fields fixed above. But those three emails are also the
57+
* fixture for the ADR-0055 controlled-by-parent isolation demo, whose whole
58+
* point is that an operator SIGNS UP as `ada@example.com` and then sees only
59+
* their own invoices — `qa/dogfood/test/showcase-invoice-seed-isolation.
60+
* dogfood.test.ts` does exactly that, and pins the seeded owners.
61+
*
62+
* Repointing invoices at the personas above would not fix that demo, it would
63+
* DELETE it: these personas hold no credential, so nobody can sign in as one
64+
* and observe the row-level scoping. Making them loginable, or giving the
65+
* showcase a set of real signup-able contributor accounts, is a larger design
66+
* decision than this defect — so it is reported on #7746 rather than guessed
67+
* at here, and `test/inert-wirings.test.ts` §5 carries the exemption with the
68+
* same reasoning attached.
69+
*/
70+
71+
/** The dev admin `plugin-auth` seeds when `NODE_ENV === 'development'`. */
72+
export const ADMIN_EMAIL = 'admin@objectos.ai';
73+
74+
/** A phone-based demo persona (§6 "phone sign-in surfaces"). */
75+
export const PHONE_DEMO_USER = {
76+
id: 'usr_showcase_phone_demo',
77+
name: 'Mei Phone (demo)',
78+
email: 'phone.demo@example.com',
79+
phone_number: '+8613800138000',
80+
} as const;
81+
82+
/**
83+
* A second persona holding ONLY `auditor`, which is the position behind the
84+
* `finance` group of the per-group (会签) demo. It has to be a *different* user
85+
* from the admin: with one user in both groups a single decision would satisfy
86+
* both tallies at once, and "one approval per group" would never be observable.
87+
*/
88+
export const AUDITOR_DEMO_USER = {
89+
id: 'usr_showcase_auditor_demo',
90+
name: 'Ada Auditor (demo)',
91+
email: 'auditor.demo@example.com',
92+
} as const;
93+
94+
/**
95+
* Every email a fresh boot of this app turns into a real `sys_user` row.
96+
*
97+
* This is the allow-list a seed value must come from before it may be written
98+
* to a field the notify path reads as a recipient — pinned by
99+
* `test/inert-wirings.test.ts` §5, so re-introducing a made-up persona fails a
100+
* test instead of shipping another unreadable inbox row.
101+
*/
102+
export const PROVISIONED_USER_EMAILS: readonly string[] = [
103+
ADMIN_EMAIL,
104+
PHONE_DEMO_USER.email,
105+
AUDITOR_DEMO_USER.email,
106+
];

‎examples/app-showcase/src/security/seed-approval-demo.ts‎

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,16 @@
3737
* which we swallow.
3838
*/
3939

40-
const SYS = { isSystem: true } as const;
40+
/**
41+
* The identities this bootstrap provisions now live in `demo-personas.ts`,
42+
* because the SEED needs them too: the fields that feed the notify path
43+
* (`showcase_task.assignee`, `showcase_project.owner`) must name identities
44+
* this file really creates, or a reassignment writes an inbox row addressed to
45+
* nobody (#7746). One registry, two consumers — so the two cannot drift apart.
46+
*/
47+
import { ADMIN_EMAIL, PHONE_DEMO_USER, AUDITOR_DEMO_USER } from './demo-personas.js';
4148

42-
const ADMIN_EMAIL = 'admin@objectos.ai';
49+
const SYS = { isSystem: true } as const;
4350

4451
/**
4552
* Positions the admin is granted so they resolve as an approver on the demos.
@@ -52,26 +59,6 @@ const ADMIN_EMAIL = 'admin@objectos.ai';
5259
*/
5360
const ADMIN_APPROVAL_POSITIONS = ['manager', 'finance', 'legal', 'exec'] as const;
5461

55-
/** A phone-based demo persona (§6 "phone sign-in surfaces"). */
56-
const PHONE_DEMO_USER = {
57-
id: 'usr_showcase_phone_demo',
58-
name: 'Mei Phone (demo)',
59-
email: 'phone.demo@example.com',
60-
phone_number: '+8613800138000',
61-
} as const;
62-
63-
/**
64-
* A second persona holding ONLY `auditor`, which is the position behind the
65-
* `finance` group of the per-group (会签) demo. It has to be a *different* user
66-
* from the admin: with one user in both groups a single decision would satisfy
67-
* both tallies at once, and "one approval per group" would never be observable.
68-
*/
69-
const AUDITOR_DEMO_USER = {
70-
id: 'usr_showcase_auditor_demo',
71-
name: 'Ada Auditor (demo)',
72-
email: 'auditor.demo@example.com',
73-
} as const;
74-
7562
interface ApprovalDemoContext {
7663
ql: {
7764
find: (object: string, query: unknown, options?: unknown) => Promise<unknown>;

0 commit comments

Comments
 (0)