Skip to content

Commit 84cb121

Browse files
hotlongclaude
andauthored
fix(platform-objects): state sys_job's uniqueness boundary as 'global' and correct its published claim (#8578) (#8776)
* fix(platform-objects): state sys_job's uniqueness boundary as 'global' (#8578) * i18n(platform-objects): regenerate sys_job bundles for the corrected boundary wording (#8578) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3d0ded8 commit 84cb121

7 files changed

Lines changed: 223 additions & 6 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
---
4+
5+
State `sys_job`'s uniqueness boundary explicitly: `unique: 'global'` on the declared `(name)` index, and correct the `name` field's description (#8578)
6+
7+
The declared index carried the bare `unique: true` spelling, which ADR-0120 D1 defines as the deprecated positional spelling of `'global'` — the listed columns verbatim. Because `sys_job` also carries a kernel-injected `organization_id`, the tenancy sweep could not tell that shape apart from the #8323 cross-tenant-oracle class, and the field's description published a boundary-free "Unique job identifier" claim that left the question open in the generated reference.
8+
9+
The reading settles it in the `'global'` direction: nothing writes `sys_job` per organization. `DbJobAdapter` is the sole writer and upserts under a SYSTEM context, locating rows by `where: { name }` with no organization dimension; the `job` metadata type is closed to tenants on all three flags (`allowOrgOverride: false` — "no per-org job fork" — plus `allowRuntimeCreate: false` and `supportsOverlay: false`); `enable.apiMethods` advertises no write verb at all (ADR-0103 engine-owned); and every `schedule()` call site is registration-time and installation-scoped. ADR-0120's own S5 inventory already names `sys_job.name` as one of the nine engine idempotency keys that are platform-wide by construction.
10+
11+
No migration and no drift: `'global'` **is** the semantics bare `true` already materialized, so the physical index is byte-identical (ADR-0120 D2). What changes is that the boundary is stated rather than inferred from position, and that the published description names it. The reading itself is pinned — the new test asserts the write paths that would have to open for the opposite verdict to become true, so a future per-organization job path fails loudly instead of silently invalidating the constraint.

packages/platform-objects/src/apps/translations/en.objects.generated.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2323,7 +2323,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
23232323
},
23242324
name: {
23252325
label: "Job Name",
2326-
help: "Unique job identifier (snake_case)"
2326+
help: "Unique job identifier (snake_case), unique across the whole installation — the job catalogue is a property of the deployment, not of an organization"
23272327
},
23282328
schedule_type: {
23292329
label: "Schedule Type",

packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2323,7 +2323,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
23232323
},
23242324
name: {
23252325
label: "Nombre de la tarea",
2326-
help: "Identificador único de tarea (snake_case)."
2326+
help: "Identificador único de tarea (snake_case), único en toda la instalación: el catálogo de tareas es una propiedad del despliegue, no de una organización."
23272327
},
23282328
schedule_type: {
23292329
label: "Tipo de programación",

packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2323,7 +2323,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
23232323
},
23242324
name: {
23252325
label: "ジョブ名",
2326-
help: "一意のジョブ識別子(snake_case)"
2326+
help: "一意のジョブ識別子(snake_case)。インストール全体で一意です — ジョブカタログは組織ではなくデプロイメントに属する情報です"
23272327
},
23282328
schedule_type: {
23292329
label: "スケジュールタイプ",

packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2323,7 +2323,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
23232323
},
23242324
name: {
23252325
label: "任务名称",
2326-
help: "唯一任务标识(snake_case)"
2326+
help: "唯一任务标识(snake_case),在整个安装范围内唯一——后台任务目录属于该部署本身,而非某个组织"
23272327
},
23282328
schedule_type: {
23292329
label: "调度类型",
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { IndexSchema, resolveInjectedSystemColumns } from '@objectstack/spec/data';
5+
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
6+
import { SysJob } from './sys-job.object.js';
7+
8+
/**
9+
* #8578 — `sys_job`'s declared uniqueness is installation-wide, and the reading
10+
* that makes it so is pinned here.
11+
*
12+
* ## The fork this card was filed on, and how it was decided
13+
*
14+
* The card deliberately asserted no defect. A DECLARED index's bare
15+
* `unique: true` is the positional spelling of `'global'` (the listed columns
16+
* verbatim), so `(name)` materialized as an installation-wide unique index on
17+
* an object that carries a kernel-injected `organization_id` — the shape of the
18+
* #8323 cross-tenant-oracle class. But `sys_job` is `managedBy: 'engine-owned'`
19+
* and calls itself a "Catalogue of registered background jobs", so the card
20+
* left the direction open and named the reading that settles it:
21+
*
22+
* > does anything write `sys_job` rows **per organization**?
23+
*
24+
* Nothing does. Five independent lines of evidence, all measured on `main`:
25+
*
26+
* 1. **The sole writer has no organization dimension.** `DbJobAdapter`
27+
* (`services/service-job`) is the only thing that writes this table —
28+
* `upsertJobRow` on `schedule()`, `setActive` on `cancel()`, `bumpJob`
29+
* after every run. All three write under
30+
* `SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }` and all
31+
* three locate their row with `find('sys_job', { where: { name }, limit: 1 })`
32+
* — keyed on `name` ALONE. The adapter therefore already assumes the global
33+
* key; a per-organization constraint would make that lookup ambiguous
34+
* (an arbitrary org's row would win) rather than fix anything.
35+
* 2. **The `job` metadata type is closed to tenants on all three flags** —
36+
* `allowOrgOverride: false`, `allowRuntimeCreate: false`,
37+
* `supportsOverlay: false`. The registry comment says the deciding half in
38+
* words: *"no per-org job fork"*.
39+
* 3. **No generic write path exists at all.** `enable.apiMethods` is
40+
* `['get', 'list']` (ADR-0103 engine-owned): reads stay open for the Setup
41+
* grid, every write verb is absent, for every caller — tenant or not.
42+
* 4. **Every `schedule()` call site is registration-time and
43+
* installation-scoped**: `AppPlugin` on `kernel:ready` from the app
44+
* bundle's declared jobs, `JobServicePlugin` replaying those registrations,
45+
* the schedule / time-relative flow triggers (keyed `<prefix>:${flowName}`,
46+
* and `flow` is itself `allowOrgOverride: false`), the approvals and
47+
* reports plugins' fixed names, and wait-node timers keyed
48+
* `flow-wait:${runId}:${nodeId}` on a server-minted run id.
49+
* 5. **ADR-0120 names this exact key.** Its S5 row — "engine idempotency keys
50+
* written by sudo (org NULL)" — lists `sys_job.name` first among the nine,
51+
* with the After spelling `'global'` and "zero drift".
52+
* `types/src/unique-scope-install-gate.ts` names it twice more as
53+
* platform-wide by construction.
54+
*
55+
* So the card's first branch wins: the object is tenant-scoped only
56+
* incidentally (`organization_id` is kernel-injected, never authored), the
57+
* installation-wide constraint is correct, and the remedy is to state it —
58+
* plus correct the field `description`, which published the bare claim.
59+
*
60+
* ## What this file pins, and why that is the point
61+
*
62+
* The card's verification bar asks for a test that fails **if the opposite
63+
* becomes true**. The spelling assertions alone cannot do that: they would stay
64+
* green on the day someone opens a per-organization write path, and the
65+
* constraint would silently become wrong. So the four assertions under
66+
* "the reading" below pin the *premises*, not the conclusion — each one is a
67+
* door that is currently shut, and each goes red the moment it opens. A future
68+
* author who legitimately opens one is then told, by a failing test naming this
69+
* card, that the uniqueness scope has to be re-decided with it.
70+
*/
71+
describe('sys_job — declared uniqueness is installation-wide (#8578)', () => {
72+
const uniqueIndexes = (SysJob.indexes ?? []).filter((i: any) => i.unique);
73+
74+
describe('the spelling', () => {
75+
it('declares exactly one unique index, on (name)', () => {
76+
expect(uniqueIndexes).toHaveLength(1);
77+
expect((uniqueIndexes[0] as any).fields).toEqual(['name']);
78+
});
79+
80+
it("spells the scope 'global' — NOT bare `true`", () => {
81+
// ⛔ Asserted by EQUALITY, never by truthiness. Bare `true` is the exact
82+
// value this card removed, and it is truthy — a `toBeTruthy()` here would
83+
// pass on the defect itself.
84+
expect((uniqueIndexes[0] as any).unique).toBe('global');
85+
expect((uniqueIndexes[0] as any).unique).not.toBe(true);
86+
});
87+
88+
it('is not left as a positional default in EITHER direction (ADR-0120 D1)', () => {
89+
// The card's closing condition: whichever branch the reading landed on,
90+
// the end state must STATE its scope. Written so it would also have held
91+
// had the reading gone the other way.
92+
expect(['global', 'organization']).toContain((uniqueIndexes[0] as any).unique);
93+
});
94+
95+
it('is a valid IndexSchema — the spec accepts the explicit vocabulary', () => {
96+
expect(IndexSchema.parse(uniqueIndexes[0])).toMatchObject({
97+
fields: ['name'],
98+
unique: 'global',
99+
});
100+
});
101+
102+
it('keeps the physical shape byte-identical (ADR-0120 D2 — zero drift)', () => {
103+
// `'global'` IS today's verbatim semantics, so this respelling is
104+
// semantic bookkeeping and NOT a migration: the materialized index is
105+
// still `(name)` over exactly the listed columns, with no tenant key part
106+
// prepended. Asserted on the BUILT value — `ObjectSchema.create`
107+
// normalizes an authored `{ fields }` into `{ fields, unique: false }`.
108+
expect(SysJob.indexes).toEqual([
109+
{ fields: ['name'], unique: 'global' },
110+
{ fields: ['active'], unique: false },
111+
]);
112+
});
113+
});
114+
115+
describe('the reading — these are the doors that must stay shut', () => {
116+
// Each assertion below is a premise of the `'global'` verdict. If one goes
117+
// red, a per-organization population became possible and the uniqueness
118+
// scope must be re-decided (the #8323 arm) — not merely re-spelled.
119+
120+
it('is engine-owned, and advertises NO generic write verb (ADR-0103)', () => {
121+
expect((SysJob as any).managedBy).toBe('engine-owned');
122+
const apiMethods: string[] = (SysJob as any).enable?.apiMethods ?? [];
123+
expect(apiMethods).toEqual(['get', 'list']);
124+
// Spelled out as an exclusion too, so widening the array fails here
125+
// rather than only in the equality above.
126+
for (const verb of ['create', 'update', 'delete', 'upsert']) {
127+
expect(apiMethods).not.toContain(verb);
128+
}
129+
});
130+
131+
it('the `job` metadata type is closed to tenants on all three flags', () => {
132+
// The deciding fact, quoted from the registry: "no per-org job fork".
133+
// Opening `allowOrgOverride` or `allowRuntimeCreate` is exactly the
134+
// "a tenant can register or shadow a job" branch the card named.
135+
const job = DEFAULT_METADATA_TYPE_REGISTRY.find((t: any) => t.type === 'job');
136+
expect(job).toBeDefined();
137+
expect((job as any).allowOrgOverride).toBe(false);
138+
expect((job as any).allowRuntimeCreate).toBe(false);
139+
expect((job as any).supportsOverlay).toBe(false);
140+
});
141+
142+
it('the `flow` type is closed too — flow-derived job names cannot fork per org', () => {
143+
// The schedule / time-relative triggers key their job names on the flow
144+
// name (`<prefix>:${flowName}`). Those names are installation-wide
145+
// identities only for as long as a flow cannot be org-forked; if it can,
146+
// two organizations produce the same job name and this constraint turns
147+
// into the cross-tenant oracle after all.
148+
const flow = DEFAULT_METADATA_TYPE_REGISTRY.find((t: any) => t.type === 'flow');
149+
expect(flow).toBeDefined();
150+
expect((flow as any).allowOrgOverride).toBe(false);
151+
});
152+
153+
it('carries an injected organization_id — so the scope is a real choice, not a default', () => {
154+
// `sys_job` IS tenant-scoped structurally (this is why the sweep flagged
155+
// it at all). The column exists; the verdict is that no writer ever
156+
// populates it per organization. Pinning this keeps the `'global'`
157+
// spelling an argued decision rather than an artifact of the column
158+
// being absent — and if the injection is ever switched off, the reading
159+
// above needs re-checking from a different direction (ADR-0120 S11).
160+
const plan = resolveInjectedSystemColumns(SysJob);
161+
expect((SysJob as any).tenancy).toBeUndefined();
162+
expect(plan.tenant).toBe(true);
163+
expect(plan.names.has('organization_id')).toBe(true);
164+
});
165+
});
166+
167+
describe('the record correction', () => {
168+
it('the `name` field no longer publishes a bare "unique" claim', () => {
169+
// The half that was never in question (#8468 ruling): the description
170+
// used to say only "Unique job identifier (snake_case)", which asserts a
171+
// boundary-free uniqueness to every admin and AI author reading the
172+
// generated reference. It must now name the boundary it actually has.
173+
const help = String((SysJob.fields as any).name.description ?? '');
174+
expect(help).toMatch(/across the whole installation/);
175+
expect(help).not.toBe('Unique job identifier (snake_case)');
176+
});
177+
});
178+
});

packages/platform-objects/src/audit/sys-job.object.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,14 @@ export const SysJob = ObjectSchema.create({
3636
required: true,
3737
maxLength: 255,
3838
searchable: true,
39-
description: 'Unique job identifier (snake_case)',
39+
// [#8578] "unique across the whole installation", not bare "unique". The
40+
// bare wording published the boundary as an open question while the
41+
// declared index below already materialized the installation-wide one —
42+
// and here that materialization is CORRECT (see the index comment), so
43+
// the text is corrected to match the constraint rather than the reverse.
44+
description:
45+
'Unique job identifier (snake_case), unique across the whole installation — ' +
46+
'the job catalogue is a property of the deployment, not of an organization',
4047
group: 'Identity',
4148
}),
4249

@@ -95,7 +102,28 @@ export const SysJob = ObjectSchema.create({
95102
},
96103

97104
indexes: [
98-
{ fields: ['name'], unique: true },
105+
// [#8578, ADR-0120 D1/S5] `'global'` — one holder across the whole
106+
// installation. This is the EXPLICIT spelling of what bare `true` already
107+
// materialized, so the physical index is byte-identical (ADR-0120 D2, zero
108+
// drift); what changes is that the boundary is now stated instead of being
109+
// a positional accident (#4986/#5082).
110+
//
111+
// Why `'global'` and not `'organization'` (the #8323 class this object was
112+
// screened against): nothing writes `sys_job` per organization. The sole
113+
// writer is `DbJobAdapter`, which upserts under a SYSTEM context and looks
114+
// its rows up by `where: { name }` with no organization dimension — a
115+
// per-organization key would make that lookup ambiguous rather than fix
116+
// anything. The `job` metadata type is closed to tenants on all three
117+
// flags (`allowOrgOverride: false` — "no per-org job fork" — plus
118+
// `allowRuntimeCreate: false` and `supportsOverlay: false`), and `enable`
119+
// below advertises no generic write verb at all. ADR-0120's S5 inventory
120+
// names `sys_job.name` outright as one of the engine idempotency keys that
121+
// are platform-wide by construction.
122+
//
123+
// The reading is PINNED in `sys-job.global-unique.test.ts`: if a
124+
// per-organization write path is ever opened, that test goes red rather
125+
// than this constraint silently becoming wrong.
126+
{ fields: ['name'], unique: 'global' },
99127
{ fields: ['active'] },
100128
],
101129

0 commit comments

Comments
 (0)