Skip to content

Commit 187c3fc

Browse files
committed
feat(spec)!: close the job runtime-create gate — a job is a code artifact (#4509)
A `job` created at runtime could never be scheduled. `JobSchema.handler` names a function in the compiled bundle's function table — the schema says so, and the scheduler is built that way: AppPlugin sources jobs from `bundle.jobs` alone and resolves handlers through `collectBundleFunctions(bundle)`. Yet the type was registered allowRuntimeCreate:true (and allowOrgOverride:true), so a job authored in Studio or via PUT /meta parsed, saved, reported success, and never ran. Unlike the sibling disconnects in this batch, this one cannot be bridged: the runtime writer does not have the bundle and cannot name a function inside it. The missing piece is a handler-binding design, not an ingestion path. Per ADR-0049 enforce-or-remove the honest move is to close the door — both flags now false, with a rationale block modeled on the `agent` entry stating what re-opening would require. `job` stays a first-class authorable type: `*.job.ts` and `defineStack({ jobs })` are the supported doors and are fully enforced, so the kind still passes the ADR-0088 admission test and stays registered. Also restamps job.json's evidence lines, which pointed at app-plugin.ts:767-791 and had drifted ~25 lines — the gate resolves paths, not line numbers, which is exactly the rot `verifiedAt` exists to catch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5CYr5SDwe85gH2Jr5KSgu
1 parent 78020b8 commit 187c3fc

7 files changed

Lines changed: 115 additions & 26 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
feat(spec)!: `job` is a code artifact — runtime creation and org overrides are withdrawn (#4509)
6+
7+
A `job` metadata item created at runtime could never be scheduled. `JobSchema.handler`
8+
names a function in the **compiled bundle's function table** — the schema says so
9+
("must match a key in `defineStack({ functions })`") and the scheduler is built that
10+
way: `AppPlugin` sources jobs from `bundle.jobs` alone and resolves each handler
11+
through `collectBundleFunctions(bundle)`, skipping any job whose handler is not in
12+
that table. Yet the type was registered `allowRuntimeCreate: true` (and
13+
`allowOrgOverride: true`), so a job authored in Studio or through `PUT /meta` parsed,
14+
saved, reported success — and never ran.
15+
16+
Unlike the sibling disconnects closed in this batch, this one **cannot be bridged**.
17+
The runtime writer does not have the bundle and cannot name a function inside it; the
18+
missing piece is a handler-binding design, not an ingestion path. Under ADR-0049
19+
enforce-or-remove, the honest move is to close the door:
20+
21+
- `allowRuntimeCreate: false` — no "create job" in Studio or via `PUT /meta`.
22+
- `allowOrgOverride: false` — no per-org job fork, which was unreachable for the same
23+
reason.
24+
25+
**`job` remains a first-class authorable type.** `*.job.ts` / `*.job.yml` /
26+
`*.job.json` files and `defineStack({ jobs })` are the supported doors, and they are
27+
fully enforced — every schedule shape, `retryPolicy`, `timeout` and `enabled` reach
28+
the scheduler. The kind stays in the metadata registry because its file loader is
29+
genuinely consumed (ADR-0088 admission test).
30+
31+
**If you were creating jobs at runtime:** move the definition into your stack
32+
(`defineStack({ jobs, functions })`) so the handler resolves against a real function.
33+
Rows already in `sys_metadata` are left untouched — they were never scheduled, so
34+
nothing changes behaviorally; `migrateStoredMetadata` now reports them `skipped`, the
35+
same way it does for `agent`.
36+
37+
Re-opening the type means constraining `handler` to something a runtime writer can
38+
name — an already-registered flow, or a named and separately governed function — and
39+
building the bridge to `IJobService.schedule`. Flipping the flag without that work
40+
just restores the silent no-op.

packages/objectql/src/overlay-precedence.test.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,11 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => {
167167
// (artifact-free) names succeed. Tested separately below.
168168
//
169169
// 2. Types with `allowRuntimeCreate: false` — after ADR-0088 retired the
170-
// router/function/service placeholder kinds, `agent` (platform-owned,
171-
// ADR-0063) is the remaining member — blocked for ANY write in
172-
// project-kernel mode.
170+
// router/function/service placeholder kinds, the members are `agent`
171+
// (platform-owned, ADR-0063) and `job` (a code artifact: its `handler`
172+
// names a function in the compiled bundle's function table, so a
173+
// runtime-created job could never be scheduled — #4509) — blocked for
174+
// ANY write in project-kernel mode.
173175
//
174176
// NOTE: `datasource` moved to cohort #1 with the ADR-0015 Addendum
175177
// (runtime-UI-creatable datasources). Brand-new runtime datasources
@@ -186,6 +188,11 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => {
186188
reason: 'agents are platform-owned (ADR-0063); per-org agent forks are withdrawn',
187189
item: { name: 'my_agent', label: 'My Agent' },
188190
},
191+
{
192+
type: 'job',
193+
reason: 'jobs are code artifacts (#4509): `handler` resolves only through the compiled bundle function table, so a runtime-created job could never be scheduled',
194+
item: { name: 'nightly_sync', label: 'Nightly Sync', schedule: '0 2 * * *', handler: 'syncAll' },
195+
},
189196
];
190197

191198
for (const { type, reason, item } of deniedTypeWide) {

packages/objectql/src/protocol-meta-types-rich.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,9 @@ describe('ObjectStackProtocolImplementation - getMetaTypes rich response', () =>
116116
const scoped = new ObjectStackProtocolImplementation(mockEngine, undefined, 'env_alpha');
117117
mockEngine.findOne.mockResolvedValue(null);
118118

119-
// Without env var: `agent` writes blocked — the one remaining
120-
// `allowRuntimeCreate: false` kind (platform-owned, ADR-0063). Since
119+
// Without env var: `agent` writes blocked — one of the two
120+
// `allowRuntimeCreate: false` kinds (platform-owned, ADR-0063; the
121+
// other is `job`, a code artifact — #4509). Since
121122
// the test registry has no artifact at this name, the protocol
122123
// returns `not_creatable` (the precise reason); for artifact-backed
123124
// names the code would be `not_overridable`. Both indicate the gate

packages/spec/liveness/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,7 @@ for t, v in r['types'].items():
512512
| book | 13 || 2 || seeded 2026-08-01 (#4488). ADR-0046 §6 spine; `audience` is ENFORCED and fail-closed (tree 401/403 + per-doc effective-audience union on both list and tree). Dead 2 = BOTH inline `translations` maps (book-level and per-group): no resolver reads them and the bundle translator doesn't cover `book` — the trap is that `doc.translations` two files over works on every read path. Also recorded: the `include: { tag }` rule variant can never match (DocSchema declares no `tags`) |
513513
| doc | 7 || 0 || seeded 2026-08-01 (#4488). Fully live: the kernel stores `content` unparsed, but the REST read layer localizes (resolveDocLocale), audience-gates, list-strips `content`, and the book resolver consumes name/label/description/order/group — plus the objectui console portal renders it all. The schema's own "docs are inert data" header describes the kernel, not the type |
514514
| email_template | 21 | 0 | 0 | 0 | this row read 8/–/13/– for one day (seeded 2026-08-01, #4488: "every authorable property is dead", the webhook shape on AUTH mail) and #4509 CLOSED it by ENFORCING — the second worked example, after `webhook`, that a dead verdict is a worklist entry rather than a tombstone. `bootstrapDeclaredEmailTemplates` materializes declared items into the `sys_email_template` rows `sendTemplate` reads, sharing `mapTemplateToRow` with the built-in seeder so the two doors cannot drift, and re-materializes on live metadata writes (`email_template` is `allowRuntimeCreate: true`, so boot-only would have left Studio saves inert). Three breaks had to close, not one: the engine never registered `emailTemplates:` into the registry, built-in seeds masqueraded as `managed_by: admin` and outranked declared templates, and nothing materialized. ADR-0054 proof bound on `subject` (`email-template-materialization`) |
515-
| job | 6 | | 3 | | seeded 2026-08-01 (#4488). The file-authored path is fully enforced: all three schedule shapes honored by the adapters, `retryPolicy`/`timeout` enforced since #3494 (this is the retryPolicy the datasource ledger warns about confusing with its dead namesake), `enabled: false` skips scheduling. Dead 3 = `id` (authorWarn — `name` is the identity everywhere) + label/description (docs-kept). Type-level gap recorded: `allowRuntimeCreate: true` but no path schedules a runtime-authored job item — #4509 |
515+
| job | 13 | 0 | 3 | 0 | seeded 2026-08-01 (#4488). The file-authored path is fully enforced: all three schedule shapes honored by the adapters, `retryPolicy`/`timeout` enforced since #3494 (this is the retryPolicy the datasource ledger warns about confusing with its dead namesake), `enabled: false` skips scheduling. Dead 3 = `id` (authorWarn — `name` is the identity everywhere) + label/description (docs-kept). The type-level gap CLOSED 2026-08-02 (#4509) by closing the door rather than bridging it: `handler` names a function in the compiled bundle's function table, which a runtime writer cannot name, so `allowRuntimeCreate` **and** `allowOrgOverride` are now false and `*.job.ts` / `defineStack({ jobs })` are the supported doors. The kind stays registered — its file loader is genuinely consumed (ADR-0088 admission test) |
516516
| mapping | 7 || 3 || seeded 2026-08-01 (#4488). The import half (#2611) is loudly enforced — unsupported transforms/formats are 400s, `mode`/`upsertKey` default the request, the wizard picker renders `label`. Dead 3 = `extractQuery` (authorWarn — "for export only" promises an export path that does not exist) + `errorPolicy`/`batchSize` (dead but UNWARNABLE: their schema defaults materialize at compile, so presence ≠ authored — `_authorWarnSkipped`, the non-boolean instance of the default(true) rule) |
517517
| seed | 5 || 0 || seeded 2026-08-01 (#4488). Fully live via SeedLoaderService on both doors (boot/per-org replay + runtime-draft publish). `records` is the z.record walk boundary: the keys an author writes are the target object's fields, governed by that object's own definitions — recorded in the entry, not silently skipped |
518518
| translation | 10 || 1 || seeded 2026-08-01 (#4488) — after fixing the walker: the registered schema is a z.preprocess pipe (#3778 retired-dialect guard) whose transform side the unwrap always took, so the type was literally unwalkable. 10 of 11 groups live across spec resolvers, REST localization, objectui client resolvers and plugin-audit (whose composed-key `t()` calls make `messages` easy to mis-verify as dead). Dead 1 = `validationMessages` (authorWarn): nothing resolves it, and #3778's own legacy-key migration table steers `errors:` authors into it — a shipped false signpost, the capabilities.readOnly shape |

packages/spec/liveness/job.json

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,58 @@
11
{
22
"type": "job",
3-
"_note": "JobSchema. The file-authored path is healthy: `defineStack({ jobs })` → app-plugin kernel:ready → IJobService.schedule (packages/runtime/src/app-plugin.ts:766-802) → the service-job adapters honor every schedule shape (packages/services/service-job/src/cron-job-adapter.ts:71-88) and runWithPolicy enforces retryPolicy/timeout (#3494 — these used to be parsed-but-ignored). `retryPolicy` here is the ENFORCED spelling ({maxRetries, backoffMs, backoffMultiplier}); do not confuse it with the datasource `retryPolicy`, which is dead and spells its delay differently. TYPE-LEVEL GAP, recorded not hidden: `job` is registered `allowRuntimeCreate: true` (metadata-plugin.zod.ts:640) but ONLY the compiled bundle's `jobs` reach the scheduler — no code path schedules a runtime-authored `job` metadata item (a Studio-created job saves cleanly and never runs; its `handler` could not even resolve, since the function map lives in the bundle). Same disconnect class as webhook (#3461) — tracked in #4509. Seeded 2026-08-01.",
3+
"_note": "JobSchema. The file-authored path is healthy: `defineStack({ jobs })` → app-plugin kernel:ready → IJobService.schedule (packages/runtime/src/app-plugin.ts:790-855) → the service-job adapters honor every schedule shape (packages/services/service-job/src/cron-job-adapter.ts:71-88) and runWithPolicy enforces retryPolicy/timeout (#3494 — these used to be parsed-but-ignored). `retryPolicy` here is the ENFORCED spelling ({maxRetries, backoffMs, backoffMultiplier}); do not confuse it with the datasource `retryPolicy`, which is dead and spells its delay differently. TYPE-LEVEL GAP CLOSED 2026-08-02 (#4509) by CLOSING THE DOOR, not building a bridge: `job` was registered `allowRuntimeCreate: true` while only the compiled bundle's `jobs` ever reached the scheduler, so a Studio-created job saved cleanly and never ran. Unlike the webhook (#3461) and email_template (#4509 item 1) disconnects, this one could not be bridged: `handler` names a function in the compiled bundle's function table (`collectBundleFunctions`, app-plugin.ts:812), which a runtime writer does not have and cannot name — the missing piece is a handler-binding design, not an ingestion path. So `allowRuntimeCreate` AND `allowOrgOverride` are now both false (metadata-plugin.zod.ts, with the rationale block), leaving `*.job.ts` / `defineStack({ jobs })` as the supported doors. The kind stays registered: its file loader is genuinely consumed, so it still passes the ADR-0088 admission test. Evidence lines restamped 2026-08-02 — the seeded set pointed at app-plugin.ts:767-791, which had drifted ~25 lines (the gate resolves paths, not line numbers, so nothing failed; this is the rot mode `verifiedAt` exists to catch). Seeded 2026-08-01.",
44
"props": {
55
"id": {
66
"status": "dead",
7-
"verifiedAt": "2026-08-01",
7+
"verifiedAt": "2026-08-02",
88
"authorWarn": true,
9-
"authorHint": "Delete it — `name` is the job's identity everywhere: the scheduling key (app-plugin.ts:784), the sys_job row key (db-job-adapter upserts by `name` and mints its own row id), and the JobExecution.jobId stamp. Nothing reads `id`, so two jobs differing only in `id` are the same job.",
9+
"authorHint": "Delete it — `name` is the job's identity everywhere: the scheduling key (app-plugin.ts:833), the sys_job row key (db-job-adapter upserts by `name` and mints its own row id), and the JobExecution.jobId stamp. Nothing reads `id`, so two jobs differing only in `id` are the same job.",
1010
"note": "The describe() text ('defaults to `name` when omitted') implies an identity override that does not exist."
1111
},
1212
"name": {
1313
"status": "live",
14-
"verifiedAt": "2026-08-01",
15-
"evidence": "packages/runtime/src/app-plugin.ts:767, packages/runtime/src/app-plugin.ts:784",
14+
"verifiedAt": "2026-08-02",
15+
"evidence": "packages/runtime/src/app-plugin.ts:815, packages/runtime/src/app-plugin.ts:833",
1616
"note": "scheduling identity; a job without one is skipped loudly."
1717
},
1818
"label": {
1919
"status": "dead",
20-
"verifiedAt": "2026-08-01",
20+
"verifiedAt": "2026-08-02",
2121
"note": "display metadata; no runtime consumer (sys_job stores name/schedule only). Docs-shaped annotation, deliberately KEPT and not authorWarn'd — the hook.label/description precedent, exempt from enforce-or-remove (ADR-0033)."
2222
},
2323
"description": {
2424
"status": "dead",
25-
"verifiedAt": "2026-08-01",
25+
"verifiedAt": "2026-08-02",
2626
"note": "same as `label`: docs-shaped, deliberately kept, no warning."
2727
},
2828
"schedule": {
2929
"status": "live",
30-
"verifiedAt": "2026-08-01",
31-
"evidence": "packages/runtime/src/app-plugin.ts:786, packages/services/service-job/src/cron-job-adapter.ts:71-88, packages/services/service-job/src/db-job-adapter.ts:83",
30+
"verifiedAt": "2026-08-02",
31+
"evidence": "packages/runtime/src/app-plugin.ts:834, packages/services/service-job/src/cron-job-adapter.ts:71-88, packages/services/service-job/src/db-job-adapter.ts:83",
3232
"note": "all three variants enforced: cron `expression` + per-job `timezone` (cron-job-adapter.ts:76-77), interval `intervalMs` (:82), once `at` (:87); the db adapter persists the shape onto sys_job (db-job-adapter.ts:233-245). WALK BOUNDARY: a discriminated union — the gate classifies it as one property; the per-variant keys are covered by the adapter evidence above, not by ledger rows."
3333
},
3434
"handler": {
3535
"status": "live",
36-
"verifiedAt": "2026-08-01",
37-
"evidence": "packages/runtime/src/app-plugin.ts:776",
38-
"note": "resolved against the bundle's function map; a missing handler skips the job with a warning rather than scheduling a no-op."
36+
"verifiedAt": "2026-08-02",
37+
"evidence": "packages/runtime/src/app-plugin.ts:824-830",
38+
"note": "resolved against the bundle's function map (`collectBundleFunctions`, app-plugin.ts:812); a missing handler skips the job with a warning rather than scheduling a no-op. This resolution is ALSO why the type is closed to runtime creation (#4509): the function table is a bundle artifact, so a handler string authored at runtime has nothing to resolve against."
3939
},
4040
"retryPolicy": {
4141
"status": "live",
42-
"verifiedAt": "2026-08-01",
43-
"evidence": "packages/runtime/src/app-plugin.ts:791, packages/services/service-job/src/run-with-policy.ts:58-65",
42+
"verifiedAt": "2026-08-02",
43+
"evidence": "packages/runtime/src/app-plugin.ts:838-841, packages/services/service-job/src/run-with-policy.ts:58-65",
4444
"note": "maxRetries/backoffMs/backoffMultiplier all drive the exponential-backoff retry loop (delay = backoffMs * multiplier^(retry-1)). Enforced since #3494. This is the `retryPolicy` the datasource ledger warns about confusing with its dead namesake."
4545
},
4646
"timeout": {
4747
"status": "live",
48-
"verifiedAt": "2026-08-01",
48+
"verifiedAt": "2026-08-02",
4949
"evidence": "packages/services/service-job/src/run-with-policy.ts:25-33",
5050
"note": "per-attempt limit; an over-limit run records execution status 'timeout' (JobTimeoutError). The in-flight handler is abandoned, not cancelled — as documented."
5151
},
5252
"enabled": {
5353
"status": "live",
54-
"verifiedAt": "2026-08-01",
55-
"evidence": "packages/runtime/src/app-plugin.ts:772",
54+
"verifiedAt": "2026-08-02",
55+
"evidence": "packages/runtime/src/app-plugin.ts:820",
5656
"note": "`enabled: false` skips scheduling entirely at registration — genuinely enforced, unlike the retired flow.active/tool.active."
5757
}
5858
}

0 commit comments

Comments
 (0)