Skip to content

Commit 7940de5

Browse files
Elon Muskclaude
andauthored
feat(cli)!: retire the @capabilities hook-body directive (#10917) (#11043)
The directive was read off the handler's stringified source, and `loadConfig` runs every config through `bundle-require` and esbuild, which strips `//` line comments before the handler is ever a runtime function. Measured on all four ordinary authoring shapes (`.ts`, `.js`, `.mjs`, an imported handler) it reached the extractor from none of them: the build exited 0, printed nothing, and shipped the inferred capabilities alone, so the mismatch surfaced far from its cause as a sandbox refusal at runtime. Maintainer ruled Option B under ADR-0049 enforce-or-remove. Removed: the override branch in `extract-hook-body.ts`, its docs block, and the two unit tests that pinned it. `body.capabilities` — data rather than a comment, measured to survive the build — remains the covered route for the same need. Accept behaviour is unchanged: a config still carrying the comment builds to the same artifact as before. Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r Co-authored-by: Claude <noreply@anthropic.com>
1 parent a24b7fa commit 7940de5

6 files changed

Lines changed: 191 additions & 111 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
**BREAKING** Retire the `@capabilities` hook-body directive comment (#10917).
6+
7+
`os build` no longer reads a `@capabilities` line out of a handler body, and the
8+
docs no longer teach one. A body's capabilities are either inferred from its
9+
source, or declared as data in `body.capabilities` on the hook or action — the
10+
route that is measured to survive the build, and now the only way to name a token
11+
the code itself does not reveal.
12+
13+
**Nothing an author wrote has to change.** The directive was read off the
14+
handler's stringified source, and `loadConfig` runs every config through
15+
`bundle-require` and esbuild, which strips `//` line comments before the handler
16+
is ever a runtime function. Measured on all four ordinary authoring shapes —
17+
`objectstack.config.ts`, `.js`, `.mjs`, and a handler imported from a local
18+
module — it reached the extractor from none of them: the build exited 0, printed
19+
nothing, and shipped the inferred capabilities alone. A config that still carries
20+
the comment builds to the same artifact before and after this release, so
21+
deleting it is optional and changes no output. What is gone is the wrong
22+
convention it taught, silently, to everyone who copied it out of the docs — a
23+
handler asking for more than inference derived was refused by the sandbox at
24+
runtime, far from the cause.
25+
26+
Ruled under ADR-0049 enforce-or-remove: a capability declaration nothing parses is
27+
a false promise, and this one could not even be typed wrongly-but-visibly, because
28+
every authoring path deleted it before the extractor looked.
29+
30+
The retirement kit: the override branch in `extract-hook-body.ts` and the two unit
31+
tests that pinned it are gone; the extractor header and
32+
`content/docs/automation/hook-bodies.mdx` record the removal instead of the
33+
spelling; the `os build`-level test keeps pinning both halves — the comment
34+
contributing nothing, and `body.capabilities` surviving — and a unit pin standing
35+
on the one shape where the override ever fired now asserts it grants nothing.
36+
37+
<!-- adr-0087: not-required (no-migration-prescription) The retired surface is a COMMENT inside a hand-written handler, not metadata. It has no spec schema, no defKey:name row in authorable-surface, and nothing that `objectstack migrate meta` can reach or rewrite, so no ADR-0087 ledger entry is expressible for it; inventing one would misdate a retirement the registry cannot honestly carry. It was also inert on every measured authoring path, so no consumer has a rewrite to perform at all — keeping the comment and deleting it produce the same build output. -->

content/docs/automation/hook-bodies.mdx

Lines changed: 37 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ If you have a body that genuinely cannot be expressed in L1+L2 (typically: it ne
257257

258258
Note that a CommonJS `require('node:os')` in a TypeScript config reaches the extractor as esbuild's `__require("node:os")`. Both spellings are refused under the same `require()` reason.
259259

260-
Capabilities are inferred by matching known patterns in the body source (e.g. `ctx.api.object(...).insert(...)``api.write`). A fuller AST-based analysis is planned for a later version. When the inference is wrong, supply `body` yourself with an explicit `capabilities` array — the `// @capabilities` directive comment [does not reach the build](#capability-inference).
260+
Capabilities are inferred by matching known patterns in the body source (e.g. `ctx.api.object(...).insert(...)``api.write`). A fuller AST-based analysis is planned for a later version. When the inference is wrong, supply `body` yourself with an [explicit `capabilities` array](#capability-inference)that path is data rather than a comment, so it survives the build.
261261

262262
## Migration
263263

@@ -319,46 +319,46 @@ The extractor scans each body for known patterns and adds the matching capabilit
319319
| `ctx.crypto.randomUUID` | `crypto.uuid` |
320320
| `ctx.log.info / warn / error / debug` | `log` |
321321

322-
To override the inference, add a directive comment as the first line of your handler body:
322+
When inference does not derive what a body needs, declare the tokens yourself by
323+
supplying `body` on the hook or action instead of a `handler`:
323324

324325
```ts
325-
handler: async (ctx) => {
326-
// @capabilities api.read api.write log
327-
328-
}
326+
hooks: [
327+
{
328+
name: 'notify_owner',
329+
object: 'account',
330+
events: ['afterInsert'],
331+
body: {
332+
language: 'js',
333+
source: "await ctx.api.object('task').insert({ subject: 'follow up' });",
334+
capabilities: ['api.write', 'log'],
335+
},
336+
},
337+
]
329338
```
330339

331-
<Callout type="warn" title="This directive does not reach `objectstack build`">
332-
The override is read off the handler's **stringified source** (`String(fn)`), so it
333-
only works if the function the CLI holds still carries the comment. Through
334-
`objectstack build`, it never does.
335-
336-
`loadConfig` runs your config through `bundle-require` → esbuild, and esbuild strips
337-
`//` line comments before the handler is ever a runtime function. The directive is
338-
gone by the time the extractor looks at it. Measured on all four authoring shapes:
339-
340-
| Authoring shape | `// @capabilities api.write log` reaches the extractor? |
341-
|---|---|
342-
| `objectstack.config.ts` | **No** — comment stripped by esbuild |
343-
| `objectstack.config.js` | **No** — esbuild runs on `.js` too |
344-
| `objectstack.config.mjs` | **No** — same path |
345-
| handler imported from a local `./handlers.js` | **No** — esbuild bundles it as well |
346-
347-
In every case the build exits 0, prints nothing, and emits the **inferred**
348-
capabilities only. A handler asking for `api.write log` whose body reads
349-
`ctx.api.object('x').find({})` ships `"capabilities": ["api.read"]` — inference won,
350-
silently, and the directive had no effect at all.
351-
352-
**So do not rely on this directive.** Write the body so the
353-
[inference table](#capability-inference) above derives what you need, or supply
354-
`body` yourself on the hook with an explicit `capabilities` array — that path is
355-
data, not a comment, and survives the build.
356-
357-
Inference is unaffected: it matches the *code*, which esbuild keeps. Only the
358-
comment-borne override is lost. Tracked in #10678, where the question of whether the
359-
directive should get a real authorable surface (or be retired) is open — this page
360-
documents the reach as measured, and an `objectstack build`-level test pins it so
361-
this page and the extractor cannot drift apart again.
340+
That path is **data, not a comment**, so nothing in the build pipeline can strip it
341+
on the way through — which is what makes it the supported way to say what a body
342+
needs.
343+
344+
<Callout type="warn" title="The `@capabilities` directive comment was removed in 17.1">
345+
A directive comment on the first line of a handler body was once documented as a
346+
way to override inference. It was **retired** (#10917) and the extractor no longer
347+
reads it.
348+
349+
It had never worked from any ordinary authoring path. The override was read off
350+
the handler's stringified source (`String(fn)`), and `loadConfig` runs your config
351+
through `bundle-require` → esbuild, which strips `//` line comments before the
352+
handler is ever a runtime function. Measured on all four shapes —
353+
`objectstack.config.ts`, `.js`, `.mjs`, and a handler imported from a local module
354+
— the build exited 0, printed nothing, and emitted the **inferred** capabilities
355+
alone. A handler asking for more than inference derived was then refused by the
356+
sandbox at runtime, far from the cause.
357+
358+
**If a config of yours still carries one:** removing it changes nothing, because it
359+
was already inert. If you meant the tokens it named, declare them in
360+
`body.capabilities` as above. Inference is unaffected either way — it matches the
361+
*code*, which esbuild keeps.
362362
</Callout>
363363

364364
### Build pipeline at a glance

docs/qa/platform-checklist/areas/cli.json

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1232,14 +1232,14 @@
12321232
"title": "Hook/action body extraction: --strict-body refuses every forbidden pattern with its own worded reason, capability tokens are inferred (and crypto.hash is NOT, #4391), free identifiers fall back to bundling — and the DEFAULT build's silent fallback contradicts the extractor's own contract (expected-fail)",
12331233
"since": "v16",
12341234
"status": "active",
1235-
"revision": 1,
1235+
"revision": 2,
12361236
"priority": "P1",
12371237
"surface": "cli",
12381238
"personas": ["build-time author (no session)"],
12391239
"fixtures": {
12401240
"app": "scaffold",
12411241
"requires": [
1242-
"a scratch blank scaffold whose config authors the probe hooks — one per FORBIDDEN_PATTERNS entry, one per CAPABILITY_PATTERNS shape, the ctx.crypto.hash regression probe, the @capabilities override, the const-api alias, an implicit-return arrow, and one handler calling a module-scope helper",
1242+
"a scratch blank scaffold whose config authors the probe hooks — one per FORBIDDEN_PATTERNS entry, one per CAPABILITY_PATTERNS shape, the ctx.crypto.hash regression probe, a probe carrying the RETIRED @capabilities first-line comment, a hook declaring body.capabilities as data instead of a handler, the const-api alias, an implicit-return arrow, and one handler calling a module-scope helper",
12431243
"jq over dist/objectstack.json — body presence and body.capabilities per hook are the artifact-side oracle"
12441244
],
12451245
"knownGaps": [
@@ -1250,7 +1250,7 @@
12501250
"steps": [
12511251
"author the forbidden seven, one hook each: dynamic import(, require(, fetch(, process.<x>, globalThis.<x>, eval(, new Function( — then run `os build --strict-body; echo $?` and capture every per-callable diagnostic verbatim",
12521252
"the default-path pair on the SAME config: `os build; echo $?` — capture the exit, the 'Bundling N handler(s)…' line, and jq the artifact for each hook's body (expect ABSENT) and handler ref (expect present)",
1253-
"the inference matrix, one hook each on a clean config: ctx.api.object('x').find(…) · ctx.api.object('x').update(…) · const api = ctx.api; api.object('x').find(…) · ctx.crypto.randomUUID() · ctx.log.info(…) · a body whose ONLY crypto call is ctx.crypto.hash(…) · a body with '// @capabilities api.read api.write' as its first line and no matching calls · an implicit-return arrow — `os build` then jq each hook's body.capabilities and isExpression handling",
1253+
"the inference matrix, one hook each on a clean config: ctx.api.object('x').find(…) · ctx.api.object('x').update(…) · const api = ctx.api; api.object('x').find(…) · ctx.crypto.randomUUID() · ctx.log.info(…) · a body whose ONLY crypto call is ctx.crypto.hash(…) · a body carrying the retired @capabilities first-line comment and no matching calls (#10917 — it must contribute nothing) · a hook supplying body.capabilities as data instead of a handler · an implicit-return arrow — `os build` then jq each hook's body.capabilities and isExpression handling",
12541254
"free-identifier probe: a handler calling a module-scope helper function — `os build; echo $?` (expect green, handler bundled, no body) then `os build --strict-body; echo $?` (expect exit 1 naming the identifier)",
12551255
"all-body-only run: remove every non-extractable handler and confirm the 'Skipping legacy runtime bundle (all N callables are body-only)' line and that no objectstack-runtime.*.mjs remains in dist/",
12561256
"re-run the strict-body failure with --json and capture the { success: false, error: 'strict-body: missing body', issues } payload"
@@ -1275,10 +1275,10 @@
12751275
"evidence": "the three jq captures"
12761276
},
12771277
{
1278-
"clause": "the '// @capabilities …' first-line override adds exactly the named tokens (from the closed set api.read/api.write/crypto.uuid/log) even when no call pattern matches, merged with any inference — the author's declaration wins additively",
1278+
"clause": "capabilities the body's own code does not reveal are declared as DATA: a hook supplying body: { language, source, capabilities: [...] } ships exactly those tokens into the artifact. The RETIRED @capabilities first-line comment (#10917, ADR-0049 enforce-or-remove) adds nothinga config that still carries one builds identically to one that does not, which is the guarantee owed to apps authored while it was documented",
12791279
"oracle": "build",
1280-
"verify": "the override hook with no matching calls carries ['api.read','api.write'] in the artifact (extract-hook-body.ts:118-131; documented at content/docs/automation/hook-bodies.mdx:320-327)",
1281-
"evidence": "the jq capture"
1280+
"verify": "jq shows the declared tokens verbatim on the body-supplying hook; the retired-comment hook carries ONLY what inference derives (['api.read'] for a body reading .object(x).find(…) — never the api.write/log the comment names) and os build exits 0 saying nothing about it. No override branch is left in extract-hook-body.ts to read the comment (retirement note :43-59); content/docs/automation/hook-bodies.mdx documents the removal, not the spelling",
1281+
"evidence": "both jq captures + the exit code"
12821282
},
12831283
{
12841284
"clause": "#1876 self-containment: a handler referencing a module-scope identifier throws out of extraction naming the identifier(s), the caller catches and keeps the handler BUNDLED (no body, handler ref into the .mjs) so the default build stays green with no behavior change — while --strict-body surfaces the same message as a hard failure",
@@ -1308,15 +1308,16 @@
13081308
],
13091309
"traps": ["stale-dist", "absence-inference"],
13101310
"source": [
1311-
"packages/cli/src/utils/extract-hook-body.ts (contract header :3-29 — whose fail-the-build sentence the expected-fail clause tests; FORBIDDEN_PATTERNS :33-41; CAPABILITY_PATTERNS :43-57 with the #4391 removal note :52-55; #1876 free-identifier throw :94-109; @capabilities override :118-131)",
1311+
"packages/cli/src/utils/extract-hook-body.ts (contract header :4-67, carrying the #10917 retirement note at :43-59; FORBIDDEN_PATTERNS :71-88; CAPABILITY_PATTERNS :90-104 with the #4391 removal note :99-102; #1876 free-identifier throw :146-161 — the @capabilities override branch this item used to cite is retired and gone)",
13121312
"packages/cli/src/utils/lower-callables.ts (:33-36 the warnings field, :63-78 tryExtractBody's catch-all fallback-to-bundle — the seam the default path's silence flows from)",
13131313
"packages/cli/src/commands/compile.ts (:126-149 the --strict-body gate, :366-393 the needsBundle decision + skip line, :437 the --json warnings key that excludes extraction warnings)",
1314-
"content/docs/automation/hook-bodies.mdx (:254-256 the documented default + --strict-body posture; :311-327 the inference table + override)",
1314+
"content/docs/automation/hook-bodies.mdx (the documented default + --strict-body posture; the Capability inference section — the inference table, the body.capabilities route, and the callout recording the retired directive)",
13151315
"packages/cli/src/utils/lower-callables.test.ts (the existing unit seam pin — cited, not a substitute for driving os build)",
13161316
"sibling items records-forms (runtime sandbox side, body-runner.ts) and cli.build-own-contract (the build's own exit/output contract)"
13171317
],
13181318
"history": [
1319-
{ "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能): a security grant derived from a regex had no checklist item — only the runtime side of one token (records-forms) and the build's generic exit contract were covered. RE-VERIFIED against source with one material correction to the hunter brief: a forbidden pattern does NOT fail a default `os build` — lower-callables.ts:63-78 catches every extraction error and falls back to the bundle, the warnings print nowhere on that path, and only --strict-body (compile.ts:126-149) produces the worded refusals; hook-bodies.mdx:256 documents exactly that, while the extractor's own header still promises fail-with-no-fallback. The worded-refusal clauses are therefore pinned to --strict-body, and the default path's silence is encoded as the expected-fail contradiction clause", "ref": "claude/new-session-0pv25p" }
1319+
{ "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能): a security grant derived from a regex had no checklist item — only the runtime side of one token (records-forms) and the build's generic exit contract were covered. RE-VERIFIED against source with one material correction to the hunter brief: a forbidden pattern does NOT fail a default `os build` — lower-callables.ts:63-78 catches every extraction error and falls back to the bundle, the warnings print nowhere on that path, and only --strict-body (compile.ts:126-149) produces the worded refusals; hook-bodies.mdx:256 documents exactly that, while the extractor's own header still promises fail-with-no-fallback. The worded-refusal clauses are therefore pinned to --strict-body, and the default path's silence is encoded as the expected-fail contradiction clause", "ref": "claude/new-session-0pv25p" },
1320+
{ "revision": 2, "date": "2026-08-22", "change": "the '// @capabilities' first-line override was RETIRED (#10917, maintainer ruling under ADR-0049 enforce-or-remove): measured on all four ordinary authoring shapes (.ts/.js/.mjs/an imported handler) it reached the extractor from NONE of them, so a documented directive silently taught a wrong convention and the mismatch surfaced far from its cause as a sandbox refusal. Its acceptance clause asserted behaviour that no longer exists — a runner would have filed a FAIL against the ruling — and is replaced by the covered route (body.capabilities as data) plus the inertness guarantee owed to configs that still carry the comment. Steps, fixtures and the extract-hook-body.ts line citations moved with it. No other clause changed; note the expected-fail clause still describes the pre-#10678 silent default and is stale on its own account, which is NOT this revision's subject", "ref": "claude/issue-10917-retire-capabilities-hook-directive" }
13201321
]
13211322
},
13221323
{

0 commit comments

Comments
 (0)