Skip to content

Commit bd0c5cc

Browse files
os-zhuangclaude
andauthored
fix(engine): a failed sys_organization read is no longer answered as an emptiness — the two genuinely-wrong read seams (#12864)
* fix(metadata-protocol): a failed sys_organization probe is not 'no sole organization' `SeedLoaderService.resolveSoleOrganizationId()` sat behind a bare `catch {}` whose comment named ONE benign cause while the catch swallowed every cause. A dropped connection, a timeout or a permission refusal all arrived at the caller as `undefined` — the 'genuinely ambiguous' verdict — so `load()` stamped no `organization_id` and every business seed row of that run landed org-less, invisible afterwards under strict org-scoping, with the seed report's `errors` field never touched. Discriminate by error TYPE through the shared `isMissingTableError` predicate, which is the repair PR #9817 already landed on the sibling probe (`ObjectQL.probeInstallOrganizations`) with this exact shape. Only an unprovisioned table is truthful emptiness; everything else propagates. Part of #12852 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry * fix(objectql): a failed sys_organization tenant scan does not decide a retention window `LifecycleService.loadGovernance()` filled `snapshot.tenantOverrides` — the ADR-0057 3.2 per-tenant retention/expiry window set — behind a bare `catch {}` whose comment named one benign cause while the catch swallowed every cause. On any read failure the map came back EMPTY, which `reap()` and `archiveObject()` read as 'this deployment has tuned no tenant': every tenant fell back to the global window, so a tenant configured to retain LONGER had its rows expired early. Nothing reported it — the snapshot has no field for an incomplete tenant pass and the catch logged nothing. Discriminate by error TYPE through the shared `isMissingTableError` predicate. An unprovisioned `sys_organization` really is 'no tenant overrides', so a single-tenant kernel is unchanged; every other cause aborts the sweep before any policy is applied. For a deletion action, not acting on incomplete evidence is the correct failure direction. The abort is reported, not thrown: one `report.errors` entry per declared object plus a warn. `sweep()`'s declared contract is that it never throws, and the scheduler enters it as `void this.sweep()` where a rejection would be unhandled — the objection #8906 recorded when it declined to rethrow from `checkGovernance` one method below. Part of #12853 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry * test(ablation-A): prediction before mutating ONLY the #12852 seam Ablation A reverts the seed-loader narrowing (packages/metadata-protocol/src/ seed-loader.ts, resolveSoleOrganizationId) back to a bare swallow, leaving the #12853 seam untouched. PREDICTION, committed before the mutation runs: - Direction: RED. Not 'fewer diagnostics' and not a reversal — the narrowing is the only thing that turns a non-benign read failure into a propagated error, and three pins assert exactly that. - seed-loader-sole-organization-read-failure.test.ts: 3 of 9 FAIL — 'a dropped connection surfaces that error and writes NO org-less rows', 'a permission refusal surfaces the same way', 'a missing COLUMN on an existing sys_organization stays loud (the superstring case)'. Each fails inside the rejection() helper: the load RESOLVES instead of rejecting, and the store carries an org-less widget row. - The other 6 in that file stay GREEN (two probe controls, the several-orgs control, both benign phrasings, and the pinned-organizationId non-effect): none of them reaches a non-benign throw. - objectql lifecycle-service.test.ts: 109 of 109 stay GREEN. Ablation A must red ONLY its own pins; a run that reds #12853's pins too proves neither card. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry * test(ablation-B): prediction before mutating ONLY the #12853 seam Ablation B reverts the lifecycle narrowing (packages/objectql/src/lifecycle/ lifecycle-service.ts, loadGovernance's tenant scan) back to a bare swallow, leaving the #12852 seam untouched. The sweep()-side containment stays in place deliberately: without the throw it is unreachable, which is exactly what the pins must detect. PREDICTION, committed before the mutation runs: - Direction: RED. The narrowing is the only thing that stops a non-benign read failure being answered as an empty tenant-override map. - lifecycle-service.test.ts: 4 of 109 FAIL, all in the #12853 describe — 'a dropped connection aborts the sweep: nothing is read as a candidate and nothing is deleted' (the reaper reads a candidate page and deletes on the GLOBAL 30d window instead of the tenant's 90d, and report.errors is empty), 'a permission refusal aborts it the same way', 'a missing COLUMN on an existing sys_organization stays loud (the superstring case)', and 'the abort is REPORTED, never thrown — sweep() still resolves, and the next sweep runs' (first.errors is empty and box.deletes is not). - The other 105 stay GREEN, including this describe's two controls and the benign unprovisioned-table case: none of them reaches a non-benign throw. - metadata-protocol seed-loader-sole-organization-read-failure.test.ts: 9 of 9 stay GREEN. Ablation B must red ONLY its own pins. Ablation A already ran and matched its own prediction: 3 failed / 6 passed in the #12852 file, 109/109 green in this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry * test(metadata-protocol): make the new fixture's WHERE matcher liftable, and pin its engine double Two gate families move on a NEW test file, and both were red before this: - `pnpm check:where-matcher`: the fixture's matcher was an inline closure the gate could not lift out of the file ('could not lift: ReferenceError: Cannot access store before initialization'), so it landed as 1 UNJUDGED — and 'could not run' is a failure, not a pass. Lifted to a module-level `matchesWhere(row, where)` with no free bindings; behaviour is identical (flat equality, and a `$`-prefixed combinator is REFUSED rather than read as a column name). Gate now: 310 discovered, 310 conforming, 194 by refusing, 0 unjudged. - `pnpm check:engine-double-contract`: the file's engine double is pinned to the producer's own dispatch predicates, but the shrink-only PINNED ledger did not know the file, so the pins protected nothing. Recorded via `node scripts/check-engine-double-contract.mjs --write` — 3 rows added (delete/findOne/update), 0 lost, and no DEBT-baseline row was touched. Part of #12852 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6228c1f commit bd0c5cc

7 files changed

Lines changed: 816 additions & 8 deletions
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): a failed `sys_organization` tenant scan no longer decides a retention window (#12853)
6+
7+
`LifecycleService.loadGovernance()` filled `snapshot.tenantOverrides` — the
8+
ADR-0057 §3.2 per-tenant retention/expiry window set — behind a bare `catch {}`
9+
whose comment named ONE benign cause ("No sys_organization (single-tenant
10+
kernel)") while the `catch` swallowed every cause. On a connection drop, a
11+
timeout, a permission refusal or a driver fault the map came back EMPTY, and an
12+
empty map is not a neutral value: `reap()` and `archiveObject()` read it as
13+
"this deployment has tuned no tenant" and fall every tenant back to the global
14+
window. That window is wrong in both directions, and the expensive direction is
15+
a tenant configured to retain LONGER having its rows expired early. Nothing
16+
reported it: `GovernanceSnapshot` carries no field saying the tenant pass did not
17+
complete, and the catch logged nothing — so the platform deleted on knowingly
18+
incomplete evidence, without knowing the evidence was incomplete.
19+
20+
The scan now discriminates by error TYPE through the shared
21+
`isMissingTableError` predicate. An unprovisioned `sys_organization` really does
22+
mean "no tenant overrides", so a single-tenant kernel is unchanged. Every other
23+
cause aborts the sweep **before any policy is applied** — for a deletion action,
24+
"do not act on incomplete evidence" is the correct failure direction, and a log
25+
cannot bring back a reaped row. The rows a deferred sweep leaves are still there
26+
for the next one.
27+
28+
Operational posture change, deliberate and worth stating: a transient
29+
`sys_organization` outage now costs a sweep. The abort is REPORTED, not thrown —
30+
one `report.errors` entry per declared object plus a `warn` — because `sweep()`'s
31+
declared contract is that it never throws and the scheduler enters it as
32+
`void this.sweep()`, where a rejection would be unhandled. That is the same
33+
objection #8906 recorded when it declined to rethrow from `checkGovernance` one
34+
method below.
35+
36+
Bump argued, not defaulted: `patch`. No exported signature, type, option or
37+
report field moves — the failure surfaces through `LifecycleSweepReport.errors`,
38+
which already exists for exactly this. The tension is honest and does not change
39+
the answer: what a deployment observes on a failure path DOES change (a sweep
40+
that used to complete silently now aborts and says so), but that is the
41+
correction of a defect, not a new capability, and the sibling repairs in this
42+
family (#8896, #8906, #9817) all shipped as `patch`.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): a failed `sys_organization` probe is no longer answered as "no sole organization" (#12852)
6+
7+
`SeedLoaderService.resolveSoleOrganizationId()` sat behind a bare `catch {}` whose
8+
comment named ONE benign cause — "sys_organization may not exist (single-tenant
9+
runtime)" — while the `catch` swallowed every cause. A dropped connection, a
10+
timeout, a permission refusal or a driver fault all arrived at the caller as
11+
`undefined`, which is not a neutral value here: it is the verdict the method's
12+
own JSDoc calls "genuinely ambiguous", so `load()` stamped no `organization_id`
13+
and every BUSINESS seed row of that run landed org-less — invisible afterwards
14+
under strict org-scoping. Nothing reported it either: `SeedLoadResult` carries an
15+
`errors` field and this path never touched it, so the operator saw a clean,
16+
successful seed.
17+
18+
The repair is the one already landed on the sibling probe across the engine
19+
boundary (`ObjectQL.probeInstallOrganizations`, #9817), copied: bind the
20+
parameter and ask the declared predicate. Only an unprovisioned TABLE is
21+
truthful emptiness — the exact cause the swallowed comment already named — so
22+
the JSDoc's "or when `sys_organization` is absent" stays true, while every other
23+
cause now propagates with its envelope intact.
24+
25+
Bump argued, not defaulted: `patch`. No exported signature, type or option
26+
moves, and the declared answer for every case the JSDoc describes is unchanged.
27+
What changes is a failure path — a seed run that used to complete while writing
28+
invisible rows now fails loudly — which is the correction of a defect rather
29+
than a new capability. The three landed repairs in this family (#8896, #8906,
30+
#9817) all shipped as `patch`, and this is the site that pass missed.

0 commit comments

Comments
 (0)