@@ -193,7 +193,20 @@ export type SeedTenancyBackfillStatus =
193193 | 'no-split'
194194 /** A split exists but the install is multi-tenant — ruled: skip, loudly. */
195195 | 'skipped-multi-tenant'
196- /** A split exists but the organization count is not exactly 1. */
196+ /**
197+ * A split exists and the install holds NO organization yet (#12395).
198+ *
199+ * Benign, and deliberately NOT folded into `skipped-ambiguous-organization`:
200+ * with zero organizations there is no second partition, so each object runs
201+ * exactly one counter and nothing can be minted twice. The state self-heals at
202+ * the first sign-up through the `sys_organization`-insert handoff.
203+ *
204+ * The same 0 / 1 / several line objectql already draws in
205+ * `resolveSystemWriteOrganization`, whose `no-organization-yet` decision this
206+ * is named after — "⛔ Refusing here would refuse first boot itself."
207+ */
208+ | 'no-organization-yet'
209+ /** A split exists but the install holds SEVERAL organizations — no derivable owner. */
197210 | 'skipped-ambiguous-organization'
198211 /** The backfill ran. */
199212 | 'applied' ;
@@ -573,6 +586,27 @@ export function buildSplitProbeSql(client?: string): string {
573586 */
574587const PLATFORM_NAMESPACE = / ^ ( s y s _ | c l o u d _ | a i _ ) / ;
575588
589+ /**
590+ * What the affected-object list is, and is not (#12395).
591+ *
592+ * It is read from `_objectstack_sequences` at the instant this probe runs, and
593+ * this probe runs on `kernel:ready`. A seed that overruns its budget keeps
594+ * writing in the BACKGROUND past that point (`[Seeder] Inline seed exceeded
595+ * <n>ms budget … continuing in background to avoid blocking kernel start`), so a
596+ * first boot can reach here with only part of the seed's counters allocated —
597+ * measured at 194 ms apart, naming 3 objects where the settled database holds 9.
598+ *
599+ * Stated rather than removed by ordering: the boot pass exists for the
600+ * EXISTING-install half, whose rows are already written and need no wait, and
601+ * the fresh-install half is delivered by the `sys_organization`-insert handoff,
602+ * which by construction runs after sign-up. Making boot block on seed settlement
603+ * would delay a repair that has nothing to wait for.
604+ */
605+ const SNAPSHOT_CAVEAT =
606+ `This list is a snapshot taken when the probe ran, not a census: a boot that reaches ` +
607+ `'kernel:ready' while an over-budget inline seed is still writing in the background names only ` +
608+ `the counters allocated so far, so a later run on the same database may name more.` ;
609+
576610/** The organizations the install has, capped — the single-tenant guard reads this. */
577611export function buildOrganizationProbeSql ( client ?: string ) : string {
578612 return `SELECT ${ quoteIdent ( 'id' , client ) } FROM ${ quoteIdent ( ORGANIZATION_TABLE , client ) } ` ;
@@ -1245,21 +1279,69 @@ export async function backfillSeedTenancy(
12451279 `no derivable answer to which organization owns the untenanted rows. Remedy: decide the owner per ` +
12461280 `object, then UPDATE <object> SET ${ ORGANIZATION_FIELD } = '<org id>' WHERE ${ ORGANIZATION_FIELD } ` +
12471281 `IS NULL, and merge that object's '${ GLOBAL_TENANT } ' row in ${ SEQUENCES_TABLE } into the ` +
1248- `organization-scoped row at the greater last_value.` ,
1282+ `organization-scoped row at the greater last_value. ` +
1283+ SNAPSHOT_CAVEAT ,
12491284 { splits, posture : resolveTenancyPosture ( ) } ,
12501285 ) ;
12511286 return { status : 'skipped-multi-tenant' , splits, collisions : [ ] , objectsStamped : 0 } ;
12521287 }
12531288
1254- // 4. Exactly one organization, or there is nothing derivable to adopt.
1289+ // 4. How many organizations does the install hold? Three answers, not two:
1290+ // none yet (benign, 4a), exactly one (derivable — the repair runs), or
1291+ // several (ambiguous, 4b).
1292+ //
1293+ // A probe that THREW is tracked separately and must never reach 4a. It
1294+ // yields the same empty array as a genuine zero, and reading a failure as
1295+ // "no organizations yet" is a known way to turn an outage into a benign-
1296+ // looking log line — objectql fixed that exact confusion in
1297+ // `resolveSystemWriteOrganization`'s probe (#9261). Unknown is not zero.
12551298 let organizationIds : string [ ] = [ ] ;
1299+ let organizationProbeError = '' ;
12561300 try {
12571301 organizationIds = ( await selectRows ( exec , buildOrganizationProbeSql ( client ) ) )
12581302 . map ( ( r ) => ( r . id == null ? '' : String ( r . id ) ) )
12591303 . filter ( ( id ) => id . length > 0 ) ;
1260- } catch {
1304+ } catch ( e ) {
1305+ organizationProbeError = ( e as Error ) . message || 'unknown error' ;
12611306 organizationIds = [ ] ;
12621307 }
1308+ // 4a. NO organization yet — benign, and NOT the ambiguous case (#12395).
1309+ //
1310+ // `!== 1` used to fold this together with "several organizations", and the
1311+ // two are opposite conditions. With several, the owner is genuinely
1312+ // underdetermined and an operator has to choose. With NONE, there is no
1313+ // second partition to be split ACROSS: every counter is the one
1314+ // `__global__` row, so "two autonumber counters" and "can mint the same
1315+ // identifier twice" — what the loud branch below says — are both false
1316+ // here, at a moment when they read as an active data-integrity emergency.
1317+ // (The `organizationLastValue: 0` this state reports is `buildSplitProbeSql`'s
1318+ // LEFT JOIN finding no second row, not a second counter sitting at zero.)
1319+ //
1320+ // Nor is it a state anyone can act on: seeds load inline during `start()`,
1321+ // while the first organization is created by plugin-auth's
1322+ // `ensureDefaultOrganization` behind an admin permission-set grant, so it
1323+ // cannot exist until a sign-up POST reaches a running server. The repair is
1324+ // already scheduled for that exact moment by the `sys_organization`-insert
1325+ // handoff in runtime's app-plugin.
1326+ //
1327+ // `info`, not silence. The split is real even though the hazard is not, and
1328+ // a diagnostic silenced in BOTH directions would be worse than the one it
1329+ // replaces — this still says what was seen, it just stops claiming harm.
1330+ if ( organizationIds . length === 0 && organizationProbeError === '' ) {
1331+ logger ?. info ?.(
1332+ `[metadata-protocol] seed/API tenancy split detected on an install with no organization yet — ` +
1333+ `nothing to adopt, and nothing at risk (#8686). Affected: ${ affected } . ` +
1334+ `${ ORGANIZATION_TABLE } is empty, so each of these objects runs exactly ONE counter (its ` +
1335+ `'${ GLOBAL_TENANT } ' row) and no "unique" identifier can be minted twice while there is only ` +
1336+ `one partition. No operator action: this self-heals at the first sign-up, when the ` +
1337+ `${ ORGANIZATION_TABLE } -insert handoff runs this same repair against a settled database. ` +
1338+ SNAPSHOT_CAVEAT ,
1339+ { splits, organizationCount : 0 } ,
1340+ ) ;
1341+ return { status : 'no-organization-yet' , splits, collisions : [ ] , objectsStamped : 0 } ;
1342+ }
1343+
1344+ // 4b. SEVERAL organizations — the genuinely ambiguous case, still loud.
12631345 if ( organizationIds . length !== 1 ) {
12641346 logger ?. warn ?.(
12651347 `[metadata-protocol] seed/API tenancy split detected but the target organization is not ` +
@@ -1268,8 +1350,14 @@ export async function backfillSeedTenancy(
12681350 `${ ORGANIZATION_TABLE } (exactly 1 is required to adopt one without guessing). Until this is ` +
12691351 `resolved these objects run two autonumber counters and can mint the same "unique" identifier ` +
12701352 `twice. Remedy: as above — stamp the untenanted rows with the owning organization and merge the ` +
1271- `'${ GLOBAL_TENANT } ' counter row into the organization-scoped one.` ,
1272- { splits, organizationCount : organizationIds . length } ,
1353+ `'${ GLOBAL_TENANT } ' counter row into the organization-scoped one. ` +
1354+ ( organizationProbeError === ''
1355+ ? ''
1356+ : `NOTE: the ${ ORGANIZATION_TABLE } probe FAILED (${ organizationProbeError } ), so the count ` +
1357+ `above is "unknown", not a measured zero — an unreadable probe is reported here rather ` +
1358+ `than through the benign no-organization-yet path (#9261). ` ) +
1359+ SNAPSHOT_CAVEAT ,
1360+ { splits, organizationCount : organizationIds . length , organizationProbeError } ,
12731361 ) ;
12741362 return { status : 'skipped-ambiguous-organization' , splits, collisions : [ ] , objectsStamped : 0 } ;
12751363 }
0 commit comments