Skip to content

Commit bbaa560

Browse files
os-zhuangclaude
andcommitted
feat(seed): never mint usr_system — resolve os.user.id to NULL at seed time
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 <noreply@anthropic.com>
1 parent 433589c commit bbaa560

5 files changed

Lines changed: 87 additions & 113 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { resolveSeedRecord } from '@objectstack/formula';
5+
6+
/**
7+
* The SeedLoader binds `os.user` to a NULL identity (`{ id: null }`) when no
8+
* real user exists at seed time (the normal case). This proves the resolution
9+
* behavior that lets us drop the `usr_system` placeholder entirely:
10+
* `owner_id: cel`os.user.id`` resolves to NULL (not a crash, not a dropped
11+
* record), and the first-admin handoff later claims the NULL-owned row.
12+
*/
13+
describe('seed os.user binding (usr_system-free)', () => {
14+
const cel = (source: string) => ({ dialect: 'cel', source });
15+
16+
it('resolves os.user.id to null under a NULL identity (no error, no drop)', () => {
17+
const rec = { name: 'Acme', owner_id: cel('os.user.id') };
18+
const ctx = { now: new Date(), user: { id: null }, org: undefined, env: {} };
19+
20+
const result = resolveSeedRecord(rec as any, ctx as any);
21+
22+
expect(result.ok).toBe(true);
23+
expect((result as any).value.owner_id).toBeNull();
24+
// Non-identity dynamic values still resolve normally alongside it.
25+
expect((result as any).value.name).toBe('Acme');
26+
});
27+
28+
it('still resolves a real os.user.id when an identity IS supplied (per-org replay)', () => {
29+
const rec = { owner_id: cel('os.user.id') };
30+
const ctx = { now: new Date(), user: { id: 'usr_real_admin' }, org: undefined, env: {} };
31+
32+
const result = resolveSeedRecord(rec as any, ctx as any);
33+
34+
expect(result.ok).toBe(true);
35+
expect((result as any).value.owner_id).toBe('usr_real_admin');
36+
});
37+
});

packages/objectql/src/seed-loader.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -208,14 +208,23 @@ export class SeedLoaderService implements ISeedLoaderService {
208208
const seedNow = new Date();
209209

210210
// Identity/context bound to seed CEL expressions. `os.user` / `os.org`
211-
// resolve from here, so `owner_id: cel\`os.user.id\`` works. When no
212-
// identity is supplied, `os.user` / `os.org` are simply unbound and any
213-
// record that references them fails loudly below (rather than silently
214-
// writing a raw Expression envelope into the column).
211+
// resolve from here, so `owner_id: cel\`os.user.id\`` works.
212+
//
213+
// When no real user identity is supplied (the normal case — seeds run
214+
// before the first human sign-up), `os.user` is bound to a NULL identity
215+
// (`{ id: null }`) rather than left undefined. This makes `os.user.id`
216+
// resolve to `null` instead of crashing the expression, so a seed's
217+
// `owner_id: cel\`os.user.id\`` simply lands NULL — semantically "owned by
218+
// whoever becomes the first admin", which the first-admin handoff
219+
// (`claimSeedOwnership`) then fills in. The platform therefore never has to
220+
// mint a placeholder `usr_system` row just to satisfy this expression.
215221
const seedIdentity = config.identity;
216222
const baseEvalCtx = {
217223
now: seedNow,
218-
user: seedIdentity?.user,
224+
// `id: null` is a legitimate seed-time state (the owning admin does not
225+
// exist yet) that the formula EvalContext's `user.id: string` type does
226+
// not yet model — cast the fallback so `os.user.id` evaluates to null.
227+
user: seedIdentity?.user ?? ({ id: null } as unknown as NonNullable<typeof seedIdentity>['user']),
219228
// Fall back to the per-tenant organizationId so `os.org.id` resolves
220229
// during per-org replay even without an explicit identity.org.
221230
org: seedIdentity?.org ?? (config.organizationId ? { id: config.organizationId } : undefined),
@@ -245,8 +254,9 @@ export class SeedLoaderService implements ISeedLoaderService {
245254
recordIndex: i,
246255
message:
247256
`Cannot resolve dynamic seed values for ${objectName} record #${i}: ${seedResult.error.message}. ` +
248-
'Records using cel`os.user.id` / cel`os.org.id` require a seed identity — ' +
249-
'ensure a system/admin user exists before seeding (see SeedLoaderConfig.identity).',
257+
'`os.user.id` resolves to null at seed time (the owning admin does not exist yet) and ' +
258+
'owner-style fields are assigned by the first-admin handoff — so a required, non-owner ' +
259+
'field must not depend on it. Provide a literal value or make the field optional.',
250260
};
251261
errors.push(error);
252262
allErrors.push(error);

packages/runtime/src/app-plugin.ts

Lines changed: 8 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { readEnvWithDeprecation } from '@objectstack/types';
55
import { SeedLoaderService } from './seed-loader.js';
66
import { loadDisabledPackageIds } from './package-state-store.js';
77
import type { IMetadataService, II18nService } from '@objectstack/spec/contracts';
8-
import { SystemUserId } from '@objectstack/spec/system';
98
import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js';
109
import { hookBodyRunnerFactory, actionBodyRunnerFactory } from './sandbox/body-runner.js';
1110

@@ -472,20 +471,14 @@ export class AppPlugin implements Plugin {
472471
object: d.object,
473472
}));
474473

475-
// Resolve the seed identity (os.user) ONLY when a seed actually
476-
// references it. The modern ownership model leaves `owner_id`
477-
// unset in seeds and lets the first-admin handoff
478-
// (`claimSeedOwnership`) re-own NULL rows to the promoted admin —
479-
// so most bundles never touch `os.user`, and the non-loginable
480-
// `usr_system` placeholder need not exist at all. We provision it
481-
// lazily (backward-compatible) only for the rare seed that embeds
482-
// `cel`os.user.id`` (detected via the serialized CEL `source`).
483-
// `os.org` is unaffected — the loader derives it from
484-
// `organizationId`, not from this identity.
485-
const seedsReferenceOsUser = JSON.stringify(normalizedDatasets).includes('os.user');
486-
const seedIdentity = seedsReferenceOsUser
487-
? await this.ensureSeedIdentity(ql, ctx.logger)
488-
: undefined;
474+
// No seed identity is provisioned. The platform never mints a
475+
// placeholder `usr_system`: seeds leave `owner_id` unset (or use
476+
// `cel`os.user.id``, which the loader resolves to NULL since the
477+
// owning admin does not exist yet), and the first-admin handoff
478+
// (`claimSeedOwnership`) re-owns those NULL rows to the promoted
479+
// admin. `os.org` is still derived from `organizationId` inside the
480+
// loader, independent of this.
481+
const seedIdentity = undefined;
489482

490483
// Stash datasets on a kernel service so SecurityPlugin's
491484
// sys_organization insert hook can replay them per-tenant
@@ -667,78 +660,6 @@ export class AppPlugin implements Plugin {
667660
this.emitCatalogEvent(ctx, 'app:unregistered', sys);
668661
}
669662

670-
/**
671-
* Lazily provision the identity bound to `os.user` for seed CEL values.
672-
*
673-
* Called ONLY when a seed dataset actually embeds `cel`os.user.id`` (see the
674-
* caller's `seedsReferenceOsUser` guard). The modern ownership model does
675-
* not need this: seeds leave `owner_id` NULL and the first-admin bootstrap
676-
* (`claimSeedOwnership` in plugin-security) re-owns those rows to the
677-
* promoted human admin — so a typical bundle never references `os.user`, and
678-
* the `usr_system` placeholder is never created.
679-
*
680-
* It survives only as a backward-compatible fallback for the rare seed that
681-
* still embeds `cel`os.user.id``: such a seed runs before the first human
682-
* sign-up, so we upsert a single non-loginable **system user**
683-
* (`usr_system`) and bind it as `os.user` so the expression resolves instead
684-
* of dropping the record.
685-
*
686-
* Why a dedicated system user rather than the login admin:
687-
* - `sys_user` is better-auth-managed and schema-locked (ADR-0010); the
688-
* password lives in `sys_account`, so a *loginable* admin can only be
689-
* minted through better-auth (the CLI does this via HTTP sign-up after
690-
* boot). A raw insert here would bypass those invariants.
691-
* - `usr_system` is an owner identity only (no credential row), analogous
692-
* to Salesforce's "Automated Process" user. The human admin is created
693-
* independently and need not be the seed owner.
694-
*
695-
* Idempotent: matches by the stable id, inserts once, reuses thereafter.
696-
* Failures are non-fatal (logged) — records that actually need `os.user`
697-
* then fail loudly in the loader with an actionable message.
698-
*/
699-
private async ensureSeedIdentity(
700-
ql: any,
701-
logger: PluginContext['logger'],
702-
): Promise<{ user: { id: string; role: string; email: string } }> {
703-
// Deterministic, non-loginable service identity that owns seeded data.
704-
const SYSTEM_USER_ID = SystemUserId.SYSTEM;
705-
const SYSTEM_USER_EMAIL = 'system@objectstack.local';
706-
const identity = { user: { id: SYSTEM_USER_ID, role: 'system', email: SYSTEM_USER_EMAIL } };
707-
const opts = { context: { isSystem: true } } as any;
708-
709-
try {
710-
const existing = await (ql as any).find(
711-
'sys_user',
712-
{ where: { id: SYSTEM_USER_ID }, limit: 1 },
713-
opts,
714-
);
715-
if (Array.isArray(existing) && existing.length > 0) {
716-
return identity;
717-
}
718-
await (ql as any).insert(
719-
'sys_user',
720-
{
721-
id: SYSTEM_USER_ID,
722-
name: 'System',
723-
email: SYSTEM_USER_EMAIL,
724-
email_verified: true,
725-
role: 'system',
726-
},
727-
opts,
728-
);
729-
logger.info(
730-
`[Seeder] Provisioned deterministic system user (${SYSTEM_USER_ID}) as seed owner — binds os.user for identity-derived seed values`,
731-
);
732-
} catch (err: any) {
733-
// Non-fatal: identity-dependent records will fail loudly in the
734-
// loader; identity-free records still seed normally.
735-
logger.warn('[Seeder] Failed to ensure system seed user; os.user-dependent seeds may be dropped', {
736-
error: err?.message ?? String(err),
737-
});
738-
}
739-
return identity;
740-
}
741-
742663
/**
743664
* Emit a kernel hook so the control-plane `AppCatalogService` can
744665
* upsert / delete the corresponding `sys_app` row. Silently no-ops

packages/runtime/src/seed-loader.test.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,7 +1102,7 @@ describe('SeedLoaderService', () => {
11021102
);
11031103
});
11041104

1105-
it('fails loudly (no raw envelope written) when os.user is unbound', async () => {
1105+
it('resolves os.user.id to NULL (not a drop) when no identity is supplied', async () => {
11061106
const metadata = createMockMetadata({
11071107
note: { name: 'note', fields: { name: { type: 'text' }, author: { type: 'text' } } },
11081108
});
@@ -1119,16 +1119,20 @@ describe('SeedLoaderService', () => {
11191119
records: [{ name: 'N1', author: cel('os.user.id') }],
11201120
},
11211121
],
1122-
// No identity → os.user unbound → record must be dropped, not written.
1122+
// No identity → the loader binds os.user to a NULL identity, so
1123+
// `os.user.id` resolves to null and the record seeds with author=null.
1124+
// The platform never mints a `usr_system` placeholder; owner-style
1125+
// fields are filled later by the first-admin handoff.
11231126
config: baseConfig(),
11241127
});
11251128

1126-
expect(result.success).toBe(false);
1127-
expect(result.summary.totalErrored).toBe(1);
1128-
expect(result.errors).toHaveLength(1);
1129-
expect(result.errors[0].message).toContain('os.user');
1130-
// Critically: the unresolved Expression envelope is NEVER persisted.
1131-
expect(engine.insert).not.toHaveBeenCalled();
1129+
expect(result.success).toBe(true);
1130+
expect(result.summary.totalErrored).toBe(0);
1131+
expect(engine.insert).toHaveBeenCalledWith(
1132+
'note',
1133+
expect.objectContaining({ name: 'N1', author: null }),
1134+
expect.anything(),
1135+
);
11321136
});
11331137

11341138
it('falls back os.org.id to organizationId during per-tenant replay', async () => {

packages/spec/src/system/constants/system-names.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -80,18 +80,20 @@ export type SystemObjectName = typeof SystemObjectName[keyof typeof SystemObject
8080
*/
8181
export const SystemUserId = {
8282
/**
83-
* Deterministic, non-loginable service identity — a lazily-provisioned
84-
* fallback for seeds that embed `cel`os.user.id``.
83+
* Reserved well-known id for the legacy non-loginable system service account.
8584
*
86-
* The default ownership model does NOT use this: seeds leave `owner_id` NULL
87-
* and the first-admin bootstrap (`claimSeedOwnership`) re-owns those rows to
88-
* the promoted human admin, so a typical bundle never references `os.user`
89-
* and this row is never created. It is upserted (by the runtime's
90-
* `ensureSeedIdentity`) ONLY when a seed still embeds `cel`os.user.id``, so
91-
* that expression resolves before the first human sign-up instead of dropping
92-
* the record. Has no `sys_account` credential, so it cannot sign in;
93-
* analogous to Salesforce's "Automated Process" user. The human login admin
94-
* is minted separately through better-auth.
85+
* NO LONGER AUTO-PROVISIONED. Ownership now works without it: seeds leave
86+
* `owner_id` NULL (and `cel`os.user.id`` resolves to NULL at seed time, since
87+
* the owning admin does not exist yet), and the first-admin bootstrap
88+
* (`claimSeedOwnership`) re-owns those NULL rows to the promoted human admin.
89+
* The runtime never mints a `usr_system` row.
90+
*
91+
* This constant survives only for backward compatibility: DBs created by an
92+
* older runtime may still contain a `usr_system` row, so the first-admin
93+
* exclusion guards (it must never be mistaken for a human admin) and the
94+
* ownership handoff (which also claims any `owner_id = usr_system` rows) keep
95+
* referencing it. On a fresh boot no such row exists and those references are
96+
* harmless no-ops.
9597
*/
9698
SYSTEM: 'usr_system',
9799
} as const;

0 commit comments

Comments
 (0)