From 1180cda10dc30a27c226564bc98476b3b544acf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 01:52:42 +0000 Subject: [PATCH 1/3] fix(objectql): a tenant's rename beats a package extension's scalar in the object fold (#8460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0029 D9.2 makes the tenant's overlay the BASE of the object fold, and mergeObjectDefinitions applied an extender's label / pluralLabel / description LAST onto whatever base it was handed. So a rename saved through Studio answered 200, showed up under ?layers=true, and was overwritten inside the fold before any read served it. Per the 2026-08-13 maintainer ruling (option A), an extender's scalar now applies only while the fold's base still carries the packaged owner's value; a diverged base was authored by the tenant and the extender yields. This is deliberately the SAME comparison-based mechanism #8284 established one layer up for the i18n catalog — scalarOverridesPackagedBase is exported from @objectstack/spec and imported by the registry rather than re-spelled, so one sentence governs both layers: an explicit override beats a packaged default. No provenance flags, no migration, no escape hatch. The set is computed once over the base the fold starts from, never re-derived from the running merge, so extender-vs-extender precedence (D9.3) is untouched. Idempotence (#8027) is preserved by construction. Amends ADR-0029 D9.2 (new D9.2a). Flips #8284's staged acceptance pin in showcase-object-extension-scalar-divergence.dogfood.test.ts to green, unmodified in its assertions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy --- .../tenant-rename-beats-extension-scalar.md | 24 ++++ ...ship-and-platform-objects-decomposition.md | 62 +++++++++ packages/objectql/src/registry.ts | 119 ++++++++++++++++-- ...xtension-scalar-divergence.dogfood.test.ts | 67 ++++++---- packages/spec/export-origins/system.json | 1 + packages/spec/src/system/i18n-resolver.ts | 17 ++- 6 files changed, 260 insertions(+), 30 deletions(-) create mode 100644 .changeset/tenant-rename-beats-extension-scalar.md diff --git a/.changeset/tenant-rename-beats-extension-scalar.md b/.changeset/tenant-rename-beats-extension-scalar.md new file mode 100644 index 0000000000..dc6624401b --- /dev/null +++ b/.changeset/tenant-rename-beats-extension-scalar.md @@ -0,0 +1,24 @@ +--- +'@objectstack/objectql': patch +'@objectstack/spec': patch +--- + +A tenant's own object rename now wins over a package extension's scalar in the object fold (#8460). + +`mergeObjectDefinitions` applied an `objectExtensions` entry's `label` / +`pluralLabel` / `description` last, onto whatever base it was handed — and +ADR-0029 D9.2 makes the tenant's overlay that base. So a rename saved through +Studio answered `200`, was visible under `?layers=true`, and was overwritten +inside the fold before any read served it. + +Per the 2026-08-13 maintainer ruling (ADR-0029 D9.2a), an extender's scalar now +applies only while the fold's base still carries the packaged owner's value; a +base that has diverged was authored by the tenant, and the extender yields. This +is the same comparison-based mechanism #8284 established for the i18n catalog +one layer up — the same predicate, now exported from `@objectstack/spec` and +imported by the registry rather than re-spelled — so one rule covers both +layers: an explicit override beats a packaged default. + +No provenance flags and no migration: the question is answered from two values +at fold time. The accepted cost is deliberate — a package can no longer relabel +an object a tenant has deliberately renamed. diff --git a/docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md b/docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md index de4df5bd89..066880a140 100644 --- a/docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md +++ b/docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md @@ -472,6 +472,68 @@ the one the ruling asked for: nobody asked for, arriving as a silent re-appearance of deleted fields. - **A second `own` is refused** — see § 4. +##### D9.2a — AMENDMENT (2026-08-13, #8460): an extender's SCALAR yields to a diverged base + +D9.2 above says the fold runs "exactly as it does today", and for `fields`, +`validations` and `indexes` it still does. For the three **scalars** — `label`, +`pluralLabel`, `description` — it no longer does, and this clause is the +difference. + +**Ruling (maintainer, 2026-08-13, option A — "tenant wins"):** an extender's +scalar applies only while the fold's base still carries the **packaged owner's** +value. A base whose scalar has diverged from the owner's has been authored by +the tenant, and the extender **yields**. + +Why this had to be decided rather than left to D9.2's "last writer wins": D9.2 +makes the tenant's overlay the *base* of the fold, so last-writer-wins meant a +code package's `objectExtensions` scalar overwrote the tenant's own Studio +rename *inside* the fold. The tenant's value was then absent from the document +every read serves — `PUT /meta/object/:name` answered `200`, `?layers=true` +showed the saved value under `overlay`, and no read a writable form derives from +ever showed it (#8037, #8027/#8045, and the severe half of #8284). + +The mechanism is **comparison-based provenance**, and is deliberately the *same* +mechanism [#8284](https://github.com/objectstack-ai/objectstack/issues/8284) +established one layer up for the i18n catalog — the same predicate, imported by +`SchemaRegistry` from `@objectstack/spec`, not a second copy free to drift. One +sentence now governs both layers: **an explicit override beats a packaged +default.** + +Binding consequences: + +- **No provenance flag and no migration.** Nothing is stamped on the document; + the question is answered from two values at fold time. A flag threaded through + the fold was explicitly rejected. +- **The comparison is against the packaged owner ALONE**, never against the + owner with extenders already folded on (D9.6's `resolveOwnerLayer`) — that + body reports every extender's scalar as "unchanged" and would yield nothing, + ever. +- **Computed once, over the base the fold starts from**, never re-derived from + the running merge. Re-deriving would make one extender's scalar look + "authored" to the next and silently invert extender-vs-extender precedence, + which D9.3 reserves to declared priority. +- **Conservative edges** (inherited from the shared predicate): an absent base, + a non-string or empty value, and inexact equality all mean "no opinion", so + this can only ever *withhold* an extender's scalar from a value that provably + diverged. A tenant who renames an object to exactly the packaged string is a + no-op, by construction. +- **Idempotence (#8027) is preserved.** A base that already carries an + extender's scalar reads as diverged, so the extenders yield and the value + stays what the first fold produced — the same answer, reached by yielding + instead of by re-applying. +- **The accepted cost is the point, not a regression:** a package can no longer + relabel an object a tenant has deliberately renamed. There is **no escape + hatch**, by ruling. Note the honest edge this implies: because the write path + persists the served body verbatim (ADR-0005 §Validation), a tenant who + round-trips an object *without* renaming it freezes the extender's current + scalar into the overlay row, and a later change to the package's extension + scalar will not reach that tenant. That follows from comparison-based + provenance with no flags, which is what the ruling required; it is recorded + here rather than papered over. +- Options B (status quo — the extension keeps winning) and C (refuse the write) + were considered and **rejected**. Dropping scalars from the fold entirely + (#8284's arm B) remains rejected and is not this clause. + #### D9.3 — selection is by KIND; priority stays descriptive `contributors.sort((a, b) => a.priority - b.priority)` (`:1189`) totals the whole diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 1954a95a1d..43dd9d5cf4 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -17,7 +17,11 @@ import { ITEM_KEY_DISCRIMINATORS, readDiscriminatorValue as discriminatorValue, } from '@objectstack/metadata-core'; -import { SystemFieldName } from '@objectstack/spec/system'; +// [#8460] `scalarOverridesPackagedBase` is the #8284 comparison, imported rather +// than re-spelled: the object FOLD asks the same question one layer down (has +// this scalar been authored away from the packaged default?), and the ruling +// required the same mechanism, not a second comparison shape. +import { SystemFieldName, scalarOverridesPackagedBase } from '@objectstack/spec/system'; import { resolveTenancyPosture, resolveSearchPinyinEnabled } from '@objectstack/types'; import { postureEnforcesWall } from '@objectstack/spec/security'; import { provisionSearchCompanion, SEARCH_COMPANION_FIELD } from './search-companion.js'; @@ -111,11 +115,30 @@ export function parseFQN(fqn: string): { namespace: string | undefined; shortNam }; } +/** + * The three SCALAR props {@link mergeObjectDefinitions} resolves last-writer-wins + * — the exact set the #8284 and #8460 rulings both cover, and the same three + * {@link scalarOverridesPackagedBase} answers for. + */ +const OBJECT_FOLD_SCALAR_KEYS = ['label', 'pluralLabel', 'description'] as const; + +type ObjectFoldScalarKey = (typeof OBJECT_FOLD_SCALAR_KEYS)[number]; + /** * Deep merge two ServiceObject definitions. * Fields are merged additively. Other props: later value wins. + * + * [#8460] …except that "later value wins" is now conditional for the three + * SCALARS. `tenantAuthored` names the scalars the fold's BASE has authored away + * from the packaged owner's value; an extender yields on those. See + * {@link SchemaRegistry.tenantAuthoredScalars} for why the set is computed once + * over the base rather than re-derived from the running `merged`. */ -function mergeObjectDefinitions(base: ServiceObject, extension: Partial): ServiceObject { +function mergeObjectDefinitions( + base: ServiceObject, + extension: Partial, + tenantAuthored?: ReadonlySet, +): ServiceObject { const merged = { ...base }; // Merge fields additively @@ -133,10 +156,16 @@ function mergeObjectDefinitions(base: ServiceObject, extension: Partial tenantAuthored?.has(key) === true; + if (extension.label !== undefined && !yields('label')) merged.label = extension.label; + if (extension.pluralLabel !== undefined && !yields('pluralLabel')) { + merged.pluralLabel = extension.pluralLabel; + } + if (extension.description !== undefined && !yields('description')) { + merged.description = extension.description; + } return merged; } @@ -1576,15 +1605,91 @@ export class SchemaRegistry { contributors: ObjectContributor[], baseDefinition: ServiceObject, ): ServiceObject { + // [#8460] Computed ONCE, over the base the fold starts from — never + // re-derived from the running `merged`, which would make an extender's own + // scalar look "authored" to the next extender and silently invert + // extender-vs-extender precedence (D9.3: declared numbers order peers). + const tenantAuthored = this.tenantAuthoredScalars(contributors, baseDefinition); let merged = { ...baseDefinition }; for (const contrib of contributors) { if (contrib.ownership === 'extend') { - merged = mergeObjectDefinitions(merged, contrib.definition); + merged = mergeObjectDefinitions(merged, contrib.definition, tenantAuthored); } } return merged; } + /** + * [#8460] Which of the three fold scalars the BASE layer carries a + * TENANT-AUTHORED value for — i.e. one that no longer equals the packaged + * owner's. + * + * Maintainer ruling, 2026-08-13 (option A, "tenant wins"): an extender's + * scalar applies only while the fold's base still carries the packaged + * owner's value; a diverged base has been authored by the tenant and the + * extender yields. This **amends ADR-0029 D9.2**, whose fold was + * unconditionally last-writer-wins on the scalars, and it is deliberately the + * SAME comparison-based mechanism the #8284 fix applies one layer up — the + * predicate is literally {@link scalarOverridesPackagedBase}, imported from + * `@objectstack/spec`, not a second copy of the shape. One sentence at both + * layers: *an explicit override beats a packaged default.* + * + * ⛔ No provenance flag is stamped on the document and no migration is + * implied: the question is answered from two values, at fold time. + * + * The accepted cost is the point, not a regression — a package can no longer + * relabel an object a tenant has deliberately renamed. There is no escape + * hatch for it, by ruling. + * + * Conservative in the same three ways the predicate is (absent base, non-string + * or empty value, exact equality), plus two of this layer's own: + * + * - **no `own` contributor → no opinion.** An orphan overlay (D9.5) keeps + * today's fold exactly. + * - **base IS the packaged owner → no opinion, by reference.** That is + * {@link resolveObject} with no overlay registered and every + * {@link resolveOwnerLayer} call (D9.6), so the common shape pays one + * identity comparison and cannot change answer. It is an OPTIMISATION, not + * a correctness edge: comparing that base against itself answers "not + * diverged" for every key anyway. ⛔ So it must never be relaxed into + * something weaker than identity — a value-equality test here would start + * answering for bodies that merely LOOK like the owner. + * + * ⛔ The owner is read as the `own` CONTRIBUTOR, deliberately NOT through + * {@link getPackagedObjectOwner} — whose extra `isCodeArtifactBody` test + * (D9.8) would make this decline to protect a RUNTIME-authored object, i.e. + * exactly the object whose owner row the tenant wrote by hand. The two agree + * wherever a packaged owner exists, which is every shape #8460 measured; they + * differ only on a tenant-authored owner, and there the ruled sentence still + * reads the same way — the tenant's own row is the explicit override and a + * package's `objectExtensions` entry is the packaged default. The rejected + * alternative is the trap PR #8454 named one layer up, in its own form: + * comparing against a body that already has extenders folded onto it + * ({@link resolveOwnerLayer}) would report every extender's scalar as + * "unchanged" and yield nothing, ever. + * + * IDEMPOTENCE is preserved, which matters because + * {@link foldObjectExtendersOnto} is documented as idempotent (#8027) and two + * shipped call sites really do hand it an already-folded base. A base that + * already carries an extender's scalar reads as "diverged", so every extender + * yields and the value stays exactly what the first fold produced — the same + * answer, reached by yielding instead of by re-applying. + */ + private tenantAuthoredScalars( + contributors: ObjectContributor[], + baseDefinition: ServiceObject, + ): ReadonlySet | undefined { + const owner = contributors.find((c) => c.ownership === 'own')?.definition; + if (!owner || owner === baseDefinition) return undefined; + let authored: Set | undefined; + for (const key of OBJECT_FOLD_SCALAR_KEYS) { + if (scalarOverridesPackagedBase(owner, key, baseDefinition[key])) { + (authored ??= new Set()).add(key); + } + } + return authored; + } + /** * [#7556] Fold this object's `extend` contributors onto a base body the * CALLER supplies — the same fold {@link resolveObject} (D9.2) and diff --git a/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts b/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts index d9a4123b34..c364a92264 100644 --- a/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts @@ -55,23 +55,43 @@ // So the first `it.fails` above is now a plain green case: the three reads of // one object serve ONE label, and it is the folded one. // -// ⛔ THE SECOND ONE IS NOT, AND THE REASON IS A SECOND DEFECT ONE LAYER DOWN. -// A tenant's rename still does not reach those reads — but no longer because of -// the catalog. `mergeObjectDefinitions` applies an extender's scalars LAST onto -// whatever base it is given, and ADR-0029 D9.2 makes the tenant's overlay that +// ⛔ THE SECOND ONE WAS NOT, AND THE REASON WAS A SECOND DEFECT ONE LAYER DOWN. +// A tenant's rename still did not reach those reads — but no longer because of +// the catalog. `mergeObjectDefinitions` applied an extender's scalars LAST onto +// whatever base it was given, and ADR-0029 D9.2 makes the tenant's overlay that // base (`overlay ?? own`, extenders folded on). So the showcase extension's -// `label: 'Account (Success Overlay)'` overwrites the tenant's 'Customer' -// inside the fold, and the value is simply not in the document any read is +// `label: 'Account (Success Overlay)'` overwrote the tenant's 'Customer' +// inside the fold, and the value was simply not in the document any read was // serving. The card measured this without naming it — its own table records // `layers.effective = "Account (Success Overlay)"` after the rename, i.e. the // extension had already beaten the overlay before i18n ever ran. // // Whether a package extension's label should outrank a tenant's Studio rename // is a fold-precedence decision the 2026-08-13 ruling did not make, and it is -// NOT arm B (nothing here proposes dropping scalars from the fold). It is filed -// as a sub-issue of #8284; the `it.fails` case below stays exactly as it was -// written, so it flips to green the day that ruling lands — and the case after -// it pins what IS true today, so the state is not merely absent from the file. +// NOT arm B (nothing here proposes dropping scalars from the fold). It was filed +// as a sub-issue of #8284 — #8460 — and ruled on separately. +// +// ══════════════════════════════════════════════════════════════════════════ +// [#8460] THE FOLD LAYER, RULED AND FIXED — THE SECOND PIN IS NOW GREEN +// ══════════════════════════════════════════════════════════════════════════ +// +// Maintainer ruling, 2026-08-13 (option A, "tenant wins"): an extender's scalar +// applies only while the fold's BASE still carries the packaged owner's value; +// a diverged base has been authored by the tenant and the extender YIELDS. +// Deliberately the same comparison-based mechanism as #8284 one layer up — the +// same predicate, imported rather than re-spelled — so one sentence governs +// both layers: *an explicit override beats a packaged default.* No provenance +// flags, no migration, and no escape hatch: a package can no longer relabel an +// object a tenant deliberately renamed, which the ruling records as the point +// rather than a regression. Implemented in `SchemaRegistry`, at the single fold +// both read exits already funnel through. +// +// So the `it.fails` below is flipped to a plain `it` — UNMODIFIED otherwise, +// because it was written as the acceptance evidence for exactly this ruling. +// The case after it still passes as written too: it asserts that the three +// reads AGREE and that they do not serve the catalog string, both of which +// remain true — what changed is the value they agree ON, which it deliberately +// never named. Its prose is updated where the fix falsified it. import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -199,7 +219,7 @@ describe('dogfood: the object-extension fold and the i18n catalog disagree on sc expect(labelOf(singleBody?.item)).toBe(EXTENSION_LABEL); }); - it.fails('SHOULD: a tenant\'s own rename reaches the reads its forms derive from', async () => { + it('SHOULD: a tenant\'s own rename reaches the reads its forms derive from', async () => { // The ordinary Studio round-trip: GET the served document, rename it, // PUT it back. The write path persists the request body verbatim // (ADR-0005 §Validation), so this is exactly what an admin's save stores. @@ -220,18 +240,21 @@ describe('dogfood: the object-extension fold and the i18n catalog disagree on sc }); it('[#8284] after the rename the three reads still AGREE — on the extension, not the catalog', async () => { - // What the ruling actually bought in the renamed state, pinned so the - // `it.fails` above is not the file's only word about it. The tenant's - // value is absent from every read because `mergeObjectDefinitions` - // applies the extender's scalar LAST onto the overlay base - // (ADR-0029 D9.2) — the second defect named in this file's header, and - // the one the `it.fails` is now waiting on. What #8284 removed is the - // DISAGREEMENT: no read serves the packaged catalog string any more. + // What the ruling bought in the renamed state, pinned so the case above + // is not the file's only word about it. This case asserts AGREEMENT and + // the absence of the catalog string — never which value they agree on — + // so it held under #8284 (all three served the extension's label, the + // tenant's rename lost inside the fold) and it holds under #8460 (all + // three serve the tenant's 'Customer', because the extender now yields + // to a diverged base). That is the point of stating it this way: the + // convergence #8284 bought is pinned independently of the fold + // precedence #8460 then settled, so a regression in either is visible + // here without this case having to be rewritten when the other moves. // - // Performs its own PUT rather than leaning on the case above: an - // `it.fails` stops at its first failing assertion, so depending on its - // side effects would make this case's meaning depend on where that - // happens to be. + // Performs its own PUT rather than leaning on the case above: that case + // was written as an `it.fails`, which stops at its first failing + // assertion, so depending on its side effects would have made this + // case's meaning depend on where that happened to be. const before: any = await (await stack.apiAs(token, 'GET', '/meta/object/showcase_account')).json(); const put = await stack.apiAs(token, 'PUT', '/meta/object/showcase_account', { ...(before?.item ?? {}), label: 'Customer', diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 0b3faa2507..874d6fd158 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -804,6 +804,7 @@ "resolveViewDescription": "src/system/i18n-resolver.ts#resolveViewDescription (function)", "resolveViewLabel": "src/system/i18n-resolver.ts#resolveViewLabel (function)", "s3StorageExample": "src/system/object-storage.zod.ts#s3StorageExample (const)", + "scalarOverridesPackagedBase": "src/system/i18n-resolver.ts#scalarOverridesPackagedBase (function)", "serviceUnavailableMessage": "src/system/core-services.zod.ts#serviceUnavailableMessage (function)", "toLocaleDescriptors": "src/system/i18n-resolver.ts#toLocaleDescriptors (function)", "translateAction": "src/system/i18n-resolver.ts#translateAction (function)", diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 960b337556..2b3263a2c2 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -1415,8 +1415,23 @@ function builtinSystemFieldLabel( * - equality is exact. The ruled edge — a tenant renaming an object to * exactly the packaged string — is a no-op: the catalog still applies, and * the tenant sees the packaged translation of the word they typed. + * + * ## [#8460] Exported, because the SAME question is asked one layer down + * + * The 2026-08-13 ruling settled catalog-vs-explicit-scalar here. The 2026-08-13 + * ruling on #8460 settled extension-vs-tenant-overlay inside the object FOLD — + * `mergeObjectDefinitions` applies an extender's scalar only while the fold's + * base still carries the packaged owner's value — and required it be "the same + * comparison-based mechanism, one layer down", explicitly not a second + * comparison shape. So it is this predicate, imported by + * `@objectstack/objectql`'s `SchemaRegistry`, rather than a copy free to drift + * from it: one sentence — *an explicit override beats a packaged default* — + * with one implementation, at both layers. + * + * ⛔ The three conservative edges above are therefore load-bearing in two + * places. Widening any of them widens the fold's precedence rule too. */ -function scalarOverridesPackagedBase( +export function scalarOverridesPackagedBase( base: unknown, key: 'label' | 'pluralLabel' | 'description', value: unknown, From a1a1fc820330c50b6f4e316e1944699f3e268d2e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 02:59:15 +0000 Subject: [PATCH 2/3] chore(spec): record the newly exported predicate in the api-surface snapshot (#8460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `TypeScript Type Check` job's final step is `check:api-surface`, which failed on the export #8460 adds: "0 breaking (removed/narrowed), 1 added". Purely additive — `scalarOverridesPackagedBase` is exported so the object fold can reuse the #8284 predicate instead of re-spelling it, per the ruling. Sibling of the export-origins regeneration already in this branch; the two snapshots are separate gates over the same export. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy --- packages/spec/api-surface/system.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 9738536db0..c92b3376b6 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -804,6 +804,7 @@ "resolveViewDescription (function)", "resolveViewLabel (function)", "s3StorageExample (const)", + "scalarOverridesPackagedBase (function)", "serviceUnavailableMessage (function)", "toLocaleDescriptors (function)", "translateAction (function)", From 32e63f69b03a6f5c43ac417d7d975f13c9e5aeb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 03:54:59 +0000 Subject: [PATCH 3/3] ci: retrigger after an unrelated service-job flake (#8460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Test Core (2/3)` reddened at a1a1fc82 on `db-job-adapter.timeout.test.ts` — a wall-clock flake filed as #8628: the case registers a real every-minute cron and asserts exactly one run row, so it fails when the shard straddles a minute boundary (CI: file started 03:02:55, assertion failed 03:03:00.17). Not reachable from this diff — `@objectstack/service-job` does not depend on `@objectstack/objectql`. Verified green locally 5/5 in isolation and 71/71 for the package, plus a full sweep of the fold's dependent class. No re-run permission from this seat, so this empty commit retriggers CI. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy