Skip to content

Commit f1e4ae5

Browse files
spec(system): JobSchema gains a sandboxed L2 body and deprecates handler; a body job has one time limit (#21538)
Part of #21515 Clause-②: yes (widening) This lands the **spec half** of #21515, the E ruling's spec scope (ruling record `5964305303` on #21489, maintainer 「jobs同意」): a job carries a sandboxed `body`, `handler` is deprecated beside it, and a body job's time limit has one spelling. Scope item 2, `objectstack build` lowering job handlers into `body`, is **not** in this PR. Measured below, it cannot work on the standard authoring path, and each route that would make it work changes a public contract, so it goes back to the seat as a decision ("Not in this PR"). #21515 stays open for that half, which is why the first line is `Part of`. ## What changes - **`JobSchema.body`** (`packages/spec/src/system/job.zod.ts`) is `ScriptBodySchema` by reference: the L2 member of the hook body union, with no copy and no new body shape. It is strict as on hooks. Its describe says the body runs in the QuickJS sandbox with no module scope, reaches data only through `ctx.api` under its declared `capabilities`, and logs through `ctx.log`. It also says the in-process `JobHandlerContext` members (`ql`, `logger`, `bundle`) do not exist there, and that the runtime binder has not landed yet. - **`handler` is optional, with the hooks' wording: "DEPRECATED, prefer `body`".** When both are present `body` wins. A job with neither key is refused at `body`, with a message naming both keys. The rule is `requiredOneOf(['body', 'handler'])` from the closed projection list, so the published JSON Schema states it as `anyOf` of `required`, and `dropped-refinements.baseline.json` does not grow. The `fn` / `function` → `handler` aliases stay as they are: both still point at a key that exists and accepts a function name. - **L1 is refused, and the message says why.** The error is set on the `language` literal of `ScriptBodySchema` (`data/hook-body.zod.ts`): an expression performs no I/O, so its only effect would be a returned value, and a job runs for its effects. It is reachable only where `ScriptBodySchema` stands alone. `HookBodySchema` routes `expression` by its discriminator, so hook and action bodies are byte-for-byte unchanged. Every other wrong language keeps zod's own message. - **One time limit.** `body.timeoutMs` is refused on a job (`bannedKeys(['timeoutMs'])` on the slot, also projected), with the prescription to move the value to the job's `timeoutMs`. The `JobSchema.timeoutMs` describe is **the one statement** of the relation. The `body` describe and the refusal point to it. - **Liveness:** `job.body` is drilled into its five children, all `planned` and carried by #21489. `authorWarn` sits on `source`, so `os validate` / `os lint` / `os build` warn whenever a job sets a body. Measured with `lintLivenessProperties` on a two-job stack: one `liveness-planned-property` finding, on the job with a body. - **Docs:** `content/docs/automation/jobs.mdx` gains "The job body" and "Time limit and long-running work". The new snippet is `os:check` type-checked. The page's stale `timeout` spellings become `timeoutMs`. The generated reference, the authorable-surface shard and the liveness counts are regenerated. - **Changeset:** `@objectstack/spec` `minor`. No CLI file changes, so there is no CLI bump. ## Measurements behind the design **A1, body levels.** The PM's premise is half right. A job does have a return consumer: a handler may resolve `{ outcome: 'degraded', reason }` (`JobRunOutcome`, `contracts/job-service.ts`), and all three adapters map it. It also has an input: `trigger(name, data)` hands `data` to the handler. But that one reader judges *work*, and an L1 expression can do none (capabilities `[]`, no I/O). So an L1 job could only report an outcome about nothing. Result: L2 only, by reference, and the L1 refusal says why. **A3, the time limit, as the code enforces it.** - `QuickJSScriptRunner.resolveTimeout` (`runtime/src/sandbox/quickjs-runner.ts`): the CPU budget is the smaller of the runner's `opts.timeoutMs` and the body's `timeoutMs` when either is set. Otherwise it is the origin default: hook 250 ms, action 5000 ms, or the `OS_SANDBOX_*` env. The wall ceiling is `max(OS_SANDBOX_WALL_CEILING_MS, 30000 by default, budget)`, and over-budget runs are interrupted (killed). - `runWithPolicy` (`service-job/src/run-with-policy.ts`): `job.timeoutMs` is a per-attempt wall-clock race. It abandons rather than kills, and it is uncapped. - So the 30 s cap sits only on `body.timeoutMs`. With that key refused on a job, the binder hands `job.timeoutMs` to the runner as `opts.timeoutMs`, and budget and wall ceiling both rise to it. Long-running work stays expressible, so the cap needs no product decision. The binder has to pass the value through, and that requirement is recorded for #21489 below. - Omitted `timeoutMs` on a body job means the sandbox's default limits, and the describe says so. **A4, the build lowering: why it is not here.** It was implemented and measured, and the code stays in this branch's history at `4893a0b48b` / `781609d2f7`, reverted in `b7cf7f78b8`. Three readings: 1. There is no inline job handler to lower: `JobSchema.handler` is `z.string()`, a `functions` key. The only shape the build sees is the named function. 2. On the standard path the build cannot read that function. `defineStack` (strict by default, the form every example and template uses) parses `functions` through `z.function()`. That replaces each callable, bare or declared (`{ handler, effect }`), with zod's `implement` wrapper, which keeps no back-reference. The pipeline pin `defineStack → normalizeStackInput → lowerCallables → ObjectStackDefinitionSchema` went **red**: the parse succeeded and the job came out with no `body`. `extractHookBody` had refused with free identifiers `func, inst, parse`, which are zod's names, not the author's. Shipped as is, every job on every standard build would print that bogus warning. 3. Where the source *is* visible, the documented handler form (`async function sweep({ jobId, ql, logger }: JobHandlerContext)`) extracts cleanly into a body that reads unbound `ql` / `logger` / `jobId`. It passes the free-identifier gate because they were parameters, and it would throw on its first sandbox run. Because `body` wins, the build would replace a working handler with a broken body. A guard (`assertJobBodyContext`, in the history) refused those, which left nothing existing to lower. So "authored packages become portable without hand-editing" does not hold on any route. The options are in the report on #21515. **A5, consumers of `JobSchema` / `Job` / `JobParsed`.** No typed consumer reads `job.handler` as required: `AppPlugin` reads jobs as `any`, objectui's `JobPreview` reads the draft untyped, and `metadata-type-schemas.ts` / `stack.zod.ts` only reference the schema. There was no type fallout. Left for #21489: - `runtime/src/app-plugin.ts#start` resolves `fnMap[job.handler]` only. A body-only job is skipped with `job handler not found in bundle.functions — skipping` at `warn`, and with both keys present `body` is ignored rather than winning. - The sandbox has no `job` origin (`ScriptOrigin.kind` is `hook | action`), so `resolveTimeout` has no job default. - `job.timeoutMs` must reach the runner as `opts.timeoutMs`. - What a job body's `ctx` carries beyond `api` / `log` / `crypto` (`jobId`, `data`) is undeclared. **A6.** No `skills/**` file names the job `handler` form. One pre-existing stale line is noted below. **A8.** The open PRs were re-read at report time: 9 open, and none touches `system/job.zod.ts`, `data/hook-body.zod.ts`, `lower-callables.ts`, `extract-hook-body.ts`, the job docs or the job ledger. ## Tests and gates (at `b0460f4ddc`, the merge of `origin/main` `ad7c351898`) - `@objectstack/spec` build: exit 0, after the merge. `check:generated`: all 15 artifacts up to date. `pnpm --filter @objectstack/spec test`: 603 files, 17848 passed, 1 todo. `typecheck`: exit 0. - New pins in `packages/spec/src/system/job.test.ts` (`JobSchema.body`, 10 cases): body only, handler only, both, neither (located at `body`, naming both keys), L1 refused at `body.language` with a reason, any other language keeps zod's message, `body.timeoutMs` refused, job `timeoutMs` 600000 accepted, a misspelt body key refused, and `Job` typing `handler` as optional. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran`: **111 derived, 111 run, 0 NOT-MEASURED, 0 UNRUN**, every exit code 0. Six gates first exited 3 (formula, lint, client, client-react and objectql unbuilt) and are green after building that closure. - **Ablations** via `scripts/ablation-replace.mjs`. Each proved the anchor 1 → 0 on disk and restored to `git diff HEAD` empty, with blob == HEAD: 1. `handler` required again (`job.zod.ts` blob `105cfb6379` → `49869aece8`): 4 red. The red pins are the body-only pin, neither (the refusal moves to `handler`), job `timeoutMs` with no handler, and the `Job`-typed call. 2. `bannedKeys(['timeoutMs'])` replaced by an always-true refine: 1 red, the `body.timeoutMs` pin. 3. The L1 message disarmed (`hook-body.zod.ts` blob `c5123b08bb` → `279b5324d0`): 1 red, the L1 pin. The hook-body suite stayed green. The first attempt used a replacement already present in the file, was refused by the tool before running anything, and was redone. - Restore leg: `job.test.ts` + `hook-body.test.ts`, 84 passed. - The second ablation the dispatch named (removing the job branch of the lowering) has no target here, because the lowering is not in this PR. ## Not in this PR: the build lowering (decision requested on #21515) The options are written up with the four axes in the seat report on #21515: - (A) Stop `functions` entries being wrapped (`FlowFunctionEntrySchema`'s `z.function()` replaced by an identity-preserving check), then mint a job body from the named function behind the job-context guard. - (B) Give jobs an inline `handler` function form, as hooks have, that the build lowers. That needs the runtime module to bundle it and `AppPlugin` to bind an inline function on a config boot. - (C) Withdraw the lowering, and author job bodies as data. ## Acceptance notes - `objectstack build` does not lower jobs, and the docs say so: authors write `body` as data. - Inference, no producer found in `examples/**` (carrier: none): `extractHookBody` peels the parameter list, so a hook handler written as `async ({ input }) => …` or `async (context) => …` lowers to a body that reads an unbound name. The sandbox runs every body as `(async (ctx) => { … })`. Only the extractor was probed, not a spawned `os build`. - The generated reference renders `Job.body`'s nested table from the reused shape, `timeoutMs` row included, although that key is refused on a job. The describes and the published JSON Schema (`propertyNames`) say it is refused. - Error text for an unknown key on a job body names "this sandboxed JS (L2) hook body", the shared shape's surface label. - The `metadata-plugin.zod.ts` `job` registry row still justifies `allowRuntimeCreate: false` by `handler` alone. A `body` makes a runtime-authored job runnable in principle once #21489 binds it. That is noted for that seat, not changed here. - objectui's `JobPreview` shows `handler` and nothing of `body`. This is a cross-repo display gap, not filed. - `skills/objectstack-platform/SKILL.md:414` still names `onEnable` as where a job gets its data handle. That was already stale (jobs take `ql` off their context), and this PR does not touch `skills/**`. --- _Generated by [Claude Code](https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 41b1333 commit f1e4ae5

9 files changed

Lines changed: 365 additions & 16 deletions

File tree

‎.changeset/21515-job-body.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
A job can carry a sandboxed `body`, the same JavaScript body hooks and script actions carry, so its work travels with the metadata; `handler` is deprecated beside it (#21515).
6+
7+
Clause-②: yes (widening)
8+
9+
- **`JobSchema.body`** is `ScriptBodySchema` by reference: `{ language: 'js', source, capabilities, memoryMb }`, strict as on hooks. It runs in the QuickJS sandbox with no module scope, reaches data only through `ctx.api` under its declared `capabilities`, and logs through `ctx.log`. The in-process `JobHandlerContext` members (`ql`, `logger`, `bundle`) do not exist there.
10+
- **`handler` is optional and DEPRECATED, "prefer `body`".** When both are present `body` wins, as for hooks. A job that declares neither is refused at parse, located at `body`, with a message naming both keys. The rule is published in the JSON Schema too (`anyOf` of one `required` per key), not only enforced by the parse.
11+
- **Only the L2 body.** An expression (L1) body is refused on a job at `body.language`, and the message says why: an expression performs no I/O, so its only effect would be a returned value, and a job runs for its effects. The message lives on `ScriptBodySchema.language` and fires only where that shape is used on its own; hook and action bodies are unchanged.
12+
- **One time limit.** A body job's limit is the job's own `timeoutMs`: one attempt is one sandbox run, bounded by that value. `body.timeoutMs` (capped at 30 s on hooks and actions) is refused on a job, with the prescription to move the value to `timeoutMs`. The job-level key has no cap, so long-running work states its limit there or splits into bounded runs. The `timeoutMs` describe is the one place this is stated.
13+
- **Not yet run by the runtime.** Scheduling a job's `body` is a separate change. Until it lands a job runs through `handler`, and a body-only job is skipped at boot with a warning. The liveness ledger grades `job.body` `planned`, so `os validate`, `os lint` and `os build` warn wherever a job sets a `body`. `objectstack build` does not mint a job body from the function a `handler` names; write it as data.
14+
15+
Nothing that parsed before is refused now: every existing job declares `handler`, and none declares `body`.

‎content/docs/automation/jobs.mdx‎

Lines changed: 91 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ navTitle: Scheduled Jobs
44
description: Run a TypeScript function on a cron, interval, or one-off schedule — and decide when a job is the right tool instead of a schedule-triggered flow.
55
---
66

7-
A **job** runs one named function in your bundle on a schedule. You declare the
8-
schedule as metadata; the platform's job service owns the timing, the retries,
9-
the per-attempt time limit, and the run history.
7+
A **job** runs work on a schedule. You declare the schedule as metadata; the
8+
platform's job service owns the timing, the retries, the per-attempt time limit,
9+
and the run history. The work is either a sandboxed [`body`](#the-job-body)
10+
that travels with the metadata — the preferred form — or, deprecated, the name of
11+
a function in your bundle (`handler`).
1012

1113
{/* os:check */}
1214
```typescript
@@ -37,10 +39,10 @@ writes carry*:
3739

3840
| | `job` | `schedule`-type flow |
3941
|:---|:---|:---|
40-
| What runs | one TypeScript function from `defineStack({ functions })` | a node graph — record operations, `notify`, `http`, approvals, subflows |
42+
| What runs | a sandboxed `body`, or (deprecated) one TypeScript function from `defineStack({ functions })` | a node graph — record operations, `notify`, `http`, approvals, subflows |
4143
| Changeable after deploy | **No.** `job` is `allowRuntimeCreate: false` and `allowOrgOverride: false` — there is no "create job" in Studio and no per-tenant fork | Yes — a new flow can be authored through Studio / `PUT /meta` (`allowRuntimeCreate: true`) |
4244
| Identity of its data writes | whatever the handler does with the engine it is given | declared by [`runAs`](/docs/automation/flows) — and a `user` run that resolves no trigger user has its data operations **refused**, so a scheduled flow normally declares `runAs: 'system'` |
43-
| Retry / time limit | `retryPolicy` + `timeout` on the job, honoured by the job adapter | the flow's own error handling |
45+
| Retry / time limit | `retryPolicy` + `timeoutMs` on the job, honoured by the job adapter | the flow's own error handling |
4446
| Run history | `sys_job` + `sys_job_run` | `sys_automation_run` |
4547

4648
**Rule of thumb:** if the work is a function you ship and version with your code,
@@ -133,6 +135,85 @@ interval adapter, a cron schedule is **registered but never executed** — the
133135
adapter says so at `warn` level on registration, because that is the difference
134136
between "no cron engine here" and a job that silently never runs.
135137

138+
## The job body
139+
140+
A job's `body` is the same sandboxed JavaScript body hooks and script actions
141+
carry — `{ language: 'js', source, capabilities }` — so the work travels with the
142+
metadata instead of living in a runtime module only some boots import. When a job
143+
declares both, `body` wins; `handler` is deprecated beside it. A job must declare
144+
at least one of the two.
145+
146+
{/* os:check */}
147+
```typescript
148+
import { defineJob } from '@objectstack/spec';
149+
150+
export const CloseStaleTasksJob = defineJob({
151+
name: 'close_stale_tasks',
152+
schedule: { type: 'cron', expression: '0 2 * * *', timezone: 'UTC' },
153+
body: {
154+
language: 'js',
155+
source: `
156+
const stale = await ctx.api.object('task').find({ where: { status: 'open' }, limit: 200 });
157+
for (const t of stale) await ctx.api.object('task').update({ id: t.id, status: 'closed' });
158+
ctx.log.info('closed stale tasks', { count: stale.length });
159+
`,
160+
capabilities: ['api.read', 'api.write', 'log'],
161+
},
162+
handler: 'closeStaleTasks', // deprecated — kept until the runtime runs job bodies (see below)
163+
timeoutMs: 120000,
164+
});
165+
```
166+
167+
<Callout type="warn">
168+
**The runtime does not run a job `body` yet.** Scheduling a job's body is a
169+
separate change that has not landed: until it does, a job is scheduled through
170+
its `handler`, and a job with a `body` and no `handler` is skipped at boot with
171+
a `warn`. `os validate` and `os build` say so wherever a `body` is set. Keep
172+
`handler` beside `body` for now; the `body` is validated today, so it is ready
173+
the day the runtime starts honouring it.
174+
</Callout>
175+
176+
What running in the sandbox means for the code in `source`:
177+
178+
- **No module scope.** `source` is the function body only: no `import` or
179+
`require`, and no helper, constant or variable from the surrounding file — the
180+
body is evaluated on its own, with the standard JavaScript globals and `ctx`.
181+
- **Data only under declared capabilities.** The body reaches records through
182+
`ctx.api.object(…)`, and only with the tokens it declares: `api.read`,
183+
`api.write`, `api.transaction`. Logging is `ctx.log`, under `log`. An undeclared
184+
call throws at run time. Outbound HTTP is not available; use a Connector.
185+
- **Not the handler's context.** The `JobHandlerContext` a `handler` receives —
186+
`ql`, `logger`, `bundle` — does not exist inside the sandbox. A handler is not
187+
turned into a body by copying its source: rewrite its `ql.find(…)` reads as
188+
`ctx.api.object(…).find(…)` and its `logger` calls as `ctx.log`.
189+
- **Only the JavaScript body.** The expression (L1) body hooks accept is refused
190+
on a job: an expression performs no I/O, so its only effect would be a returned
191+
value, and a job runs for its effects.
192+
193+
`objectstack build` does not write a job's `body` for you: write it as data. The
194+
build cannot read the source of a function a `handler` names — `defineStack`
195+
parses `functions` into wrappers — and a handler written against
196+
`JobHandlerContext` would not run in the sandbox as it stands.
197+
198+
### Time limit and long-running work
199+
200+
A body job has **one** time limit, the job's `timeoutMs`. One attempt is one
201+
sandbox run, and the runtime bounds that run by `timeoutMs`; the `timeoutMs` a
202+
hook or action body can carry inside `body` is refused on a job, so the limit is
203+
never written twice. Unlike that body-level key, which is capped at 30 seconds,
204+
the job-level `timeoutMs` has no cap. Unlike a `handler` attempt, a sandbox run
205+
over its limit is stopped, not merely abandoned.
206+
207+
So long-running work either **declares a `timeoutMs` that covers it**, or —
208+
usually better — **splits into bounded runs**: process a page of records per
209+
run (a `limit` on the read, as above) and let the schedule bring the next run,
210+
so each attempt finishes well inside its limit and a retry repeats one page
211+
rather than the whole sweep. Omit `timeoutMs` and a body run is still bounded by
212+
the sandbox's own default invocation limits, which are far shorter than a long
213+
sweep needs. A body also runs under the sandbox's per-run memory cap
214+
(`body.memoryMb`, at most 256), which is one more reason to page rather than load
215+
everything at once.
216+
136217
## The handler, and the ways it can fail to be one
137218

138219
`handler` must match a key of `defineStack({ functions })`. At `kernel:ready`
@@ -221,11 +302,13 @@ Two defaults worth knowing before you rely on the block:
221302
count still means *no retry*. State a count to opt in.
222303
- **`backoffMultiplier` defaults to `1`** — a flat delay, not exponential.
223304

224-
`timeout` is a **per-attempt** limit in milliseconds. An over-limit run is
305+
`timeoutMs` is a **per-attempt** limit in milliseconds. An over-limit run is
225306
recorded with status `timeout` and, being a failure, is retried like any other.
226-
JavaScript cannot forcibly cancel a running function, so the attempt is
307+
JavaScript cannot forcibly cancel a running function, so a `handler` attempt is
227308
*abandoned*, not killed — a handler that ignores its own cancellation can still
228-
be executing after the platform has moved on. Omit `timeout` for no limit.
309+
be executing after the platform has moved on. Omit `timeoutMs` for no
310+
per-attempt limit. For a job with a `body`, `timeoutMs` is also the limit of the
311+
sandbox run — see [Time limit and long-running work](#time-limit-and-long-running-work).
229312

230313
## Running on more than one node
231314

‎content/docs/references/system/job.mdx‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,10 @@ const result = CronScheduleSchema.parse(data);
5757
| **label** | `string` | optional | Human-readable label |
5858
| **description** | `string` | optional | Job description / purpose |
5959
| **schedule** | `{ type: 'cron'; expression: string \| object; timezone?: string } \| { type: 'interval'; intervalMs: integer } \| { type: 'once'; at: string }` | ✅ | Job schedule configuration |
60-
| **handler** | `string` | ✅ | Handler function name (must match a key in `defineStack({ functions })`) |
60+
| **handler** | `string` | optional | Handler function name (must match a key in `defineStack({ functions })`) — DEPRECATED, prefer `body`. When both are present `body` wins; a job must declare one of the two. |
61+
| **body** | `{ language: 'js'; source: string; capabilities?: Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'log'>[]; timeoutMs?: integer; … }` | optional | Job body — a sandboxed JS (L2) body, the same shape hooks and actions use; an expression (L1) body is refused, because a job runs for its effects and an expression has none. Preferred over `handler`: when both are present `body` wins. It runs in the QuickJS sandbox with no module scope (no imports, no helpers or constants from the surrounding file): it reaches data only through `ctx.api` under its declared `capabilities` (`api.read` / `api.write` / `api.transaction`) and logs through `ctx.log` (`log`); the in-process handler context (`ql`, `logger`, `bundle`) does not exist there. Its time limit is the job's `timeoutMs` (see there): long-running work declares a `timeoutMs` that covers it, or splits into bounded runs that each finish within it. The runtime binder that schedules job bodies has not landed yet: until it does a job runs through `handler`, so keep `handler` beside `body`. |
6162
| **retryPolicy** | `{ maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; maxRetryDelayMs?: integer; … }` | optional | Retry policy: failed runs (including timeouts) are retried with exponential backoff (delay = min(backoffMs * backoffMultiplier^(retry-1), maxRetryDelayMs), optionally jittered) up to maxRetries retries after the initial attempt. Omit the block for a single attempt; declaring it without `maxRetries` also means no retry since 17.0.0 — state a count to opt in. |
62-
| **timeoutMs** | `integer` | optional | Per-attempt time limit in milliseconds; an over-limit run is recorded with execution status "timeout". The in-flight handler is abandoned, not forcibly cancelled. Omit for no time limit. |
63+
| **timeoutMs** | `integer` | optional | Per-attempt time limit in milliseconds; an over-limit run is recorded with execution status "timeout". A `handler` run is abandoned, not forcibly cancelled. For a job with a `body` this is the ONE time limit: one attempt is one sandbox invocation, the runtime bounds that invocation by this value, and the body shape's own `timeoutMs` (capped at 30000 for hooks and actions) is refused on a job — so this key, which has no such cap, is where long-running work states how long it needs. Omit for no per-attempt limit; a `body` run is then still bounded by the sandbox's own default invocation limits. |
6364
| **timeout** | `never` | optional | [REMOVED] `job.timeout` was removed in @objectstack/spec 17 — its unit (milliseconds) lived only in the description while the sibling `retryPolicy.backoffMs` spells its own, so the same number read as two conventions on one surface. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
6465
| **enabled** | `boolean` | optional (default: `true`) | Whether the job is enabled |
6566
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
@@ -92,6 +93,16 @@ const result = CronScheduleSchema.parse(data);
9293
| **type** | `'once'` | ✅ | |
9394
| **at** | `string` | ✅ | ISO 8601 datetime when to execute |
9495

96+
### Nested Shape: `Job.body`
97+
98+
| Property | Type | Required | Description |
99+
| :--- | :--- | :--- | :--- |
100+
| **language** | `'js'` | ✅ | |
101+
| **source** | `string` | ✅ | Function body source |
102+
| **capabilities** | `Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'log'>[]` | optional (default: `[]`) | Granted capability tokens |
103+
| **timeoutMs** | `integer` | optional | Per-invocation timeout (ms) |
104+
| **memoryMb** | `integer` | optional | Per-invocation memory cap (MB) |
105+
95106
### Nested Shape: `Job.retryPolicy`
96107

97108
| Property | Type | Required | Description |

‎packages/spec/authorable-surface/system.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,7 @@
475475
"system/Job:_packageId",
476476
"system/Job:_packageVersion",
477477
"system/Job:_provenance",
478+
"system/Job:body",
478479
"system/Job:description",
479480
"system/Job:enabled",
480481
"system/Job:handler",

0 commit comments

Comments
 (0)