Skip to content

Commit d48929e

Browse files
os-litantclaude
andauthored
fix(security): make a refused RBAC catalog write boot-visible instead of a silent seed of zero (#12967)
* fix(security): surface refused RBAC catalog writes instead of reporting a seed of zero Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194kbQJxUvv2yvsGRtuXpP5 * test(security): pin the refused-catalog-write warning (loud, aggregated, classified) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194kbQJxUvv2yvsGRtuXpP5 * chore(security): changeset for the refused-catalog-write repair Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194kbQJxUvv2yvsGRtuXpP5 * test(security): conform the seed-refusal double to the engine/where/limit contracts Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194kbQJxUvv2yvsGRtuXpP5 * fix(security): split the refused-catalog-write level — error for unique violations, warn for the rest The AGENTS.md degradation rule decides this: a boot that logs 'RBAC catalog seeded' at info over zero landed rows is the shape whose answer is error. A non-unique refusal is a retrying outage and stays warn, per the same section's 'do not over-apply it'. SeedLogger gains an OPTIONAL error carrying the kernel Logger arity, and every emission routes through logSeedDurabilityFailure so the warn fallback cannot be forgotten — never logger.error?.() (silence against a reduced sink) and never (a ?? b)() (detached receiver). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194kbQJxUvv2yvsGRtuXpP5 * fix(security): make SeedLogger.warn non-optional so the durability fallback is guaranteed by the type With both `warn` and `error` optional, `{}` satisfied SeedLogger and every value of the type was permitted to print nothing — a contract that permits silence, which no call-site spelling can repair. `error` stays optional so reduced sinks remain representable. Pinned by reading the declaration's AST rather than a `@ts-expect-error`: measured, zero test files in this package reach any tsc program, so an expect-error here would evaluate never. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194kbQJxUvv2yvsGRtuXpP5 * test(security): pin SeedLogger's guaranteed warn channel at the type level The earlier AST-reading pin used import.meta.url, which is TS1470 under this package's CommonJS target and pushed TEST_DEBT 11 -> 12 on a shrink-only ratchet. Replaced with a type-level pin that needs no filesystem: the re-measure program DOES compile test files (it lifts the tsconfig test exclusion), so a @ts-expect-error here is enforced rather than phantom. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194kbQJxUvv2yvsGRtuXpP5 * test(security): pin SeedLogger's guaranteed warn channel as a runtime AST assertion check:type-check-coverage refuses a @ts-expect-error in a file no tsc program the typecheck script runs compiles, and PHANTOM_PIN_DEBT is closed to new entries — it named this file when the pin was written that way. Replaced with a runtime assertion over the declaration's own AST, seeded from __dirname (import.meta is TS1470 under this package's CommonJS resolution). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194kbQJxUvv2yvsGRtuXpP5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 431d2fb commit d48929e

10 files changed

Lines changed: 1279 additions & 34 deletions
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
fix(security): a refused RBAC catalog write is now boot-visible instead of reporting a seed of zero (#12923)
6+
7+
The five RBAC catalog seeders answered a refused write with `null`/`false`,
8+
which is byte-for-byte the answer for "nothing to do": the `seeded` counter
9+
never incremented and the pass returned normally. On a deployment still
10+
enforcing a **platform-wide** unique index on the name column — the shape that
11+
predates per-organization materialization — every per-organization INSERT is
12+
refused that way, so the boot log read as a successful seed of zero rows.
13+
Measured on a deployed plane, undetected for weeks: an empty Setup (no
14+
positions, no permission sets, no capabilities) under a clean log.
15+
16+
The outer handler was not missing, it was **disarmed**. `security-plugin.ts`
17+
already wrapped the organization-creation seed in a `try`/`catch` that warns,
18+
and it was unreachable for this failure class: the refusal was converted to
19+
`null` three call layers below, so the `await` resolved normally and the hook
20+
logged "RBAC catalog seeded" at `info` over a seed of nothing. Another outer
21+
`try`/`catch` would fix nothing — the signal has to survive the inner helper,
22+
which is where the change is.
23+
24+
Each seeder now accumulates the writes the database refused and reports them
25+
**once per object per class per pass**, beside its counts:
26+
27+
- a **unique violation** is named as a deployment-schema defect, with the
28+
migrate remedy (`os migrate plan``os migrate apply`, where the legacy
29+
index surfaces as a `replace_unique_index` operation) and a pointer to the
30+
query engine's own redacted `Insert operation failed` entries, which keep the
31+
colliding index identifier;
32+
- anything **else** gets its own line and is never relabelled as the above,
33+
because no migration repairs it.
34+
35+
Classification uses the shipped cross-dialect predicates in
36+
`@objectstack/types` (`isUniqueViolationError` / `uniqueViolationColumn`), not
37+
a local `23505` / `ER_DUP_ENTRY` regex. The warning prints only the value-free
38+
`code`/`errno` channel — never the driver's message, which a SQL driver
39+
prefixes with the fully bound statement.
40+
41+
Diagnosis only: the seeders still **warn and continue**, never throw. A rethrow
42+
would turn a silent degradation into a boot failure on every deployment
43+
carrying the legacy index. Counts, accept/reject behaviour and the healthy-path
44+
logs are unchanged, and a pass that refuses nothing stays silent.

packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,14 @@
3636

3737
import { BUILTIN_IDENTITY_NAMES, BUILTIN_IDENTITY_METADATA, EVERYONE_POSITION, GUEST_POSITION } from '@objectstack/spec';
3838
import {
39+
createSeedWriteRefusals,
3940
resolveOwnOrganizationRow,
4041
rowMatchesDeclaration,
4142
seedCtx,
4243
warnOrganizationLessRows,
44+
reportSeedWriteRefusals,
45+
type SeedLogger,
46+
type SeedWriteRefusals,
4347
} from './per-organization-catalog.js';
4448

4549
/**
@@ -73,15 +77,37 @@ async function tryFind(ql: any, object: string, where: any, limit = 100, organiz
7377
return Array.isArray(rows) ? rows : [];
7478
} catch { return []; }
7579
}
76-
async function tryInsert(ql: any, object: string, data: any, organizationId?: string): Promise<any | null> {
77-
try { return await ql.insert(object, data, { context: seedCtx(organizationId) }); } catch { return null; }
80+
// ⛔ The `catch` RECORDS before it answers — see the sibling in
81+
// `bootstrap-declared-positions.ts`. Answering `null`/`false` alone is what
82+
// made a refused write indistinguishable from "nothing to do".
83+
async function tryInsert(
84+
ql: any, object: string, data: any, organizationId?: string, refusals?: SeedWriteRefusals,
85+
): Promise<any | null> {
86+
try {
87+
return await ql.insert(object, data, { context: seedCtx(organizationId) });
88+
} catch (e) { refusals?.record(object, e); return null; }
7889
}
79-
async function tryUpdate(ql: any, object: string, data: any, organizationId?: string): Promise<boolean> {
80-
try { await ql.update(object, data, { context: seedCtx(organizationId) }); return true; } catch { return false; }
90+
async function tryUpdate(
91+
ql: any, object: string, data: any, organizationId?: string, refusals?: SeedWriteRefusals,
92+
): Promise<boolean> {
93+
try {
94+
await ql.update(object, data, { context: seedCtx(organizationId) }); return true;
95+
} catch (e) { refusals?.record(object, e); return false; }
8196
}
8297

8398
interface SeedOptions {
84-
logger?: { info: (m: string, meta?: Record<string, any>) => void; warn: (m: string, meta?: Record<string, any>) => void };
99+
logger?: {
100+
info: (m: string, meta?: Record<string, any>) => void;
101+
warn: (m: string, meta?: Record<string, any>) => void;
102+
/**
103+
* Durability channel for a catalog write that was refused — see
104+
* {@link SeedLogger.error} for the signature and why it is optional.
105+
* Declared here so the level this seeder reaches for is visible in its
106+
* own options rather than only inside the reporter; absent, the report
107+
* falls back to `warn` and is never dropped.
108+
*/
109+
error?: SeedLogger['error'];
110+
};
85111
/**
86112
* Seed THIS organization's copies. Omitted = the `single`-posture pass, the
87113
* one place an organization-less catalog row is the correct shape.
@@ -101,6 +127,8 @@ export async function bootstrapBuiltinRoles(
101127
let updated = 0;
102128
let unchanged = 0;
103129
const residue: string[] = [];
130+
// One log per pass, not per refused row (see the sibling seeders).
131+
const refusals = createSeedWriteRefusals();
104132
const rows: Array<[string, { label: string; description: string }]> = [
105133
...BUILTIN_IDENTITY_NAMES.map((n) => [n, BUILTIN_IDENTITY_METADATA[n]] as [string, { label: string; description: string }]),
106134
...Object.entries(AUDIENCE_ANCHOR_METADATA),
@@ -121,11 +149,11 @@ export async function bootstrapBuiltinRoles(
121149
if (own?.id) {
122150
// O(changed declarations): an unchanged row costs no write at all.
123151
if (rowMatchesDeclaration(own, fields)) { unchanged += 1; continue; }
124-
if (await tryUpdate(ql, 'sys_position', { id: own.id, ...fields }, organizationId)) updated += 1;
152+
if (await tryUpdate(ql, 'sys_position', { id: own.id, ...fields }, organizationId, refusals)) updated += 1;
125153
} else {
126154
const created = await tryInsert(ql, 'sys_position', {
127155
id: genId('position'), name, ...fields, active: true, is_default: false,
128-
}, organizationId);
156+
}, organizationId, refusals);
129157
if (created) seeded += 1;
130158
}
131159
}
@@ -134,6 +162,8 @@ export async function bootstrapBuiltinRoles(
134162
// writer survives for `sys_position`, so no platform bucket is declared.
135163
warnOrganizationLessRows(options.logger, 'sys_position', residue, organizationId);
136164
}
165+
// Before the counts, so an operator reads WHY the count is zero beside it.
166+
reportSeedWriteRefusals(options.logger, refusals, organizationId);
137167
if (seeded + updated > 0) {
138168
options.logger?.info?.('[security] built-in identity names + audience anchors seeded into sys_position', {
139169
seeded, updated, unchanged, total: rows.length, ...(organizationId ? { organization: organizationId } : {}),

packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ import {
7575
tryUpdate,
7676
type ProjectionLogger,
7777
} from './permission-set-projection.js';
78+
import {
79+
createSeedWriteRefusals,
80+
reportSeedWriteRefusals,
81+
type SeedWriteRefusals,
82+
} from './per-organization-catalog.js';
7883
import { buildExistingByName, type ExistingByNameIndex } from './seed-name-lookup.js';
7984
import { readDeclared } from './bootstrap-declared-permissions.js';
8085
import { PLATFORM_CAPABILITY_NAMES } from '@objectstack/spec/security';
@@ -264,6 +269,11 @@ async function upsertPackageCapability(
264269
*/
265270
existingByName: ExistingByNameIndex,
266271
logger?: ProjectionLogger,
272+
/**
273+
* Collects writes the database REFUSED, so the pass reports them once
274+
* instead of returning a zero that reads as "nothing to do".
275+
*/
276+
refusals?: SeedWriteRefusals,
267277
): Promise<boolean> {
268278
if (!cap?.name) return false;
269279

@@ -314,7 +324,7 @@ async function upsertPackageCapability(
314324
package_id: packageId,
315325
active: true,
316326
};
317-
const created = await tryInsert(ql, 'sys_capability', row);
327+
const created = await tryInsert(ql, 'sys_capability', row, undefined, refusals);
318328
if (created) {
319329
out.seeded += 1;
320330
// [#11096] The oracle is a SNAPSHOT taken before the loop, so it cannot
@@ -348,7 +358,7 @@ async function upsertPackageCapability(
348358
// `bootstrap-seed-round-trips.test.ts` exist to make impossible.
349359
if (!capabilityRecordDiffers(existing, fields)) {
350360
out.unchanged += 1;
351-
} else if (await tryUpdate(ql, 'sys_capability', { id: existing.id, ...fields })) {
361+
} else if (await tryUpdate(ql, 'sys_capability', { id: existing.id, ...fields }, undefined, refusals)) {
352362
out.updated += 1;
353363
}
354364
} else {
@@ -365,7 +375,7 @@ async function upsertPackageCapability(
365375
// Non-curated (curated excluded above) platform row = a derived-from-
366376
// systemPermissions placeholder. The explicit declaration CLAIMS it:
367377
// upgrade to package provenance with the authored label/description/scope.
368-
if (await tryUpdate(ql, 'sys_capability', { id: existing.id, ...fields, managed_by: 'package', package_id: packageId })) {
378+
if (await tryUpdate(ql, 'sys_capability', { id: existing.id, ...fields, managed_by: 'package', package_id: packageId }, undefined, refusals)) {
369379
out.claimed += 1;
370380
}
371381
return true;
@@ -399,6 +409,10 @@ export async function bootstrapDeclaredCapabilities(
399409

400410
const grantorsByCapability = indexGrantors(options.permissionSets);
401411

412+
// One log per pass, not per refused row: a legacy platform-wide unique index
413+
// refuses EVERY declared capability, and a line each would bury the remedy.
414+
const refusals = createSeedWriteRefusals();
415+
402416
// [#11096] ONE existence read for the whole declaration, hoisted out of the
403417
// loop below. Each declared capability used to cost its own sequential
404418
// `SELECT … WHERE name = ? LIMIT 1` — invisible on a local file database, one
@@ -426,13 +440,17 @@ export async function bootstrapDeclaredCapabilities(
426440
// spec `packageId` (ADR-0086 D3) as fallback.
427441
const packageId: string | undefined = cap._packageId ?? cap.packageId ?? undefined;
428442
const grantors = grantorsByCapability.get(cap.name) ?? [];
429-
const materialized = await upsertPackageCapability(ql, cap, packageId, out, grantors, existingByName, options.logger);
443+
const materialized = await upsertPackageCapability(ql, cap, packageId, out, grantors, existingByName, options.logger, refusals);
430444
// [#4967 Part 1] Report the name ONLY once this pass knows a row exists for
431445
// it. Reporting it before the upsert decided anything is what let a refused
432446
// declaration suppress the derivation it needed.
433447
if (materialized) out.materializedNames.push(cap.name);
434448
}
435449

450+
// Before the counts, so an operator reads WHY the count is zero beside it.
451+
// This seeder is organization-less today (see the lookup note above), so the
452+
// report carries no organization either.
453+
reportSeedWriteRefusals(options.logger, refusals);
436454
if (out.unreadable > 0) {
437455
// [#11096] Said ONCE with the count, like the sibling seeders: a per-name
438456
// warn on a database that is down is a log flood that buries its own

packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,12 @@ import {
6161
} from './seed-name-lookup.js';
6262
import { defaultPermissionSets } from './objects/default-permission-sets.js';
6363
import {
64+
createSeedWriteRefusals,
6465
resolveOwnOrganizationRow,
6566
seedCtx,
6667
warnOrganizationLessRows,
68+
reportSeedWriteRefusals,
69+
type SeedWriteRefusals,
6770
} from './per-organization-catalog.js';
6871

6972
export type { PermissionSeedOutcome } from './permission-set-projection.js';
@@ -190,6 +193,14 @@ export async function upsertPackagePermissionSet(
190193
organizationId?: string;
191194
/** Collects names whose pre-fix organization-less row is still standing. */
192195
residue?: string[];
196+
/**
197+
* Collects writes the database REFUSED, so the pass can report them once
198+
* instead of returning a zero that reads as "nothing to do". Passed by the
199+
* boot catalog loop; the ADR-0086 P2 publish materializer passes nothing
200+
* and keeps its own honest outcome (`success: false` with a reason when it
201+
* materialized nothing).
202+
*/
203+
refusals?: SeedWriteRefusals;
193204
},
194205
): Promise<PermissionSeedOutcome> {
195206
const out: PermissionSeedOutcome = { seeded: 0, updated: 0, unchanged: 0, unreadable: 0, skippedEnvAuthored: 0, skippedForeign: 0 };
@@ -232,7 +243,7 @@ export async function upsertPackagePermissionSet(
232243
package_id: packageId,
233244
managed_by: 'package',
234245
};
235-
const created = await tryInsert(ql, 'sys_permission_set', row, organizationId);
246+
const created = await tryInsert(ql, 'sys_permission_set', row, organizationId, opts?.refusals);
236247
if (created) {
237248
out.seeded += 1;
238249
// A batched oracle is a snapshot taken before the loop — tell it about
@@ -263,7 +274,7 @@ export async function upsertPackagePermissionSet(
263274
// beautiful round-trip curve.
264275
if (!recordDiffersFromBody(existing, ps)) {
265276
out.unchanged += 1;
266-
} else if (await tryUpdate(ql, 'sys_permission_set', { id: existing.id, ...permissionSetRowFields(ps) }, organizationId)) {
277+
} else if (await tryUpdate(ql, 'sys_permission_set', { id: existing.id, ...permissionSetRowFields(ps) }, organizationId, opts?.refusals)) {
267278
out.updated += 1;
268279
}
269280
} else {
@@ -325,13 +336,16 @@ export async function bootstrapDeclaredPermissions(
325336
// organization's own row is created regardless — the leftover is reported,
326337
// never treated as "already seeded" (#10103).
327338
const residue: string[] = [];
339+
// One log per pass, not per refused row: a legacy platform-wide unique index
340+
// refuses EVERY declared permission set, and a line each would bury the remedy.
341+
const refusals = createSeedWriteRefusals();
328342

329343
for (const ps of sets) {
330344
if (!ps?.name) continue;
331345
// Registry provenance first (ADR-0010 `_packageId`), author-declared
332346
// spec `packageId` (ADR-0086 D3) as fallback.
333347
const packageId: string | undefined = ps._packageId ?? ps.packageId ?? undefined;
334-
const r = await upsertPackagePermissionSet(ql, ps, packageId, options.logger, { existingByName, organizationId, residue });
348+
const r = await upsertPackagePermissionSet(ql, ps, packageId, options.logger, { existingByName, organizationId, residue, refusals });
335349
out.seeded += r.seeded;
336350
out.updated += r.updated;
337351
out.unchanged += r.unchanged;
@@ -349,6 +363,8 @@ export async function bootstrapDeclaredPermissions(
349363
options.platformBucketNames ?? SHIPPED_PLATFORM_BUCKET_NAMES,
350364
);
351365
}
366+
// Before the counts, so an operator reads WHY the count is zero beside it.
367+
reportSeedWriteRefusals(options.logger, refusals, organizationId);
352368
if (out.unreadable > 0) {
353369
// Said once, with the count: these sets were neither seeded nor reconciled
354370
// because the record could not be READ. Silence here would read exactly

packages/plugins/plugin-security/src/bootstrap-declared-positions.ts

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,23 +26,55 @@
2626
*/
2727

2828
import { buildExistingByName } from './seed-name-lookup.js';
29-
import { seedCtx, warnOrganizationLessRows } from './per-organization-catalog.js';
29+
import {
30+
createSeedWriteRefusals,
31+
seedCtx,
32+
warnOrganizationLessRows,
33+
reportSeedWriteRefusals,
34+
type SeedLogger,
35+
type SeedWriteRefusals,
36+
} from './per-organization-catalog.js';
3037

3138
function genId(prefix: string): string {
3239
const rand = Math.random().toString(36).slice(2, 10);
3340
const ts = Date.now().toString(36);
3441
return `${prefix}_${ts}${rand}`;
3542
}
3643

37-
async function tryInsert(ql: any, object: string, data: any, organizationId?: string): Promise<any | null> {
38-
try { return await ql.insert(object, data, { context: seedCtx(organizationId) }); } catch { return null; }
44+
// ⛔ The `catch` RECORDS before it answers. Answering `null`/`false` alone is
45+
// what made a refused INSERT indistinguishable from "nothing to do": `seeded`
46+
// never increments, the pass returns normally, and the boot logs a successful
47+
// seed of zero rows. The refusal log is what carries the signal past this
48+
// frame — see `reportSeedWriteRefusals`. Still no rethrow: the pass reports and
49+
// continues, it does not decide whether the deployment boots.
50+
async function tryInsert(
51+
ql: any, object: string, data: any, organizationId?: string, refusals?: SeedWriteRefusals,
52+
): Promise<any | null> {
53+
try {
54+
return await ql.insert(object, data, { context: seedCtx(organizationId) });
55+
} catch (e) { refusals?.record(object, e); return null; }
3956
}
40-
async function tryUpdate(ql: any, object: string, data: any, organizationId?: string): Promise<boolean> {
41-
try { await ql.update(object, data, { context: seedCtx(organizationId) }); return true; } catch { return false; }
57+
async function tryUpdate(
58+
ql: any, object: string, data: any, organizationId?: string, refusals?: SeedWriteRefusals,
59+
): Promise<boolean> {
60+
try {
61+
await ql.update(object, data, { context: seedCtx(organizationId) }); return true;
62+
} catch (e) { refusals?.record(object, e); return false; }
4263
}
4364

4465
interface SeedOptions {
45-
logger?: { info: (m: string, meta?: Record<string, any>) => void; warn: (m: string, meta?: Record<string, any>) => void };
66+
logger?: {
67+
info: (m: string, meta?: Record<string, any>) => void;
68+
warn: (m: string, meta?: Record<string, any>) => void;
69+
/**
70+
* Durability channel for a catalog write that was refused — see
71+
* {@link SeedLogger.error} for the signature and why it is optional.
72+
* Declared here so the level this seeder reaches for is visible in its
73+
* own options rather than only inside the reporter; absent, the report
74+
* falls back to `warn` and is never dropped.
75+
*/
76+
error?: SeedLogger['error'];
77+
};
4678
/**
4779
* Seed THIS organization's copies. Omitted = the `single`-posture pass, the
4880
* one place an organization-less catalog row is the correct shape.
@@ -124,6 +156,9 @@ export async function bootstrapDeclaredPositions(
124156
let updated = 0;
125157
let unchanged = 0;
126158
let unreadable = 0;
159+
// One log per pass, not per refused row: a legacy platform-wide unique index
160+
// refuses EVERY declared position, and a line each would bury the remedy.
161+
const refusals = createSeedWriteRefusals();
127162
for (const r of positions) {
128163
if (!r?.name) continue;
129164
const fields = positionRowFields(r);
@@ -152,14 +187,14 @@ export async function bootstrapDeclaredPositions(
152187
// re-seed (#2909 T2), so they can neither cause nor suppress one.
153188
if (!positionRecordDiffers(existing, fields)) {
154189
unchanged += 1;
155-
} else if (await tryUpdate(ql, 'sys_position', { id: existing.id, ...fields }, organizationId)) {
190+
} else if (await tryUpdate(ql, 'sys_position', { id: existing.id, ...fields }, organizationId, refusals)) {
156191
updated += 1;
157192
}
158193
} else {
159194
const row = {
160195
id: genId('position'), name: r.name, ...fields, active: true, is_default: false,
161196
};
162-
const created = await tryInsert(ql, 'sys_position', row, organizationId);
197+
const created = await tryInsert(ql, 'sys_position', row, organizationId, refusals);
163198
if (created) {
164199
seeded += 1;
165200
// The batched oracle is a snapshot taken before the loop; a name
@@ -174,6 +209,9 @@ export async function bootstrapDeclaredPositions(
174209
// row any more, so every leftover here really is pre-fix residue (#11532).
175210
warnOrganizationLessRows(options.logger, 'sys_position', residue, organizationId);
176211
}
212+
// Before the counts are reported, so an operator reads WHY the count is zero
213+
// in the same place they read the zero.
214+
reportSeedWriteRefusals(options.logger, refusals, organizationId);
177215
if (unreadable > 0) {
178216
// Said once, with the count — see the sibling warn in
179217
// `bootstrap-declared-permissions.ts`.

0 commit comments

Comments
 (0)