Skip to content

Seed data: demo-en and demo-zh - #19

Draft
os-sam wants to merge 1 commit into
mainfrom
claude/issue-5-seed-data
Draft

Seed data: demo-en and demo-zh#19
os-sam wants to merge 1 commit into
mainfrom
claude/issue-5-seed-data

Conversation

@os-sam

@os-sam os-sam commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Fixes #5

What changed

Two demo seed sets, one schema, selected by OS_SEED_LOCALE (default en):

  • src/data/shared/ — the locale-independent half. skeleton.ts (hand-authored structure: employers, jobs, credential types, credentials, reports), pipeline.ts (the generated, literal 200-row pipeline plus candidate attributes; produced once by a seeded generator and pasted so the source carries no randomness and no clock), build.ts (pure builders: skeleton × locale pack → typed defineSeed() records), sys-objects.ts (minimal typed shapes for the four platform identity tables the seed writes), pack.ts (the locale pack contract).
  • src/data/demo-en/ and src/data/demo-zh/ — one *.seed.ts per object (generated from a single template so the two locales cannot drift) plus the locale pack.ts with every human string. demo-zh mirrors demo-en row for row: the compiled artifacts differ only in strings and in the candidate phone prefix (+1 vs +86); every enum value, number, index and CEL date is byte-identical between the two.
  • src/data/index.ts — reads OS_SEED_LOCALE through a typed globalThis cast (the app has no Node typings) and exports data.
  • objectstack.config.ts — exactly two lines: the ./src/data/index.js import and the data, key. Nothing else in the file was touched (Employer views: pipeline kanban, inbox, interview calendar, talent pool #2 adds views:, Approval flows F1–F3 + the automation capability #6 adds flows: / requires:).

Counts, measured on the compiled artifact of both locales and again through REST on a booted server

Object Rows Shape
ats_skill 60 18 technical · 14 domain · 12 tool · 6 language · 10 soft
ats_credential_type 15 issuers and validity_months (12–60), 8 with levels
ats_employer 12 10 industries; verified 9 · pending 2 · suspended 1
ats_employer_member 30 1 admin + 1–2 recruiters per employer, display_name mirrored
ats_job 40 published 22 · pending_review 6 · draft 4 · paused 3 · closed 3 · rejected 2; featured 4; titles unique (they are the natural key)
ats_candidate 80 graded experience 0–20 years, 2–6 skills each, avatar empty
ats_candidate_credential 30 5 expire within 90 days (15/33/48/62/80 days out), 2 already expired
ats_application 200 applied 88 · screening 46 · interview 28 · offer 14 · hired 9 · rejected 15 · withdrawn 0; (job, candidate) unique; only on published/paused/closed jobs; closed jobs carry only terminal stages
ats_interview 40 all scheduled, daysFromNow(1..14) + duration("9h".."16h30m"); measured 1.31–14.62 days out at boot
ats_offer 14 one per offer-stage application; pending_approval 3 · sent 5 · approved 3 · draft 2 · declined 1
ats_report 6 job ×2 · employer ×2 · candidate · application; new 3
sys_organization 12 one per employer, slug ats-SLUG, id org_ats_SLUG (what employer_org points at)
sys_user 112 30 employer staff + 2 platform staff + 80 candidates; directory rows, no credentials
sys_member 30 staff → their employer organization (admin / member grade)
sys_user_position 110 employer_admin ×12 · employer_recruiter ×18 · job_seeker ×80; no platform positions granted

Every date is dynamic CEL (daysAgo(n), daysFromNow(n), daysFromNow(n) + duration(...)); an audit over both artifacts asserts that no date-typed field carries a fixed value, that every natural-key reference resolves inside the seed, and that display_name / employer / employer_org / candidate_user are consistent with the rows they denormalise.

Gates (commit a59f716)

$ pnpm validate
  → Running author-time rules (42)...
  ✓ Validation passed (227ms)
  Data: 11 Objects  129 Fields   Security: 5 Positions  6 Permissions
exit 0

$ pnpm lint
  ✓ All checks passed (244ms)
exit 0

$ pnpm typecheck
> tsc --noEmit
exit 0

Boot verification — read this carefully

A clean pnpm dev on this branch does NOT load the ATS rows on origin/main, for two reasons that are outside this card:

  1. ats_* objects are tenant-scoped by default: system-context seed writes are refused once more than one organization exists — declare tenancy: { enabled: false } (ADR-0066) #16 (new) — every ats_* object is tenant-scoped by default; once the seed's 12 employer organizations exist, a system-context write cannot derive an organization and is refused (Insert on 'ats_employer' was REFUSED ... declare tenancy: { enabled: false } (ADR-0066)). Measured: dictionaries and sys_* rows load (339), all 452 ats_* rows are refused.
  2. Stamp hooks throw under the body-only sandbox: ctx.ql is undefined, every write on ats_job / ats_application / ats_interview / ats_offer / ats_employer_member answers 500 #10 — the stamp hooks throw in the body-only sandbox, and the seed path does run them (skipTriggers does not cover app hooks): 31 ats_candidate_credential_stamp throws were logged during the seed load.

Verified with a local, uncommitted override artifact (compiled artifact with tenancy: { enabled: false } on the 11 ats_* objects and hooks: [], booted via pnpm dev --fresh --artifact dist/objectstack.json), for both locales:

  • banner Seeds: ats 791 rows, zero refusals, zero hook throws;
  • REST as the dev admin: every total in the table above, the stage distribution exactly 88/46/28/14/9/15, job statuses 22/6/4/3/3/2 with 4 featured, employers 9/2/1, 3 pending_approval offers, 40 interviews all in the future window, is_expiring === true on exactly 5 credentials, employer_org / candidate_user persisted, can_publish true on the 9 verified employers;
  • OS_SEED_LOCALE=zh boots the Chinese set with identical numbers (张建国 → 生产主管, 杭州办公室 3 号会议室, 药剂技师 · 成都).

Isolation acceptance ("two employer accounts see disjoint pipelines") — blocked by #18 (new). Passwords were set for the seeded admin@quillstone.example and admin@harborline.example via the auth admin API and both signed in; better-auth selected their employer organization as the active one and the request context carried positions: [..., "employer_admin"] and permissions: [..., "ats_employer_admin"] — i.e. the seeded membership → position → permission-set chain resolves end to end. The reads still return zero because the M1 policies are spelled in the deprecated SQL-style IN (current_user.accessible_org_ids) and the bridge cannot bind the variable (DENY (fail closed) ... variable "current_user.accessible_org_ids" is undefined). With the canonical CEL spelling the expected numbers are Quillstone 5 jobs / 27 applications / 2 offers and Harborline 5 / 31 / 2, disjoint.

Recipe to reproduce the override boot: compile (pnpm exec objectstack compile), set hooks to [] and objects[i].tenancy = { enabled: false } for ats_* in dist/objectstack.json, then pnpm dev --fresh --port 4106 --artifact dist/objectstack.json.

Decisions taken inside the card (strike any you disagree with)

  • Platform identity rows are seeded (sys_organization, sys_user, sys_member, sys_user_position), as the issue body asks: accessible_org_ids is derived from sys_member, so an employer without an organization shows zero rows to its own staff. The write path is legitimate — the seed loader writes as the system, the same path the platform's default-organization bootstrap uses; the identity write guard only refuses user-context writes. The shapes are typed through local { name, fields } literals rather than a new dependency on @objectstack/platform-objects.
  • Persona grants are limited to what DESIGN.md §03 ties to a seeded row: access_level admin → employer_admin, recruiter → employer_recruiter, candidates → job_seeker. The two platform staff stay directory rows (they exist so ats_report.handled_by resolves); the dev admin remains the platform persona.
  • Offers hang off the 14 offer-stage applications (an application is in offer because an offer exists). The 9 hired applications carry no offer row because the count is pinned at 14; if the dashboards (Three dashboards and their datasets #8) would rather see accepted offers on hired rows, that is a five-line change in pipeline.ts.
  • ats_report.target_ref stores the target's natural key (title / name / display name): a seed cannot know engine-minted ids. See ats_report.target_ref is free text: the polymorphic pointer cannot be resolved by the seed loader or linked in the UI #17.
  • avatar is empty, per the card and the issue body. DESIGN.md §06 says candidates come 「带头像」; the card overrides it and there is no asset pipeline to seed an image without a hot link — flagged in the report as a §06 amendment.
  • Switching locale on a populated database adds a second ATS row set (organizations and users upsert onto the same keys, ATS rows are keyed by localized natural keys). Documented in src/data/index.ts; switch on a fresh database.
  • The no_sign_in_account_at_boot ERROR line seen on every --fresh boot is a platform ordering artifact (it fires before the dev admin is provisioned) and is not caused by the seed.

Out-of-scope findings (filed, not fixed here)

Suggested landing order: #16 and #10 first, then this PR boots clean on main; the RLS fix completes the isolation acceptance.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG


Generated by Claude Code

Seed data is the demo: the kanban funnel, the interview calendar, the
review queues and the isolation model are all driven from these rows.

One locale-independent skeleton (src/data/shared) is rendered through two
locale packs, so demo-zh mirrors demo-en row for row by construction:
60 skills, 15 credential types, 12 employers (2 pending, 1 suspended),
40 jobs (6 pending_review, 4 featured), 80 candidates, 200 applications
distributed exactly applied 88 / screening 46 / interview 28 / offer 14 /
hired 9 / rejected 15, 40 interviews over the next 14 days, 14 offers
(3 pending_approval), 30 credentials (5 expiring within 90 days), 6 reports,
1 admin + 1-2 recruiters per employer.

Why the platform identity rows are seeded too: every employer-side policy is
`employer_org IN (current_user.accessible_org_ids)`, which is derived from
sys_member, so each employer gets a sys_organization, its staff get sys_user
and sys_member rows, and the persona is granted through sys_user_position
(employer_admin / employer_recruiter / job_seeker only). Without them an
employer shows zero rows to its own staff.

Why every date is CEL: `daysAgo(n)` / `daysFromNow(n)` (+ `duration` for a
wall-clock hour) are evaluated against the loader's pinned `now`, so the
calendar still shows next week a month after the build and two builds stay
byte-identical.

The denormalised fields (display_name mirrors, employer, employer_org,
candidate_user) are written explicitly rather than left to the stamp hooks,
as the card requires. avatar stays empty: no hot-linked placeholders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Seed data: demo-en and demo-zh

2 participants