@@ -5,6 +5,7 @@ import { readEnvWithDeprecation } from '@objectstack/types';
55import { SeedLoaderService } from './seed-loader.js' ;
66import { loadDisabledPackageIds } from './package-state-store.js' ;
77import type { IMetadataService , II18nService } from '@objectstack/spec/contracts' ;
8+ import { SystemUserId } from '@objectstack/spec/system' ;
89import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js' ;
910import { hookBodyRunnerFactory , actionBodyRunnerFactory } from './sandbox/body-runner.js' ;
1011
@@ -482,6 +483,13 @@ export class AppPlugin implements Plugin {
482483 object : d . object ,
483484 } ) ) ;
484485
486+ // Resolve the seed identity (os.user / os.org) BEFORE any seed
487+ // runs. Deterministically ensures a non-loginable system user
488+ // exists so identity-derived seed values (e.g.
489+ // `owner_id: cel`os.user.id``) resolve at boot — before the
490+ // first human sign-up. See ensureSeedIdentity().
491+ const seedIdentity = await this . ensureSeedIdentity ( ql , ctx . logger ) ;
492+
485493 // Stash datasets on a kernel service so SecurityPlugin's
486494 // sys_organization insert hook can replay them per-tenant
487495 // (Salesforce-sandbox style: every new org gets its own
@@ -529,6 +537,11 @@ export class AppPlugin implements Plugin {
529537 defaultMode : 'upsert' ,
530538 multiPass : true ,
531539 organizationId,
540+ // Bind os.user (system identity) and os.org (this
541+ // tenant) so identity-derived seed values resolve
542+ // per-org. org.id falls back to organizationId
543+ // inside the loader when identity.org is absent.
544+ identity : seedIdentity ,
532545 } ,
533546 } ) ;
534547 const result = await seedLoader . load ( request ) ;
@@ -571,14 +584,38 @@ export class AppPlugin implements Plugin {
571584 const { SeedLoaderRequestSchema } = await import ( '@objectstack/spec/data' ) ;
572585 const request = SeedLoaderRequestSchema . parse ( {
573586 datasets : normalizedDatasets ,
574- config : { defaultMode : 'upsert' , multiPass : true } ,
587+ config : { defaultMode : 'upsert' , multiPass : true , identity : seedIdentity } ,
575588 } ) ;
576589 const result = await seedLoader . load ( request ) ;
577- ctx . logger . info ( '[Seeder] Seed loading complete' , {
578- inserted : result . summary . totalInserted ,
579- updated : result . summary . totalUpdated ,
580- errors : result . errors . length ,
581- } ) ;
590+ const { totalInserted, totalUpdated, totalSkipped, totalErrored } = result . summary ;
591+ if ( result . success ) {
592+ ctx . logger . info ( '[Seeder] Seed loading complete' , {
593+ inserted : totalInserted ,
594+ updated : totalUpdated ,
595+ skipped : totalSkipped ,
596+ errored : totalErrored ,
597+ } ) ;
598+ } else {
599+ // LOUD FAILURE: dropped records were previously
600+ // invisible (the summary only logged errors.length and
601+ // omitted totalErrored). Report the count AND each
602+ // actionable reason so broken seeds can't pass silently.
603+ ctx . logger . warn (
604+ `[Seeder] Seed loading completed with ${ totalErrored } dropped record(s) and ${ result . errors . length } error(s) for ${ appId } ` ,
605+ {
606+ inserted : totalInserted ,
607+ updated : totalUpdated ,
608+ skipped : totalSkipped ,
609+ errored : totalErrored ,
610+ } ,
611+ ) ;
612+ for ( const e of result . errors . slice ( 0 , 20 ) ) {
613+ ctx . logger . warn ( `[Seeder] ✗ ${ e . message } ` ) ;
614+ }
615+ if ( result . errors . length > 20 ) {
616+ ctx . logger . warn ( `[Seeder] …and ${ result . errors . length - 20 } more error(s)` ) ;
617+ }
618+ }
582619 } else {
583620 // Fallback: basic insert when metadata service is not available
584621 ctx . logger . debug ( '[Seeder] No metadata service; using basic insert fallback' ) ;
@@ -633,6 +670,72 @@ export class AppPlugin implements Plugin {
633670 this . emitCatalogEvent ( ctx , 'app:unregistered' , sys ) ;
634671 }
635672
673+ /**
674+ * Resolve the identity bound to `os.user` / `os.org` for seed CEL values.
675+ *
676+ * On a fresh boot there are zero users until the first human sign-up
677+ * (which the SeedLoader runs *before*), so identity-derived seeds like
678+ * `owner_id: cel`os.user.id`` had nothing to resolve against and were
679+ * dropped silently. To make seeds deterministic and self-sufficient we
680+ * upsert a single non-loginable **system user** (`usr_system`) and bind
681+ * it as `os.user`.
682+ *
683+ * Why a dedicated system user rather than the login admin:
684+ * - `sys_user` is better-auth-managed and schema-locked (ADR-0010); the
685+ * password lives in `sys_account`, so a *loginable* admin can only be
686+ * minted through better-auth (the CLI does this via HTTP sign-up after
687+ * boot). A raw insert here would bypass those invariants.
688+ * - `usr_system` is an owner identity only (no credential row), analogous
689+ * to Salesforce's "Automated Process" user. The human admin is created
690+ * independently and need not be the seed owner.
691+ *
692+ * Idempotent: matches by the stable id, inserts once, reuses thereafter.
693+ * Failures are non-fatal (logged) — records that actually need `os.user`
694+ * then fail loudly in the loader with an actionable message.
695+ */
696+ private async ensureSeedIdentity (
697+ ql : any ,
698+ logger : PluginContext [ 'logger' ] ,
699+ ) : Promise < { user : { id : string ; role : string ; email : string } } > {
700+ // Deterministic, non-loginable service identity that owns seeded data.
701+ const SYSTEM_USER_ID = SystemUserId . SYSTEM ;
702+ const SYSTEM_USER_EMAIL = 'system@objectstack.local' ;
703+ const identity = { user : { id : SYSTEM_USER_ID , role : 'system' , email : SYSTEM_USER_EMAIL } } ;
704+ const opts = { context : { isSystem : true } } as any ;
705+
706+ try {
707+ const existing = await ( ql as any ) . find (
708+ 'sys_user' ,
709+ { where : { id : SYSTEM_USER_ID } , limit : 1 } ,
710+ opts ,
711+ ) ;
712+ if ( Array . isArray ( existing ) && existing . length > 0 ) {
713+ return identity ;
714+ }
715+ await ( ql as any ) . insert (
716+ 'sys_user' ,
717+ {
718+ id : SYSTEM_USER_ID ,
719+ name : 'System' ,
720+ email : SYSTEM_USER_EMAIL ,
721+ email_verified : true ,
722+ role : 'system' ,
723+ } ,
724+ opts ,
725+ ) ;
726+ logger . info (
727+ `[Seeder] Provisioned deterministic system user (${ SYSTEM_USER_ID } ) as seed owner — binds os.user for identity-derived seed values` ,
728+ ) ;
729+ } catch ( err : any ) {
730+ // Non-fatal: identity-dependent records will fail loudly in the
731+ // loader; identity-free records still seed normally.
732+ logger . warn ( '[Seeder] Failed to ensure system seed user; os.user-dependent seeds may be dropped' , {
733+ error : err ?. message ?? String ( err ) ,
734+ } ) ;
735+ }
736+ return identity ;
737+ }
738+
636739 /**
637740 * Emit a kernel hook so the control-plane `AppCatalogService` can
638741 * upsert / delete the corresponding `sys_app` row. Silently no-ops
0 commit comments