From e2b53243b7dc23faa2724806dc513a57023cb927 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 18 Jun 2026 13:42:41 +0800 Subject: [PATCH 1/6] feat(ownership): auto-provision canonical owner_id + hand seeded records to first admin Make record ownership correct-by-default (a mistake-proof design for AI/human authors) instead of the opt-in model that silently shipped objects with no working ownership and left seeded demo data owned by nobody a human can log in as. - objectql/applySystemFields: auto-inject a canonical, REASSIGNABLE `owner_id` lookup on user-authored business objects, next to the tenant/audit fields. Withheld for managedBy/sys_* and for `ownership: 'org' | 'none'` opt-outs (Dataverse-style). Once present, insert auto-stamp + owner RLS + owner-keyed views engage automatically. - plugin-security/claimSeedOwnership: invoked from bootstrapPlatformAdmin after the first human is promoted to platform admin, transfers ownership of seeded rows (owner_id NULL or usr_system) to that admin. Ownership twin of org-scoping's claimOrphanOrgRows; idempotent; skips managedBy/sys_*. - bootstrapPlatformAdmin: defensive read of untyped permission-set `description` (fixes a latent dts typecheck error surfaced by the cache invalidation). Tests: registry owner_id injection cases (+5), claimSeedOwnership suite (+6). Full framework suite green (128/128 tasks). Co-Authored-By: Claude Opus 4.8 --- .changeset/seed-ownership-handoff.md | 32 ++++ packages/objectql/src/registry.test.ts | 43 ++++++ packages/objectql/src/registry.ts | 51 +++++++ .../src/bootstrap-platform-admin.ts | 21 ++- .../src/claim-seed-ownership.test.ts | 88 +++++++++++ .../src/claim-seed-ownership.ts | 141 ++++++++++++++++++ packages/plugins/plugin-security/src/index.ts | 2 + 7 files changed, 376 insertions(+), 2 deletions(-) create mode 100644 .changeset/seed-ownership-handoff.md create mode 100644 packages/plugins/plugin-security/src/claim-seed-ownership.test.ts create mode 100644 packages/plugins/plugin-security/src/claim-seed-ownership.ts diff --git a/.changeset/seed-ownership-handoff.md b/.changeset/seed-ownership-handoff.md new file mode 100644 index 0000000000..1b161f011a --- /dev/null +++ b/.changeset/seed-ownership-handoff.md @@ -0,0 +1,32 @@ +--- +"@objectstack/objectql": minor +"@objectstack/plugin-security": minor +--- + +feat(ownership): auto-provision a canonical `owner_id` and hand seeded records to the first admin + +Ownership is now correct-by-default instead of opt-in — closing the gap where +seeded demo data ended up owned by nobody a human can log in as (so "My" views, +owner reports and owner notifications were empty out of the box) and where +author-written objects silently shipped with no working ownership at all. + +- **`applySystemFields` (objectql)** now auto-injects a canonical, reassignable + `owner_id` lookup (→ `sys_user`) on user-authored business objects, alongside + the existing tenant/audit fields. Unlike the audit `*_by` lookups it is NOT + readonly — ownership transfers. Withheld for `managedBy` / `sys_*` tables and + for objects that opt out via `ownership: 'org' | 'none'` (Dataverse-style). The + safe default direction: forgetting the opt-out leaves a harmless spare column, + whereas the old opt-IN model let authors ship objects with broken ownership. + Once present, the existing machinery engages automatically (insert auto-stamp, + owner-scoped RLS, owner-keyed views/reports). + +- **`claimSeedOwnership` (plugin-security)**, invoked from `bootstrapPlatformAdmin` + right after the first human is promoted to platform admin, transfers ownership + of seeded rows (`owner_id` NULL or `usr_system`) to that admin. The ownership + twin of org-scoping's `claimOrphanOrgRows`. Idempotent; skips `managedBy` / + `sys_*`. Authors write plain seed records (no `owner_id`) and the platform — + not the author — performs the handoff, so there is nothing to remember or + mistype. + +Also hardens `bootstrapPlatformAdmin` against a latent dts typecheck error +(defensive read of the untyped `description` on seed permission sets). diff --git a/packages/objectql/src/registry.test.ts b/packages/objectql/src/registry.test.ts index 26946cf2fe..f39a807022 100644 --- a/packages/objectql/src/registry.test.ts +++ b/packages/objectql/src/registry.test.ts @@ -573,6 +573,49 @@ describe('applySystemFields', () => { expect(out.fields.updated_at).toMatchObject({ system: true }); }); + // ── owner_id — canonical, reassignable record owner ────────────────── + it('injects owner_id by default on a business object (reassignable, not readonly)', () => { + const out = applySystemFields(baseLead, { multiTenant: false }); + expect(out.fields.owner_id).toMatchObject({ + type: 'lookup', reference: 'sys_user', system: true, readonly: false, + }); + // Unlike the audit *_by lookups, owner is editable (ownership transfers). + expect(out.fields.owner_id.readonly).toBe(false); + }); + + it('does NOT inject owner_id for managedBy tables', () => { + const platform: any = { name: 'sys_audit_log', managedBy: 'platform', fields: { msg: { type: 'text' } } }; + const out = applySystemFields(platform, { multiTenant: false }); + expect(out.fields.owner_id).toBeUndefined(); + }); + + it('does NOT inject owner_id for sys_* objects', () => { + const sysish: any = { name: 'sys_widget', fields: { msg: { type: 'text' } } }; + const out = applySystemFields(sysish, { multiTenant: false }); + expect(out.fields.owner_id).toBeUndefined(); + // audit/tenant fields are still injected for non-managed sys_ objects + expect(out.fields.created_at).toBeDefined(); + }); + + it('respects ownership: "org" / "none" opt-out (audit + tenant still injected)', () => { + for (const ownership of ['org', 'none'] as const) { + const opted: any = { ...baseLead, ownership }; + const out = applySystemFields(opted, { multiTenant: false }); + expect(out.fields.owner_id).toBeUndefined(); + expect(out.fields.created_at).toBeDefined(); + } + }); + + it('does NOT overwrite an author-declared owner_id', () => { + const declared: any = { + name: 'lead', + fields: { owner_id: { type: 'lookup', reference: 'sys_user', label: 'Custom Owner', readonly: true } }, + }; + const out = applySystemFields(declared, { multiTenant: false }); + expect(out.fields.owner_id.label).toBe('Custom Owner'); + expect(out.fields.owner_id.readonly).toBe(true); + }); + it('SchemaRegistry({ multiTenant: true }) auto-injects on registerObject', () => { const reg = new SchemaRegistry({ multiTenant: true }); reg.registerObject({ name: 'lead', fields: { first_name: { type: 'text' } } } as any, 'crm', 'crm', 'own'); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 41832dc380..0c3fc5af84 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -186,6 +186,22 @@ export interface SchemaRegistryOptions { * timestamps; the `*_by` lookups are filled by the runtime when an * authenticated session is present (NULL otherwise — e.g. seeded * rows). + * - `owner_id` — canonical, *reassignable* record owner (lookup to + * `sys_user`). Auto-provisioned by DEFAULT on user-authored business + * objects so ownership is correct-by-default for AI/human authors who + * would otherwise forget to declare it (or reinvent a custom `owner` + * lookup the platform can't see). Once present, the existing machinery + * engages automatically: SecurityPlugin auto-stamps it to the acting + * user on insert (step 3.5), owner-scoped RLS / "My" views / owner + * reports key off it, and the first-admin bootstrap hands seeded rows + * (owner_id NULL) to the promoted admin. Unlike `created_by` it is + * editable (`readonly: false`) — ownership transfers; provenance does + * not. Excluded for `managedBy` / `sys_*` tables and any object that + * opts out via `ownership: 'org' | 'none'` (Dataverse-style: a + * catalog/junction table that has no per-record owner). Forgetting the + * opt-out is harmless (a spare nullable column), whereas forgetting to + * ADD ownership — the failure mode we are eliminating — silently breaks + * every owner-keyed feature. */ export function applySystemFields( schema: ServiceObject, @@ -232,6 +248,22 @@ export function applySystemFields( const wantTenant = sf?.tenant !== false && !tenancyDisabled; const wantAudit = sf?.audit !== false; + // Ownership is auto-provisioned by DEFAULT on user-authored business + // objects (correct-by-default for AI authors). It is withheld only where a + // per-record owner is meaningless: any platform-managed table (`managedBy` + // is set — config/append-only/system/platform; `better-auth` already + // returned above), the `sys_*` namespace, or an explicit opt-out via + // `ownership: 'org' | 'none'` on the schema (Dataverse-style — catalog / + // junction tables). Note this is the SAFE default direction: forgetting the + // opt-out leaves a harmless spare column, whereas the old opt-IN model let + // authors silently ship objects with no working ownership at all. + const ownership = (schema as any).ownership as 'user' | 'org' | 'none' | undefined; + const wantOwner = + ownership !== 'org' && + ownership !== 'none' && + !(schema as any).managedBy && + !schema.name.startsWith('sys_'); + const additions: Record = {}; if (wantTenant && !schema.fields?.organization_id) { @@ -294,6 +326,25 @@ export function applySystemFields( } } + // Canonical reassignable owner. `system: true` marks it platform-provided + // (so tooling/migrations recognise it), but — unlike the audit `*_by` + // lookups — it is NOT `readonly`: ownership is transferable, so it stays + // editable in forms and assignable via the API. SecurityPlugin auto-stamps + // it to the acting user on insert when left NULL. + if (wantOwner && !schema.fields?.owner_id) { + additions.owner_id = { + type: 'lookup', + reference: 'sys_user', + label: 'Owner', + required: false, + readonly: false, + system: true, + description: + 'Record owner (auto-stamped to the creating user on insert; reassignable). ' + + 'Drives owner-scoped views, reports and notifications.', + }; + } + if (Object.keys(additions).length === 0) return schema; return { diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index 0ff446b687..3dd0e1a355 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -21,6 +21,7 @@ import type { PermissionSet } from '@objectstack/spec/security'; import { SystemUserId } from '@objectstack/spec/system'; +import { claimSeedOwnership } from './claim-seed-ownership.js'; interface BootstrapOptions { /** Logger from PluginContext. */ @@ -67,6 +68,8 @@ export async function bootstrapPlatformAdmin( seeded: number; adminPromoted: boolean; reason?: string; + /** Count of seeded rows re-owned to the freshly-promoted admin. */ + ownershipClaimed?: number; }> { const logger = options.logger; if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') { @@ -87,7 +90,10 @@ export async function bootstrapPlatformAdmin( id, name: ps.name, label: ps.label ?? ps.name, - description: ps.description ?? null, + // `description` is not part of the typed PermissionSet shape (name/label + // only); read it defensively so a runtime-provided description still + // persists without tripping the dts typecheck. + description: (ps as any).description ?? null, object_permissions: JSON.stringify(ps.objects ?? {}), field_permissions: JSON.stringify(ps.fields ?? {}), // Persist the remaining permset facets so the runtime resolver @@ -163,5 +169,16 @@ export async function bootstrapPlatformAdmin( } logger?.info?.(`[security] first user promoted to platform admin: ${target.email ?? target.id}`); - return { seeded: seededCount, adminPromoted: true }; + // Hand seeded business records (owner_id NULL / usr_system) to the freshly + // promoted admin so owner-keyed UX works out of the box. Best-effort and + // idempotent — failures here must not undo the promotion above. + let ownershipClaimed = 0; + try { + const claims = await claimSeedOwnership(ql, target.id, { logger }); + ownershipClaimed = claims.reduce((s, c) => s + c.count, 0); + } catch (e) { + logger?.warn?.('[security] seed ownership handoff failed', { error: (e as Error).message }); + } + + return { seeded: seededCount, adminPromoted: true, ownershipClaimed }; } diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership.test.ts b/packages/plugins/plugin-security/src/claim-seed-ownership.test.ts new file mode 100644 index 0000000000..a7c08492aa --- /dev/null +++ b/packages/plugins/plugin-security/src/claim-seed-ownership.test.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { claimSeedOwnership } from './claim-seed-ownership.js'; + +const SYSTEM = 'usr_system'; +const ADMIN = 'usr_admin_human'; + +function makeQL(schemas: any[], rowsByObject: Record) { + const updates: { object: string; data: any }[] = []; + const ql: any = { + registry: { getAllObjects: () => schemas }, + find: vi.fn(async (object: string, query: any) => { + const all = rowsByObject[object] ?? []; + const w = query?.where ?? {}; + if ('owner_id' in w) { + return all.filter((r) => (r.owner_id ?? null) === (w.owner_id ?? null)); + } + return all; + }), + update: vi.fn(async (object: string, data: any) => { + updates.push({ object, data }); + const row = (rowsByObject[object] ?? []).find((r) => r.id === data.id); + if (row) row.owner_id = data.owner_id; + return row; + }), + }; + return { ql, updates }; +} + +describe('claimSeedOwnership', () => { + it('returns [] when registry is unavailable', async () => { + const ql: any = { find: vi.fn(), update: vi.fn() }; + expect(await claimSeedOwnership(ql, ADMIN)).toEqual([]); + }); + + it('no-ops when the target is empty or the system user', async () => { + const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }] }]; + const { ql, updates } = makeQL(schemas, { crm_lead: [{ id: 'l1', owner_id: null }] }); + expect(await claimSeedOwnership(ql, '')).toEqual([]); + expect(await claimSeedOwnership(ql, SYSTEM)).toEqual([]); + expect(updates).toHaveLength(0); + }); + + it('skips managedBy and sys_* tables', async () => { + const schemas = [ + { name: 'sys_user', managedBy: 'better-auth', fields: [{ name: 'owner_id' }] }, + { name: 'sys_widget', fields: [{ name: 'owner_id' }] }, + ]; + const { ql, updates } = makeQL(schemas, { + sys_user: [{ id: 'u1', owner_id: null }], + sys_widget: [{ id: 'w1', owner_id: null }], + }); + expect(await claimSeedOwnership(ql, ADMIN)).toEqual([]); + expect(updates).toHaveLength(0); + }); + + it('skips objects without an owner_id field', async () => { + const schemas = [{ name: 'crm_pricebook', fields: [{ name: 'name' }] }]; + const { ql, updates } = makeQL(schemas, { crm_pricebook: [{ id: 'p1' }] }); + expect(await claimSeedOwnership(ql, ADMIN)).toEqual([]); + expect(updates).toHaveLength(0); + }); + + it('re-owns NULL and usr_system rows to the admin, leaving human-owned rows untouched', async () => { + const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }] }]; + const rows = [ + { id: 'l1', owner_id: null }, // claimed (author left unset) + { id: 'l2', owner_id: SYSTEM }, // claimed (seed identity) + { id: 'l3', owner_id: 'usr_someone' },// untouched (already human-owned) + ]; + const { ql, updates } = makeQL(schemas, { crm_lead: rows }); + const result = await claimSeedOwnership(ql, ADMIN); + + expect(result).toEqual([{ object: 'crm_lead', count: 2 }]); + expect(updates.map((u) => u.data.id).sort()).toEqual(['l1', 'l2']); + expect(updates.every((u) => u.data.owner_id === ADMIN)).toBe(true); + expect(rows.find((r) => r.id === 'l3')!.owner_id).toBe('usr_someone'); + }); + + it('is idempotent — a second run claims nothing', async () => { + const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }] }]; + const { ql } = makeQL(schemas, { crm_lead: [{ id: 'l1', owner_id: null }] }); + await claimSeedOwnership(ql, ADMIN); + const second = await claimSeedOwnership(ql, ADMIN); + expect(second).toEqual([]); + }); +}); diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership.ts b/packages/plugins/plugin-security/src/claim-seed-ownership.ts new file mode 100644 index 0000000000..845efc1bc5 --- /dev/null +++ b/packages/plugins/plugin-security/src/claim-seed-ownership.ts @@ -0,0 +1,141 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * claimSeedOwnership — hand seeded business records to the first platform admin. + * + * Seed data is loaded during app-plugin `start()`, which runs BEFORE any human + * user exists (the login admin is minted later, on `kernel:ready`). So seeded + * rows land with `owner_id = NULL` (the author left it unset — the correct, + * mistake-proof default) or `owner_id = usr_system` (the deterministic seed + * identity bound to `os.user`). Either way the record is owned by nobody a + * human can log in as, so owner-keyed UX — "My" views, owner reports, owner + * notifications — is empty out of the box. + * + * This helper runs **once**, right after `bootstrapPlatformAdmin` promotes the + * first human user to platform admin, and transfers ownership of those orphan + * rows to that admin. It is the ownership twin of org-scoping's + * `claimOrphanOrgRows` (which back-fills `organization_id`): walk every + * user-authored object that declares the canonical `owner_id` column, and + * re-own the rows that no human owns yet. + * + * Mistake-proof by construction: authors write plain seed records (no + * `owner_id`), and the platform — not the author — performs the handoff. There + * is nothing to remember and nothing to mistype. + * + * Idempotent: only NULL / `usr_system`-owned rows are touched, so once a real + * admin owns them a re-run is a no-op. `managedBy` and `sys_*` tables are + * skipped (their ownership, if any, is platform-controlled). + */ + +import type { ServiceObject } from '@objectstack/spec/data'; +import { SystemUserId } from '@objectstack/spec/system'; + +interface ClaimOwnershipOptions { + logger?: { + info: (message: string, meta?: Record) => void; + warn: (message: string, meta?: Record) => void; + }; +} + +const SYSTEM_CTX = { isSystem: true }; + +function hasOwnerField(schema: ServiceObject): boolean { + const fields: any = (schema as any)?.fields; + if (!fields) return false; + if (Array.isArray(fields)) { + return fields.some((f) => f?.name === 'owner_id'); + } + return Object.prototype.hasOwnProperty.call(fields, 'owner_id'); +} + +/** + * Re-own every orphan seed row (owner_id NULL or usr_system) to `adminUserId`. + * + * Walks `ql.registry.getAllObjects()`, filters to schemas that + * (a) are not `managedBy` (skip sys_/auth/platform tables), + * (b) are not `sys_*`-namespaced, + * (c) declare an `owner_id` field, + * and updates the unowned rows as `isSystem`. Returns a per-object summary. + */ +export async function claimSeedOwnership( + ql: any, + adminUserId: string, + options: ClaimOwnershipOptions = {}, +): Promise<{ object: string; count: number }[]> { + const logger = options.logger; + if (!adminUserId || adminUserId === SystemUserId.SYSTEM) return []; + if (!ql || typeof ql.update !== 'function' || typeof ql.find !== 'function') { + return []; + } + const registry = (ql as any).registry; + if (!registry || typeof registry.getAllObjects !== 'function') { + logger?.warn?.('[security] claimSeedOwnership: registry unavailable'); + return []; + } + + const schemas: ServiceObject[] = registry.getAllObjects(); + const results: { object: string; count: number }[] = []; + + for (const schema of schemas) { + if (!schema?.name) continue; + if ((schema as any).managedBy) continue; + if (schema.name.startsWith('sys_')) continue; + if (!hasOwnerField(schema)) continue; + + try { + // Unowned = owner_id NULL (author left it unset) OR usr_system (seed + // identity). Two narrow scans keep the where-clauses driver-portable + // instead of relying on an OR/IN predicate. + const seen = new Set(); + const ids: string[] = []; + for (const where of [{ owner_id: null }, { owner_id: SystemUserId.SYSTEM }]) { + const rows = await ql.find( + schema.name, + { where, limit: 10_000, fields: ['id'] }, + { context: SYSTEM_CTX }, + ); + const list: any[] = Array.isArray(rows) + ? rows + : Array.isArray(rows?.records) + ? rows.records + : []; + for (const r of list) { + if (r?.id && !seen.has(r.id)) { + seen.add(r.id); + ids.push(r.id); + } + } + } + if (ids.length === 0) continue; + + let updated = 0; + for (const id of ids) { + try { + await ql.update( + schema.name, + { id, owner_id: adminUserId }, + { context: SYSTEM_CTX }, + ); + updated += 1; + } catch (e) { + logger?.warn?.(`[security] claimSeedOwnership failed for ${schema.name}:${id}`, { + error: (e as Error).message, + }); + } + } + if (updated > 0) results.push({ object: schema.name, count: updated }); + } catch (e) { + logger?.warn?.(`[security] claimSeedOwnership scan failed for ${schema.name}`, { + error: (e as Error).message, + }); + } + } + + if (results.length > 0) { + const total = results.reduce((s, r) => s + r.count, 0); + logger?.info?.(`[security] handed ${total} seeded record(s) to first admin ${adminUserId}`, { + breakdown: results, + }); + } + return results; +} diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index e410a9f4c3..8690cf2679 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -23,3 +23,5 @@ export { reconcileOrgAdminGrant, backfillOrgAdminGrants, } from './auto-org-admin-grant.js'; +export { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +export { claimSeedOwnership } from './claim-seed-ownership.js'; From b6c66e2e8e27454ec64a620a451a728ea3c6491e Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 18 Jun 2026 14:21:49 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(seed):=20provision=20usr=5Fsystem=20la?= =?UTF-8?q?zily=20=E2=80=94=20only=20when=20a=20seed=20embeds=20os.user.id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default ownership model leaves seed `owner_id` NULL and lets the first-admin handoff (claimSeedOwnership) re-own those rows, so most bundles never reference `os.user`. Provision the non-loginable `usr_system` placeholder lazily — only when a seed dataset actually embeds `cel`os.user.id`` (detected via the serialized CEL source) — so a typical app never creates it. `os.org` is unaffected (derived from organizationId in the loader). Co-Authored-By: Claude Opus 4.8 --- packages/runtime/src/app-plugin.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 1120719d79..d8fa531ecb 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -472,12 +472,20 @@ export class AppPlugin implements Plugin { object: d.object, })); - // Resolve the seed identity (os.user / os.org) BEFORE any seed - // runs. Deterministically ensures a non-loginable system user - // exists so identity-derived seed values (e.g. - // `owner_id: cel`os.user.id``) resolve at boot — before the - // first human sign-up. See ensureSeedIdentity(). - const seedIdentity = await this.ensureSeedIdentity(ql, ctx.logger); + // Resolve the seed identity (os.user) ONLY when a seed actually + // references it. The modern ownership model leaves `owner_id` + // unset in seeds and lets the first-admin handoff + // (`claimSeedOwnership`) re-own NULL rows to the promoted admin — + // so most bundles never touch `os.user`, and the non-loginable + // `usr_system` placeholder need not exist at all. We provision it + // lazily (backward-compatible) only for the rare seed that embeds + // `cel`os.user.id`` (detected via the serialized CEL `source`). + // `os.org` is unaffected — the loader derives it from + // `organizationId`, not from this identity. + const seedsReferenceOsUser = JSON.stringify(normalizedDatasets).includes('os.user'); + const seedIdentity = seedsReferenceOsUser + ? await this.ensureSeedIdentity(ql, ctx.logger) + : undefined; // Stash datasets on a kernel service so SecurityPlugin's // sys_organization insert hook can replay them per-tenant From 152cdd06e061d4d8bc23a1327757360120bd57cf Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 18 Jun 2026 14:23:25 +0800 Subject: [PATCH 3/6] docs(seed): describe usr_system as a lazy os.user fallback, not the seed owner Co-Authored-By: Claude Opus 4.8 --- packages/runtime/src/app-plugin.ts | 28 +++++++++++-------- .../spec/src/system/constants/system-names.ts | 17 +++++++---- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index d8fa531ecb..bfaeccc171 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -534,10 +534,10 @@ export class AppPlugin implements Plugin { defaultMode: 'upsert', multiPass: true, organizationId, - // Bind os.user (system identity) and os.org (this - // tenant) so identity-derived seed values resolve - // per-org. org.id falls back to organizationId - // inside the loader when identity.org is absent. + // `os.org` is derived from organizationId inside + // the loader. `seedIdentity` (os.user) is undefined + // unless a seed embeds `cel`os.user.id`` — see the + // lazy guard where it is resolved. identity: seedIdentity, }, }); @@ -668,14 +668,20 @@ export class AppPlugin implements Plugin { } /** - * Resolve the identity bound to `os.user` / `os.org` for seed CEL values. + * Lazily provision the identity bound to `os.user` for seed CEL values. * - * On a fresh boot there are zero users until the first human sign-up - * (which the SeedLoader runs *before*), so identity-derived seeds like - * `owner_id: cel`os.user.id`` had nothing to resolve against and were - * dropped silently. To make seeds deterministic and self-sufficient we - * upsert a single non-loginable **system user** (`usr_system`) and bind - * it as `os.user`. + * Called ONLY when a seed dataset actually embeds `cel`os.user.id`` (see the + * caller's `seedsReferenceOsUser` guard). The modern ownership model does + * not need this: seeds leave `owner_id` NULL and the first-admin bootstrap + * (`claimSeedOwnership` in plugin-security) re-owns those rows to the + * promoted human admin — so a typical bundle never references `os.user`, and + * the `usr_system` placeholder is never created. + * + * It survives only as a backward-compatible fallback for the rare seed that + * still embeds `cel`os.user.id``: such a seed runs before the first human + * sign-up, so we upsert a single non-loginable **system user** + * (`usr_system`) and bind it as `os.user` so the expression resolves instead + * of dropping the record. * * Why a dedicated system user rather than the login admin: * - `sys_user` is better-auth-managed and schema-locked (ADR-0010); the diff --git a/packages/spec/src/system/constants/system-names.ts b/packages/spec/src/system/constants/system-names.ts index c56f371c90..059eb112a5 100644 --- a/packages/spec/src/system/constants/system-names.ts +++ b/packages/spec/src/system/constants/system-names.ts @@ -80,13 +80,18 @@ export type SystemObjectName = typeof SystemObjectName[keyof typeof SystemObject */ export const SystemUserId = { /** - * Deterministic, non-loginable service identity that owns seeded data. + * Deterministic, non-loginable service identity — a lazily-provisioned + * fallback for seeds that embed `cel`os.user.id``. * - * Provisioned before seed loading so identity-derived seed values - * (`owner_id: cel`os.user.id``) resolve even on a fresh boot — before the - * first human sign-up. Has no `sys_account` credential, so it cannot sign - * in; analogous to Salesforce's "Automated Process" user. The human login - * admin is minted separately through better-auth. + * The default ownership model does NOT use this: seeds leave `owner_id` NULL + * and the first-admin bootstrap (`claimSeedOwnership`) re-owns those rows to + * the promoted human admin, so a typical bundle never references `os.user` + * and this row is never created. It is upserted (by the runtime's + * `ensureSeedIdentity`) ONLY when a seed still embeds `cel`os.user.id``, so + * that expression resolves before the first human sign-up instead of dropping + * the record. Has no `sys_account` credential, so it cannot sign in; + * analogous to Salesforce's "Automated Process" user. The human login admin + * is minted separately through better-auth. */ SYSTEM: 'usr_system', } as const; From 433589c4d885dbdde1752a143d45078374dea877 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 18 Jun 2026 14:26:42 +0800 Subject: [PATCH 4/6] chore(changeset): include runtime lazy usr_system provisioning Co-Authored-By: Claude Opus 4.8 --- .changeset/seed-ownership-handoff.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.changeset/seed-ownership-handoff.md b/.changeset/seed-ownership-handoff.md index 1b161f011a..aa579de273 100644 --- a/.changeset/seed-ownership-handoff.md +++ b/.changeset/seed-ownership-handoff.md @@ -1,6 +1,7 @@ --- "@objectstack/objectql": minor "@objectstack/plugin-security": minor +"@objectstack/runtime": minor --- feat(ownership): auto-provision a canonical `owner_id` and hand seeded records to the first admin @@ -28,5 +29,12 @@ author-written objects silently shipped with no working ownership at all. not the author — performs the handoff, so there is nothing to remember or mistype. +- **`usr_system` is now provisioned lazily (runtime)** — only when a seed + dataset actually embeds `cel`os.user.id``. Because the default model leaves + `owner_id` NULL and relies on the handoff above, a typical bundle never + references `os.user`, so the non-loginable `usr_system` placeholder is never + created. It survives purely as a backward-compatible fallback; `os.org` is + unaffected (derived from `organizationId` in the loader). + Also hardens `bootstrapPlatformAdmin` against a latent dts typecheck error (defensive read of the untyped `description` on seed permission sets). From bbaa560dc04fed7d302b0fa014b92391202835b3 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 18 Jun 2026 14:45:10 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat(seed):=20never=20mint=20usr=5Fsystem?= =?UTF-8?q?=20=E2=80=94=20resolve=20os.user.id=20to=20NULL=20at=20seed=20t?= =?UTF-8?q?ime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates the usr_system placeholder entirely (not just lazily). The seed loader now binds os.user to a NULL identity when none is supplied, so `owner_id: cel`os.user.id`` resolves to NULL instead of crashing or dropping the record — semantically "owned by whoever becomes the first admin", which the first-admin handoff (claimSeedOwnership) then fills in. - objectql/seed-loader: bind `user: identity?.user ?? { id: null }`; the unresolved-expression error message no longer tells authors to provision a system user (os.user.id now resolves to null). - runtime/app-plugin: remove `ensureSeedIdentity` (the only code that inserted a usr_system row) and its SystemUserId import; pass no seed identity. - spec: SystemUserId.SYSTEM redocumented as a reserved id that is NO LONGER auto-provisioned — kept only so legacy DBs' exclusion guards / ownership handoff still recognize a pre-existing usr_system row. Tests: new seed-loader-os-user test (os.user.id → null under null identity, → real id when supplied); updated the runtime seed-loader test that asserted the old fail-loud-on-unbound contract. objectql 665, runtime 388, plugin-security 97 all green. Co-Authored-By: Claude Opus 4.8 --- .../objectql/src/seed-loader-os-user.test.ts | 37 ++++++++ packages/objectql/src/seed-loader.ts | 24 +++-- packages/runtime/src/app-plugin.ts | 95 ++----------------- packages/runtime/src/seed-loader.test.ts | 20 ++-- .../spec/src/system/constants/system-names.ts | 24 ++--- 5 files changed, 87 insertions(+), 113 deletions(-) create mode 100644 packages/objectql/src/seed-loader-os-user.test.ts diff --git a/packages/objectql/src/seed-loader-os-user.test.ts b/packages/objectql/src/seed-loader-os-user.test.ts new file mode 100644 index 0000000000..067b14cf07 --- /dev/null +++ b/packages/objectql/src/seed-loader-os-user.test.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { resolveSeedRecord } from '@objectstack/formula'; + +/** + * The SeedLoader binds `os.user` to a NULL identity (`{ id: null }`) when no + * real user exists at seed time (the normal case). This proves the resolution + * behavior that lets us drop the `usr_system` placeholder entirely: + * `owner_id: cel`os.user.id`` resolves to NULL (not a crash, not a dropped + * record), and the first-admin handoff later claims the NULL-owned row. + */ +describe('seed os.user binding (usr_system-free)', () => { + const cel = (source: string) => ({ dialect: 'cel', source }); + + it('resolves os.user.id to null under a NULL identity (no error, no drop)', () => { + const rec = { name: 'Acme', owner_id: cel('os.user.id') }; + const ctx = { now: new Date(), user: { id: null }, org: undefined, env: {} }; + + const result = resolveSeedRecord(rec as any, ctx as any); + + expect(result.ok).toBe(true); + expect((result as any).value.owner_id).toBeNull(); + // Non-identity dynamic values still resolve normally alongside it. + expect((result as any).value.name).toBe('Acme'); + }); + + it('still resolves a real os.user.id when an identity IS supplied (per-org replay)', () => { + const rec = { owner_id: cel('os.user.id') }; + const ctx = { now: new Date(), user: { id: 'usr_real_admin' }, org: undefined, env: {} }; + + const result = resolveSeedRecord(rec as any, ctx as any); + + expect(result.ok).toBe(true); + expect((result as any).value.owner_id).toBe('usr_real_admin'); + }); +}); diff --git a/packages/objectql/src/seed-loader.ts b/packages/objectql/src/seed-loader.ts index 420dfbbbd9..e6e5bf3198 100644 --- a/packages/objectql/src/seed-loader.ts +++ b/packages/objectql/src/seed-loader.ts @@ -208,14 +208,23 @@ export class SeedLoaderService implements ISeedLoaderService { const seedNow = new Date(); // Identity/context bound to seed CEL expressions. `os.user` / `os.org` - // resolve from here, so `owner_id: cel\`os.user.id\`` works. When no - // identity is supplied, `os.user` / `os.org` are simply unbound and any - // record that references them fails loudly below (rather than silently - // writing a raw Expression envelope into the column). + // resolve from here, so `owner_id: cel\`os.user.id\`` works. + // + // When no real user identity is supplied (the normal case — seeds run + // before the first human sign-up), `os.user` is bound to a NULL identity + // (`{ id: null }`) rather than left undefined. This makes `os.user.id` + // resolve to `null` instead of crashing the expression, so a seed's + // `owner_id: cel\`os.user.id\`` simply lands NULL — semantically "owned by + // whoever becomes the first admin", which the first-admin handoff + // (`claimSeedOwnership`) then fills in. The platform therefore never has to + // mint a placeholder `usr_system` row just to satisfy this expression. const seedIdentity = config.identity; const baseEvalCtx = { now: seedNow, - user: seedIdentity?.user, + // `id: null` is a legitimate seed-time state (the owning admin does not + // exist yet) that the formula EvalContext's `user.id: string` type does + // not yet model — cast the fallback so `os.user.id` evaluates to null. + user: seedIdentity?.user ?? ({ id: null } as unknown as NonNullable['user']), // Fall back to the per-tenant organizationId so `os.org.id` resolves // during per-org replay even without an explicit identity.org. org: seedIdentity?.org ?? (config.organizationId ? { id: config.organizationId } : undefined), @@ -245,8 +254,9 @@ export class SeedLoaderService implements ISeedLoaderService { recordIndex: i, message: `Cannot resolve dynamic seed values for ${objectName} record #${i}: ${seedResult.error.message}. ` + - 'Records using cel`os.user.id` / cel`os.org.id` require a seed identity — ' + - 'ensure a system/admin user exists before seeding (see SeedLoaderConfig.identity).', + '`os.user.id` resolves to null at seed time (the owning admin does not exist yet) and ' + + 'owner-style fields are assigned by the first-admin handoff — so a required, non-owner ' + + 'field must not depend on it. Provide a literal value or make the field optional.', }; errors.push(error); allErrors.push(error); diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index bfaeccc171..d39a0808bf 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -5,7 +5,6 @@ import { readEnvWithDeprecation } from '@objectstack/types'; import { SeedLoaderService } from './seed-loader.js'; import { loadDisabledPackageIds } from './package-state-store.js'; import type { IMetadataService, II18nService } from '@objectstack/spec/contracts'; -import { SystemUserId } from '@objectstack/spec/system'; import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; import { hookBodyRunnerFactory, actionBodyRunnerFactory } from './sandbox/body-runner.js'; @@ -472,20 +471,14 @@ export class AppPlugin implements Plugin { object: d.object, })); - // Resolve the seed identity (os.user) ONLY when a seed actually - // references it. The modern ownership model leaves `owner_id` - // unset in seeds and lets the first-admin handoff - // (`claimSeedOwnership`) re-own NULL rows to the promoted admin — - // so most bundles never touch `os.user`, and the non-loginable - // `usr_system` placeholder need not exist at all. We provision it - // lazily (backward-compatible) only for the rare seed that embeds - // `cel`os.user.id`` (detected via the serialized CEL `source`). - // `os.org` is unaffected — the loader derives it from - // `organizationId`, not from this identity. - const seedsReferenceOsUser = JSON.stringify(normalizedDatasets).includes('os.user'); - const seedIdentity = seedsReferenceOsUser - ? await this.ensureSeedIdentity(ql, ctx.logger) - : undefined; + // No seed identity is provisioned. The platform never mints a + // placeholder `usr_system`: seeds leave `owner_id` unset (or use + // `cel`os.user.id``, which the loader resolves to NULL since the + // owning admin does not exist yet), and the first-admin handoff + // (`claimSeedOwnership`) re-owns those NULL rows to the promoted + // admin. `os.org` is still derived from `organizationId` inside the + // loader, independent of this. + const seedIdentity = undefined; // Stash datasets on a kernel service so SecurityPlugin's // sys_organization insert hook can replay them per-tenant @@ -667,78 +660,6 @@ export class AppPlugin implements Plugin { this.emitCatalogEvent(ctx, 'app:unregistered', sys); } - /** - * Lazily provision the identity bound to `os.user` for seed CEL values. - * - * Called ONLY when a seed dataset actually embeds `cel`os.user.id`` (see the - * caller's `seedsReferenceOsUser` guard). The modern ownership model does - * not need this: seeds leave `owner_id` NULL and the first-admin bootstrap - * (`claimSeedOwnership` in plugin-security) re-owns those rows to the - * promoted human admin — so a typical bundle never references `os.user`, and - * the `usr_system` placeholder is never created. - * - * It survives only as a backward-compatible fallback for the rare seed that - * still embeds `cel`os.user.id``: such a seed runs before the first human - * sign-up, so we upsert a single non-loginable **system user** - * (`usr_system`) and bind it as `os.user` so the expression resolves instead - * of dropping the record. - * - * Why a dedicated system user rather than the login admin: - * - `sys_user` is better-auth-managed and schema-locked (ADR-0010); the - * password lives in `sys_account`, so a *loginable* admin can only be - * minted through better-auth (the CLI does this via HTTP sign-up after - * boot). A raw insert here would bypass those invariants. - * - `usr_system` is an owner identity only (no credential row), analogous - * to Salesforce's "Automated Process" user. The human admin is created - * independently and need not be the seed owner. - * - * Idempotent: matches by the stable id, inserts once, reuses thereafter. - * Failures are non-fatal (logged) — records that actually need `os.user` - * then fail loudly in the loader with an actionable message. - */ - private async ensureSeedIdentity( - ql: any, - logger: PluginContext['logger'], - ): Promise<{ user: { id: string; role: string; email: string } }> { - // Deterministic, non-loginable service identity that owns seeded data. - const SYSTEM_USER_ID = SystemUserId.SYSTEM; - const SYSTEM_USER_EMAIL = 'system@objectstack.local'; - const identity = { user: { id: SYSTEM_USER_ID, role: 'system', email: SYSTEM_USER_EMAIL } }; - const opts = { context: { isSystem: true } } as any; - - try { - const existing = await (ql as any).find( - 'sys_user', - { where: { id: SYSTEM_USER_ID }, limit: 1 }, - opts, - ); - if (Array.isArray(existing) && existing.length > 0) { - return identity; - } - await (ql as any).insert( - 'sys_user', - { - id: SYSTEM_USER_ID, - name: 'System', - email: SYSTEM_USER_EMAIL, - email_verified: true, - role: 'system', - }, - opts, - ); - logger.info( - `[Seeder] Provisioned deterministic system user (${SYSTEM_USER_ID}) as seed owner — binds os.user for identity-derived seed values`, - ); - } catch (err: any) { - // Non-fatal: identity-dependent records will fail loudly in the - // loader; identity-free records still seed normally. - logger.warn('[Seeder] Failed to ensure system seed user; os.user-dependent seeds may be dropped', { - error: err?.message ?? String(err), - }); - } - return identity; - } - /** * Emit a kernel hook so the control-plane `AppCatalogService` can * upsert / delete the corresponding `sys_app` row. Silently no-ops diff --git a/packages/runtime/src/seed-loader.test.ts b/packages/runtime/src/seed-loader.test.ts index 8b2c46d186..6e90eb87c2 100644 --- a/packages/runtime/src/seed-loader.test.ts +++ b/packages/runtime/src/seed-loader.test.ts @@ -1102,7 +1102,7 @@ describe('SeedLoaderService', () => { ); }); - it('fails loudly (no raw envelope written) when os.user is unbound', async () => { + it('resolves os.user.id to NULL (not a drop) when no identity is supplied', async () => { const metadata = createMockMetadata({ note: { name: 'note', fields: { name: { type: 'text' }, author: { type: 'text' } } }, }); @@ -1119,16 +1119,20 @@ describe('SeedLoaderService', () => { records: [{ name: 'N1', author: cel('os.user.id') }], }, ], - // No identity → os.user unbound → record must be dropped, not written. + // No identity → the loader binds os.user to a NULL identity, so + // `os.user.id` resolves to null and the record seeds with author=null. + // The platform never mints a `usr_system` placeholder; owner-style + // fields are filled later by the first-admin handoff. config: baseConfig(), }); - expect(result.success).toBe(false); - expect(result.summary.totalErrored).toBe(1); - expect(result.errors).toHaveLength(1); - expect(result.errors[0].message).toContain('os.user'); - // Critically: the unresolved Expression envelope is NEVER persisted. - expect(engine.insert).not.toHaveBeenCalled(); + expect(result.success).toBe(true); + expect(result.summary.totalErrored).toBe(0); + expect(engine.insert).toHaveBeenCalledWith( + 'note', + expect.objectContaining({ name: 'N1', author: null }), + expect.anything(), + ); }); it('falls back os.org.id to organizationId during per-tenant replay', async () => { diff --git a/packages/spec/src/system/constants/system-names.ts b/packages/spec/src/system/constants/system-names.ts index 059eb112a5..77af026c9b 100644 --- a/packages/spec/src/system/constants/system-names.ts +++ b/packages/spec/src/system/constants/system-names.ts @@ -80,18 +80,20 @@ export type SystemObjectName = typeof SystemObjectName[keyof typeof SystemObject */ export const SystemUserId = { /** - * Deterministic, non-loginable service identity — a lazily-provisioned - * fallback for seeds that embed `cel`os.user.id``. + * Reserved well-known id for the legacy non-loginable system service account. * - * The default ownership model does NOT use this: seeds leave `owner_id` NULL - * and the first-admin bootstrap (`claimSeedOwnership`) re-owns those rows to - * the promoted human admin, so a typical bundle never references `os.user` - * and this row is never created. It is upserted (by the runtime's - * `ensureSeedIdentity`) ONLY when a seed still embeds `cel`os.user.id``, so - * that expression resolves before the first human sign-up instead of dropping - * the record. Has no `sys_account` credential, so it cannot sign in; - * analogous to Salesforce's "Automated Process" user. The human login admin - * is minted separately through better-auth. + * NO LONGER AUTO-PROVISIONED. Ownership now works without it: seeds leave + * `owner_id` NULL (and `cel`os.user.id`` resolves to NULL at seed time, since + * the owning admin does not exist yet), and the first-admin bootstrap + * (`claimSeedOwnership`) re-owns those NULL rows to the promoted human admin. + * The runtime never mints a `usr_system` row. + * + * This constant survives only for backward compatibility: DBs created by an + * older runtime may still contain a `usr_system` row, so the first-admin + * exclusion guards (it must never be mistaken for a human admin) and the + * ownership handoff (which also claims any `owner_id = usr_system` rows) keep + * referencing it. On a fresh boot no such row exists and those references are + * harmless no-ops. */ SYSTEM: 'usr_system', } as const; From efac8e8aaedc0b7e46726611b8aff20e3c62465f Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 18 Jun 2026 14:45:30 +0800 Subject: [PATCH 6/6] chore(changeset): usr_system is removed, not lazily provisioned Co-Authored-By: Claude Opus 4.8 --- .changeset/seed-ownership-handoff.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.changeset/seed-ownership-handoff.md b/.changeset/seed-ownership-handoff.md index aa579de273..97ffe09b3a 100644 --- a/.changeset/seed-ownership-handoff.md +++ b/.changeset/seed-ownership-handoff.md @@ -29,12 +29,14 @@ author-written objects silently shipped with no working ownership at all. not the author — performs the handoff, so there is nothing to remember or mistype. -- **`usr_system` is now provisioned lazily (runtime)** — only when a seed - dataset actually embeds `cel`os.user.id``. Because the default model leaves - `owner_id` NULL and relies on the handoff above, a typical bundle never - references `os.user`, so the non-loginable `usr_system` placeholder is never - created. It survives purely as a backward-compatible fallback; `os.org` is - unaffected (derived from `organizationId` in the loader). +- **`usr_system` is never minted (runtime + objectql).** The seed loader binds + `os.user` to a NULL identity, so `cel`os.user.id`` resolves to NULL at seed + time (the owning admin does not exist yet) and the row seeds NULL-owned — then + the handoff above fills it. The runtime's `ensureSeedIdentity` (the only code + that inserted a `usr_system` row) is removed. `SystemUserId.SYSTEM` survives + only as a reserved id so legacy DBs' exclusion guards / ownership handoff still + recognize a pre-existing row. `os.org` is unaffected (derived from + `organizationId`). Also hardens `bootstrapPlatformAdmin` against a latent dts typecheck error (defensive read of the untyped `description` on seed permission sets).