Census the catch { return null; } durability swallow family, and repair the first two members (#12981) - #13397
Conversation
Adds `scripts/measure-durability-swallow-family.mjs`, a MEASUREMENT
instrument (not a gate, not in any workflow, not named `check:*`) that
enumerates the `catch { return null; }` family the durability log-level
gate is structurally blind to.
The predicate, not the list, is the deliverable: a silent catch, no
rethrow, and an AWAITED write-shaped callee reached in the guarded block.
Members split into three tiers by what survives the catch — dark,
carries-error, channelled — because "the caller still reports success" is
inter-procedural and is not pretended to be mechanical.
Controls are declared, not derived from a run: 3 positive (one per tier),
3 negative (files matching the raw syntactic shape with no durability
seam), 1 regression.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WkdHQwHr2KQmaX7P1BHzi
…ing as a clean one plugin-auth `ensureDefaultOrganization`: a refused sys_organization or sys_member insert leaves the platform admin with no organization while the boot goes on looking healthy. Both lines move from `warn` to `error` and name the consequence and the remedy. `BootstrapLogger` gains an OPTIONAL `error` beside its required `warn`, with the mandatory fallback in one helper. plugin-sharing `backfillPrimaryBu`: per-row refusals were uncounted and the report was gated on `updated > 0`, so a fully refused backfill printed nothing at all. Refusals are counted, reported once, and the gate is now `updated > 0 || refused > 0` — the #12970 `permission-set-drift.ts` repair. The census instrument learns the accumulator channel so a caller-side repair is visible to a re-run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WkdHQwHr2KQmaX7P1BHzi
…allow-family-census
📓 Docs Drift CheckThis PR changes 2 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d31d8206d06790a8fec09932911d2d35666fc47d && git checkout d31d8206d06790a8fec09932911d2d35666fc47d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 83546d97053009cc84b90e1addeee7163d99911c 541f65eae94077f30f3ef21c19031bda11f53efa && git checkout -B drift-repro 83546d97053009cc84b90e1addeee7163d99911c && git merge --no-ff 541f65eae94077f30f3ef21c19031bda11f53efa
node scripts/docs-audit/affected-docs.mjs --json 83546d97053009cc84b90e1addeee7163d99911c
|
Part of #12981
⛔ Deliberately NOT a closing keyword. #12981 is a WORKLIST card covering the whole family — the ruling's own shape is "step one is a mechanical census … then batched repairs". This PR repairs 2 of the 28 tier-1 sites and enumerates the other 26 in §4; closing the card on merge would orphan that tail, leaving the worklist alive only inside a merged PR body.
Step one of the repair-first worklist the maintainer ruled on 2026-08-29 (verbatim 「同意」, option A): a mechanical census with positive controls enumerating the full membership, plus the first repair batch. The gate vocabulary is untouched in either direction, and
scripts/durability-degradation.baseline.jsonkeeps its designed empty steady state — not one entry added.1. The census —
scripts/measure-durability-swallow-family.mjsA measurement instrument, not a gate: not wired into any workflow, exits 0 on any membership count, and deliberately not named
check:*orgen:*so the #4203 script ledger has nothing to classify (the shapemeasure-partial-retirement-annotation.mjsestablished).The predicate is the deliverable, not the list. A try/catch is a MEMBER when all three hold:
try— a callee from the declaredWRITE_SHAPED_CALLEESvocabulary, directly or through same-file helpers.Conjunct 3 is the durability filter, and it is what the card's "15" always was without ever being written down. Written down, it is measurable — see the ablation below.
Why a census may use a vocabulary the GATE may not
The gate's header excludes
find/findOne/countas "names too generic to declare repo-wide", andinsert/update/deleteare in the same class. That exclusion is right FOR A GATE, because a gate BLOCKS and a disabled gate is worse than none. A census REPORTS, and its failure mode is the opposite: a member it cannot see is a member nobody repairs. So it over-collects on purpose, labels every bucket, and prints what it dropped.AWAITED, the narrowing that earns its keep
temporalRewriteCache.delete(first)informula/src/cel-engine.tsis a Map eviction spelled exactly like a driver delete. Type information would separate them; a full Program is not worth its cost here. The structural discriminator is that every driver and ObjectQL write in this repo is async and awaited, and noMap/Set/Arraymutation ever is. 105 calls are dropped by it, and the count plus the per-name split is printed on every run — a narrowing nobody can see is indistinguishable from a matcher that stopped matching.Measured, on
541f65eaeTier 1 is the card's family and is mechanically decided. Tier 2 is where the judgement lives and it is not pretended otherwise: "the caller still reports success" is inter-procedural, and deciding it needs the call graph of every caller across package boundaries plus a judgement about whether the counter the caller increments is the one it later prints. Instead of faking that, the census prints the EVIDENCE per site — the enclosing function, and whether the file carries a quiet-level summary log outside any catch (the "RBAC catalog seeded" line that makes a zero-row pass read healthy) — and leaves the verdict to a person.
Controls, declared rather than derived from a run
A control read off the output only proves the instrument agrees with itself, so every one of these was chosen by reading code first.
--self-testasserts them.permission-set-projection.ts(channelled, fix(security): RBAC catalog seeder swallows unique-violation write failures — 'seeded 0' reported as success while a legacy index vetoes every row #12923's shared accumulator),primary-bu-projection.ts(channelled via the weakest channel,accumulator-reported),crud-nodes.ts(carries-error),bootstrap-system-capabilities.ts(dark — the card's shape verbatim, still standing).cel-engine.ts,pinyin-search-plugin.ts,zod-graph.ts. A membership number with no negative control is the same false green this whole card is about.db-job-adapter.ts :: cancel. Its try block isawait this.cron.cancel(name)— the CronJobAdapter's method. Resolving a dotted callee to a same-file body by its LAST segment walked into the file's OWNcancel(), reachedsetActive()'sengine.update(...)two frames on, and reported a cron-registry cleanup as a swallowed durability write.sameFileCalleenow resolves only barefoo(...)andthis.foo(...).Reverse verification — direction predicted BEFORE the run
Prediction: ablating conjunct 3 (the durability filter) turns
--self-testRED with all three negative controls firing.Mutation proven on disk before anything was read.
if (!write) return;→if (!write) write = { callee: 'ABLATED', ... };, restore trapped onEXIT INT TERMagainst an absolute path:Observed: exactly the predicted direction.
ABLATED_SELFTEST_EXIT=1, and the negative controls yielded 23 / 2 / 3 members respectively (plus the regression control). The durability filter is load-bearing and now measurably so.Restore proven by state, not by exit code: blob back to
66b22bd2…, marker count 0, guard count 1,git diff HEADempty,git statusclean. Nodist/participates — the instrument is executed from source bynode, so there is no rebuild leg to prove.2. The repair batch — 2 files, and why only 2
plugin-sharing—backfillPrimaryBuThe exact shape #12970 repaired in
permission-set-drift.ts, still standing here: a per-rowcatch { }that counted nothing, and a report gated onupdated > 0. Together they made a pass in which EVERY write was refused print byte-identical output to a pass with nothing to do, while every affected user kept a stale or absentprimary_business_unit_idand every sharing rule keyed on the primary business unit evaluated against the wrong value. Refusals are now counted, reported once with the consequence and the remedy, and the branch isupdated > 0 || refused > 0.warnand that is a decision, not an oversight.OptionalSharingLogger's own header ⛔ forbids growing anerror(it would enrol every module on that type intocheck:optional-error-sink-contract's population at once, and that ledger is shrink-only). Giving this function a stricter sink instead means requiringwarnon a publicly exported shape — the breakscripts/optional-error-sink-contract.baseline.jsonrecords in as many words, citing the maintainer ruling of 2026-08-24 and theminorthat shipped it for external hosts.OptionalSharingLogger's own header and C recorded as the end state needing aCONTRACT_REVIEW_TIERseat. #10556 remains the right citation for the PRECEDENT — how that break was shipped — and is not the route.What is fixed here is the SILENCE, which needed no contract at all; the LEVEL is #13398's.
plugin-auth—ensureDefaultOrganizationA refused
sys_organizationorsys_memberinsert leaves the platform admin with no organization: multi-org RLS filters their console to zero rows, single-org has no active org to invite into (ADR-0081 D1). Reported atwarn. Both lines now report aterror, each naming the consequence AND the remedy.BootstrapLoggergains an OPTIONALerrorbeside its already-requiredwarn— additive, so a host passing{ info, warn }compiles unchanged and gets the same line on thewarnchannel.The fallback is spelled
if (logger?.error) logger.error(m, undefined, meta); else logger?.warn?.(m, meta), re-derived fromlogSeedDurabilityFailurewith both of its measured prohibitions: ⛔ neverlogger?.error?.(…)(prints nothing against a reduced sink), ⛔ never(logger.error ?? logger.warn)(…)(detaches the receiver and throws against a class sink while surviving every plain-closure double).check:logger-receiver-detachandcheck:optional-error-sinkboth pass.The batch is 2 files because the census says the channel does not reach further — see §3.
3. PM mechanism assumptions — results
① Is the predicate mechanically expressible? PARTLY, and the split is now explicit rather than hidden. Conjuncts 1–3 are fully mechanical and give tier 1 (28 sites / 14 files). "The caller keeps reporting success" is NOT, and is reported as tier 2 with per-site evidence. No regex was tuned to return 15.
② Does #12923's shared reporter fit every member? NO — FALSIFIED, and more sharply than expected.
createSeedWriteRefusals/reportSeedWriteRefusals/logSeedDurabilityFailureall live inpackages/plugins/plugin-security/src/per-organization-catalog.tsand none of them is exported from that package'sindex.ts(its only seed-adjacent export isclaimSeedOwnership). They are intra-package helpers by design, and the header oflogSeedDurabilityFailuresays so in as many words. So the channel does not merely "not fit" a non-seeder swallow — it is not reachable from any package outsideplugin-securityat all, and reaching it would mean a new runtime dependency on the security plugin plus an export change inside the package this card is fenced out of.What IS transferable is the spelling, not the accumulator, and that is what the
plugin-authrepair uses.③ Do the 52 and the 15 differ only by the durability filter? YES. The raw multiline shape re-measured 52 files on
541f65eae— the same 52 as onaa16721b6, so the tree did not grow members. The gap is entirely the filter, now written down as conjunct 3, and the ablation above shows what removing it does.Bonus falsification, for the record. Triage's confidence gap ③ ("I could not count the 18-entry vocabulary; my regex returned 0, which is my query being broken, not the vocabulary being empty") — it is 18, machine-counted.
4. The remainder, enumerated honestly
The 26 tier-1 sites this PR does not repair, and why each is left:
plugin-security—bootstrap-system-capabilities.ts×2,suggested-audience-bindings.ts×4,cleanup-package-permissions.ts×1bootstrap-system-capabilities.tsis the card's shape verbatim and the shared accumulator IS reachable in-package.runtime—action-execution.ts,domains/keys.tsplugin-auth—admin-user-endpoints.ts,admin-import-users.tssys_audit_loginsert, and both carry a documented rationale: plugin-audit is OPTIONAL, so with it uninstalled there is nosys_audit_logtable and a bareerrorfires on EVERY admin action — the mirror-image failure AGENTS.md names. The correct repair discriminates with the shippedisMissingTableErrorpredicate, which is exported from@objectstack/metadata— a packageplugin-authdoes not depend on, at all. Adding that dependency is an architecture decision, not a mechanical repair. Routed: #13399, which records recommendation B (re-export the predicate from a packageplugin-authalready depends on) together with the note that doing so widens a published surface and is therefore above the implementing seat.plugin-auth—auth-manager.ts×9,auth-plugin.ts×2recordSignInOutcome's lockout accounting silently not landing means brute-force protection silently does not work), and each needs its own argument against its author's. Nine at once inside a census PR is the failure mode triage warned about.service-storage—storage-service-plugin.ts×2metadata-protocol—protocol.ts(reassignOrphanedMetadata)plugin-sharing—share-link-service.tsuse_count/last_used_attelemetry stamp. Escalating a functional degradation toerroris the over-application AGENTS.md forbids.verify—harness.ts5. Verification — all on
541f65eae, the merged head this PR carriespnpm --filter '@objectstack/plugin-auth^...' --filter '@objectstack/plugin-sharing^...' build, then both packages' own build — VERDICT command-exit 0.plugin-sharing:typecheck+vitest run— 30 files / 678 tests passed; the newprimary-bu-projection.test.tsrun targeted — 1 file / 5 tests passed.plugin-auth:typecheck(both legs, examples included) +vitest run— 85 files / 1729 tests passed, one locked run joined with&&so the VERDICT covers every part.typecheckscripts exclude**/*.test.ts, so their green says NOTHING about the two test files this PR touches. Measured separately with a test-inclusivetsc --noEmit --listFiles: both files are confirmed IN the program (--listFilesgrep, 1 hit each) and neither produces a single error. The 97 errors that run does report are pre-existing debt in other test files — 94 inplugin-auth, 3 inplugin-sharing, none in anything this PR wrote.pnpm lint(eslint . --no-inline-config), not a narrowed scan — VERDICT command-exit 0.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(the first derivation warned STALE TREE; after mergingorigin/mainthe derivation is clean and the matched list is byte-identical). 37 families run locally, all green, including the two this change most plausibly moves:check:logger-receiver-detachandcheck:optional-error-sink.check:durability-log-level— the gate this card is about — is green and, per the standing premise, that green is NOT MEASURED for every file listed in §4.pm/check-half-statesexits 3 for want of a GitHub token ("nothing was swept"), andcheck:i18nexited 1 with "PREREQUISITE NOT MET — the workspace CLI is not built / nothing was checked". The i18n prerequisite was then cleared with its own named turbo closure and re-run green (9 packages, all bundles in sync).cmd > log 2>&1; EXIT=$?), and every verdict quoted above is the gate's own printed line, never a bare$?.check:type-check-debt --re-measureis left to CI: it refuses without the whole workspace closure built, and a refusal is NOT MEASURED rather than a pass. Its structural half,check:type-check-coverage, is green here.Authoring session (durable copy — the footer below is rewritten to its bare form by every body edit): https://claude.ai/code/session_012WkdHQwHr2KQmaX7P1BHzi
Generated by Claude Code