Skip to content

Commit f93df4d

Browse files
os-warrenclaude
andauthored
fix(metadata-protocol): zero organizations is a third state, not the ambiguous one (#12395) (#12594)
The #8686 split diagnostic guarded on `organizationIds.length !== 1`, folding "no organizations yet" together with "several organizations". They are opposite conditions: with several the owner is underdetermined, but with none there is no second partition, so each object runs exactly one `__global__` counter and the line's claim of two live counters and an active duplicate-minting hazard was false at the one moment a fresh install actually read it. Zero now returns `no-organization-yet` at `info` — reported, not silenced, and named after the 0/1/several line objectql's `resolveSystemWriteOrganization` already draws. A FAILED organization probe keeps the loud path (#9261): unknown is not zero. The repair threshold is unchanged — data still moves on exactly `length === 1`. The affected-object list is now described as a probe-time snapshot: `kernel:ready` can be reached while an over-budget inline seed is still writing. Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o Co-authored-by: Claude <noreply@anthropic.com>
1 parent 92916e7 commit f93df4d

3 files changed

Lines changed: 271 additions & 6 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): the seed-tenancy backfill stops reporting a duplicate-minting hazard on a zero-organization first boot (#12395)
6+
7+
The `#8686` split diagnostic guarded on `organizationIds.length !== 1`, which folded
8+
two opposite conditions into one loud warning. With **several** organizations the
9+
owner of an untenanted row is genuinely underdetermined and the warning is right.
10+
With **none** there is no second partition at all: every object runs exactly one
11+
`__global__` counter, so the line's claim that the named objects "run two autonumber
12+
counters and can mint the same `unique` identifier twice" was false precisely when a
13+
fresh install read it. (The `organizationLastValue: 0` it reported alongside is the
14+
split probe's `LEFT JOIN` finding no second row, not a second counter at zero.)
15+
16+
Zero organizations is now its own state — `no-organization-yet`, named after and
17+
matching the 0 / 1 / several line `objectql`'s `resolveSystemWriteOrganization`
18+
already draws — logged at `info` rather than `warn`. It is not silenced: the split
19+
is still reported, because the observation is real even though the hazard is not.
20+
It self-heals at the first sign-up, when the `sys_organization`-insert handoff runs
21+
the same repair against a settled database.
22+
23+
Two things this deliberately does not change. An organization probe that **failed**
24+
still takes the loud path and now says so — an unreadable probe returns the same
25+
empty array as a genuine zero, and reading it as "no organizations yet" is the
26+
confusion `objectql` fixed in `#9261`. And the repair threshold is untouched: data
27+
is still modified on exactly `organizationIds.length === 1` and nothing else.
28+
29+
The affected-object list is also now described as what it is — a snapshot taken when
30+
the probe ran. The probe runs at `kernel:ready`, which a boot can reach while an
31+
over-budget inline seed is still writing in the background, so a first boot can name
32+
fewer objects than the settled database holds.

packages/metadata-protocol/src/migrations/seed-tenancy-backfill.test.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,3 +808,148 @@ describe('#9451 the seed-tenancy repair leaves a durable receipt', () => {
808808
expect(resolveSeedTenancyLedger({ getObject: () => ({}), find: async () => [] })).toBeUndefined();
809809
});
810810
});
811+
812+
describe('#12395 zero organizations is a third state, not the ambiguous one', () => {
813+
/**
814+
* The contract these cases pin is a DISCRIMINATION, not a wording:
815+
* `organizationCount: 0` must not warn, and `organizationCount: 2` must still
816+
* warn. Both arms are asserted in every case that can carry both, because a
817+
* diagnostic silenced in both directions would pass a one-armed test while
818+
* being strictly worse than the line it replaced.
819+
*
820+
* NOTE ON EXISTING ASSERTIONS: none were changed. The two pins that already
821+
* existed on `skipped-ambiguous-organization` — `null-seam.test.ts` and
822+
* runtime's `seed-tenancy-autonumber-split.integration.test.ts` — both drive
823+
* the TWO-organization arm (`org_a`/`org_b`, `org_second`), which keeps its
824+
* status and its warning here. No test covered the zero arm before this one.
825+
*/
826+
function spy() {
827+
const warn: Array<[string, unknown]> = [];
828+
const info: Array<[string, unknown]> = [];
829+
return {
830+
warn,
831+
info,
832+
logger: {
833+
info: (m: string, p?: unknown) => void info.push([m, p]),
834+
warn: (m: string, p?: unknown) => void warn.push([m, p]),
835+
error: () => {},
836+
},
837+
};
838+
}
839+
840+
/** A seam holding one `__global__` counter and `orgs` organizations. */
841+
function seam(orgs: string[], opts: { organizationProbeThrows?: boolean } = {}) {
842+
const sql: string[] = [];
843+
const exec = async (statement: string) => {
844+
sql.push(statement);
845+
if (statement.includes('WHERE 1 = 0')) return [];
846+
if (statement.includes('LEFT JOIN')) {
847+
return [
848+
{
849+
object: 'crm_case',
850+
field: 'case_number',
851+
global_last_value: 38,
852+
// No organization-scoped row exists: the LEFT JOIN yields NULL here.
853+
organization_last_value: null,
854+
},
855+
];
856+
}
857+
if (statement.includes(ORGANIZATION_TABLE)) {
858+
if (opts.organizationProbeThrows) throw new Error('connection reset by peer');
859+
return orgs.map((id) => ({ id }));
860+
}
861+
return [];
862+
};
863+
return { seam: { exec, client: 'better-sqlite3' as const }, sql };
864+
}
865+
866+
const HAZARD = 'can mint the same "unique" identifier twice';
867+
868+
it('[zero] does NOT warn, and reports its own state instead of the ambiguous one', async () => {
869+
const log = spy();
870+
const { seam: s } = seam([]);
871+
const result = await backfillSeedTenancy(s, log.logger as any);
872+
873+
// The discrimination, arm 1.
874+
expect(log.warn).toHaveLength(0);
875+
expect(log.info).toHaveLength(1);
876+
expect(result.status).toBe('no-organization-yet');
877+
expect(log.info[0][1]).toMatchObject({ organizationCount: 0 });
878+
879+
// Still VISIBLE — silenced about harm, not about the observation.
880+
expect(result.splits).toEqual([
881+
{ object: 'crm_case', field: 'case_number', globalLastValue: 38, organizationLastValue: 0 },
882+
]);
883+
});
884+
885+
it('[several] still warns, and still names the hazard', async () => {
886+
const log = spy();
887+
const { seam: s } = seam(['org_a', 'org_b']);
888+
const result = await backfillSeedTenancy(s, log.logger as any);
889+
890+
// The discrimination, arm 2 — unchanged from before this card.
891+
expect(log.warn).toHaveLength(1);
892+
expect(log.info).toHaveLength(0);
893+
expect(result.status).toBe('skipped-ambiguous-organization');
894+
expect(log.warn[0][1]).toMatchObject({ organizationCount: 2 });
895+
expect(log.warn[0][0]).toContain(HAZARD);
896+
});
897+
898+
it('[the claim moved with the state] only the ambiguous arm asserts the minting hazard', async () => {
899+
// The card's actual complaint: the line claimed two live counters and an
900+
// active duplicate-minting risk at a moment when exactly one counter
901+
// existed. Asserting BOTH arms is what keeps this from going green on a
902+
// rewrite that simply deletes the sentence everywhere.
903+
const zero = spy();
904+
await backfillSeedTenancy(seam([]).seam, zero.logger as any);
905+
const several = spy();
906+
await backfillSeedTenancy(seam(['org_a', 'org_b']).seam, several.logger as any);
907+
908+
expect(zero.info[0][0]).not.toContain(HAZARD);
909+
expect(several.warn[0][0]).toContain(HAZARD);
910+
// And the benign line says why it is benign, in the counter's own terms.
911+
expect(zero.info[0][0]).toContain('exactly ONE counter');
912+
});
913+
914+
it('[no writes] the zero state touches no data — the repair threshold is unchanged', async () => {
915+
// Clause-② evidence in executable form: the set of inputs on which this
916+
// migration MODIFIES data is exactly what it was — `length === 1` — so the
917+
// zero arm must still issue reads only.
918+
const { seam: s, sql } = seam([]);
919+
const result = await backfillSeedTenancy(s, spy().logger as any);
920+
921+
expect(result.objectsStamped).toBe(0);
922+
const writes = sql.filter((q) => /^\s*(UPDATE|DELETE|INSERT)/i.test(q));
923+
expect(writes).toEqual([]);
924+
});
925+
926+
it('[#9261] an organization probe that FAILED is not read as "no organizations yet"', async () => {
927+
// The probe returns the same empty array for "none" and for "could not
928+
// ask". Folding the second into the benign path would convert an outage
929+
// into a reassuring info line — the confusion objectql already fixed in
930+
// `resolveSystemWriteOrganization`. Unknown is not zero.
931+
const log = spy();
932+
const { seam: s } = seam([], { organizationProbeThrows: true });
933+
const result = await backfillSeedTenancy(s, log.logger as any);
934+
935+
expect(result.status).toBe('skipped-ambiguous-organization');
936+
expect(log.info).toHaveLength(0);
937+
expect(log.warn).toHaveLength(1);
938+
expect(log.warn[0][1]).toMatchObject({ organizationProbeError: 'connection reset by peer' });
939+
expect(log.warn[0][0]).toContain('probe FAILED');
940+
});
941+
942+
it('[snapshot] every list-bearing branch says the list is a probe-time snapshot', async () => {
943+
// Problem 2. The affected list is read at `kernel:ready`, which a boot can
944+
// reach while an over-budget inline seed is still writing — measured at 3
945+
// objects named where the settled database held 9. Stated rather than
946+
// reordered away; see SNAPSHOT_CAVEAT's comment for why boot does not wait.
947+
const zero = spy();
948+
await backfillSeedTenancy(seam([]).seam, zero.logger as any);
949+
const several = spy();
950+
await backfillSeedTenancy(seam(['org_a', 'org_b']).seam, several.logger as any);
951+
952+
expect(zero.info[0][0]).toContain('snapshot taken when the probe ran');
953+
expect(several.warn[0][0]).toContain('snapshot taken when the probe ran');
954+
});
955+
});

packages/metadata-protocol/src/migrations/seed-tenancy-backfill.ts

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
574587
const PLATFORM_NAMESPACE = /^(sys_|cloud_|ai_)/;
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. */
577611
export 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

Comments
 (0)