fix(cli,lint): refuse a hook/action body calling .create() at lowering, and withdraw the verb from the write-pattern ledger - #16900
Conversation
…write ledger Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
… withdrawal Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
…pin the refusal text Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
…fuse-create-at-lowering
📓 Docs Drift Check4 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 25 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b026049143313c5cde71731813437c89bfad3d50 && git checkout b026049143313c5cde71731813437c89bfad3d50
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f2b5e4686b0464fcbc19873c24b9a477fd5a0bfa fb0af58acdd7275b67fc8296db99c13e1abf34ab && git checkout -B drift-repro f2b5e4686b0464fcbc19873c24b9a477fd5a0bfa && git merge --no-ff fb0af58acdd7275b67fc8296db99c13e1abf34ab
node scripts/docs-audit/affected-docs.mjs --json f2b5e4686b0464fcbc19873c24b9a477fd5a0bfa |
Fixes #16249
Clause-②: no— re-declared from the delivered diff, not inherited from the dispatch. Every move in this PR narrows toward a contract the spec already declares: the build refuses a spelling the sandbox never installed, and the extractor ledger stops advertising it. Nothing published gains a member. The rejected alternative — installing acreateleaf ininstallCtx— would have flipped it toyes, because it would have had the sandbox ratify a verb the contract never declared; it is not taken here.The premise triage did not verify — verified, and it holds
Triage's ruling rested on an explicitly unchecked premise, so this was the first action on the card:
Read at
origin/main,packages/spec/src/contracts/scoped-context.ts(read-only here — PR #16783 holds that file):IScopedObjectRepositorydeclares exactlyfind·findOne·count·insert·update·updateById. Nocreate.createin the measured-and-deliberately-excluded list: "create(aninsertalias),deleteById,execute— no call site at all outside those fixtures."ObjectRepository.create()inpackages/objectql/src/engine.tsis a host-side alias ofinsert(), carrying the comment/** Alias for insert() — matches @objectql/core convention */.⇒ The contract declares
insertonly. The contract-first route holds; the card proceeds as ruled. Nothing to stop and report.What was wrong
Four layers disagreed about
ctx.api.object('x').create({ … }), and the only one that actively told an author how to write it was the one that was wrong.insertonlyinstallCtx)insert / update / delete / updateMany / deleteMany / upsert— nocreateleaf.create({…})as legalapi-crud-literalsyntax and graded its payload as a live writeAn author following the ledger got a body that threw
TypeError: not a functionon its first run, and under a hook's defaultonError: 'abort'that throw aborted the triggering write, with a message naming no member — the blind message #14010 measured forsudo().The three moves, together
packages/cli/src/utils/extract-hook-body.ts—.create(joinsFORBIDDEN_PATTERNS. Shaped on.sudo(, one entry over, with a reason that names.insert({ ... })as the spelling the sandbox actually has and lists the leaves the VM installs. The header comment above the table keeps up. Behaviour is the ordinaryforbidden-tokenfallback: the callable is still registered and still shipped through the.mjsbundle, so the handler keeps running in-process where the host alias exists — the build merely declines to also emit it as a body that cannot run. Under--strict-bodyit is a hard failure, correctly.The alias question is answered by
.sudo(, not re-decided here: receiver-loose, soconst repo = ctx.api.object('x'); repo.create(…)is refused too. ⭐ One carve-out.sudo(needed no equivalent of:Objectis a real sandbox global (pinned inSANDBOX_GLOBALS), soObject.create(null)is working, lowerable code. A bare receiver-loose rule would refuse it — turning a correct body into a bundled closure, and a hard failure under--strict-body. That is a false refusal, not the safe direction, so the pattern excludes that one receiver by lookbehind and nothing else (myObject.create(still matches, because the word boundary is required). Both directions are pinned.packages/lint/src/validate-hook-body-writes.ts—createwithdrawn from the ledger. Out of theapi-crud-literaladvertised syntax, and out ofAPI_WRITE_METHODS. The map's docblock now records both absences (upsert,create) with their measured reasons, the way this repo keeps "excluded" tellable from "overlooked".hook-body-write-unknown-field/action-body-write-unknown-fieldstop grading a call that cannot run — the ledger is shared, so the withdrawal lands on the action surface identically, and for the same reason (an actionbodyruns in the same VM).⭐
packages/lint/src/validate-readonly-hook-writes.ts— finding: the flow and hook readonly rules still carry the superseded "INSERT is engine-exempt" premise as a scan gap — a non-system create_record / ctx.api.insert of a readonly column is now a silent no-op nothing reports at build time #15394's exclusion kept, its reason updated.READONLY_HOOK_METHOD_EXCLUSIONSstill excludescreate; what changed is why. It is no longer "the call throws, so a silently-dropped finding would be false" — that described a shape which reached this rule because the ledger advertised it. It is now "the shape can no longer get here": the build refuses.create(at lowering, so a handler spelling it is bundled and never becomes abody.sourcethis rule parses. The sandbox reading stays in the text as the why behind the refusal, so the entry still explains itself to someone who never opens the CLI. The file header carries the same correction. ⛔ The old sentence is pinned as not coming back.⛔ The host-side
ObjectRepository.create()alias is untouched. The card draws that line: it is reachable by in-process handlers and actions and is not the defect. Only the L2 body surface is.Pins, and the ablation that proves they can fail
Tier choice: both pins are named into the queue tier (no
.e2e/.livein the filename) and land in theunitproject by behaviour — they spawn nothing and boot no kernel, they call the extractor and the rule functions directly. Deliberate: a build-time refusal and a ledger partition are pure functions of source text, and the value of the pin is that it runs on every queue build rather than nightly.Ablation run from the committed state, on
fb0af58acd. Verdicts are test counts, never exit codes —vitest --project Xnaming no collected file exits 1 exactly as a failing suite does.Leg A — neuter the build-time refusal (
packages/cli/src/utils/extract-hook-body.ts)1 -> 0, injected marker0 -> 1; blob073ea1c7ead8->462147869c0cTests 30 passed (30)Tests 3 failed | 27 passed (30)git diff HEADempty; blob back to073ea1c7ead8;Tests 30 passed (30)Leg B — re-add
createto BOTH ledger halves (packages/lint/src/validate-hook-body-writes.ts)API_WRITE_METHODSrow0 -> 1, injected advertised syntax0 -> 1; blobf6b39519c027->3d89842454e8Tests 131 passed (131)Tests 3 failed | 128 passed (131)— the three named pins, one per surfacegit diff HEADempty; blob back tof6b39519c027;Tests 131 passed (131)The three cases leg B reddens are exactly the requested "a re-added ledger entry reddens too":
validate-readonly-hook-writes.test.ts— partitions the extractor's ctx.api write verbs exactlyvalidate-hook-body-writes.test.ts— does not grade a .create() payloadvalidate-action-body-writes.test.ts— does not grade a .create() payload on the action surface eitherWhole-tree restore proof after both legs:
git status --porcelainempty,git diff HEAD --statempty. The ablation script carried anEXIT INT TERMtrap restoring withgit checkout HEAD -- ABSOLUTE_PATH; an emptygit hash-objectreading was treated as failure, never as "nothing to compare". Its first run aborted on its own guard — a marker I had chosen was not unique to the syntax line, so the pre-count was 1 where 0 was expected — and it refused to read the run rather than reporting a mutation it had not proven.Verification
pnpm lint(eslint . --no-inline-config) — exit 0, 2m09s, onfb0af58acd. Full scan; no narrowing claimed and none needed.node scripts/pm/dispatch-gates.mjs --commandsover the merge-base change set: 86 derived, 86 run, 85 green, thencheck:type-check-debtgreen on a re-run once its heap ceiling was raised (it OOMs under--max-old-space-size=4096, which is a prerequisite failure and not a finding).--ranreconciliation: 86 derived familie(s) accounted for — 86 run, 0 NOT-MEASURED.check:doc-authoringrefused the tracker id I had put inside the runtimereasonstring (a runtime string reaches authors who cannot resolve#NNNN); the id moved to the adjacent//comment. The first CLI pin also failed on its own assertion regex, which expected the leaf list unquoted while the refusal backticks it.@objectstack/cli--project uniton the touched files:Tests 73 passed (73).@objectstack/lintfull suite:Test Files 102 passed (102),Tests 3571 passed (3571).typecheckgreen on both packages.packages/spec check:generated— All 15 generated artifacts are up to date.packages/clitiering: theintegrationtier is declared to CI. The diff touches no integration-tier file, no spawn entry (bin/,test/helpers/serve-process.ts) and no driver/kernel boot path;test/vitest-tiers-partition.test.tsran green in theunitproject.check:nul-bytes:grep -naPover the nine delivered files, zero hits, with a positive control proving the query fires.Docs drift — done on this lane, with the tool's blind spot answered by hand
node scripts/docs-audit/affected-docs.mjs --json origin/main, from a worktree whosegit status --porcelainwas empty. The tool's own provenance saysdirty: false,head fb0af58acd,diffBase afa3a2698f. Its verdict: 0 docs, 0 release-owned docs, 4 symbol anchors (API_WRITE_METHODS,FORBIDDEN_PATTERNS,HOOK_BODY_WRITE_PATTERNS,READONLY_HOOK_METHOD_EXCLUSIONS).content/docs/automation/hook-bodies.mdxstates this rule three times by its inputs and names none of the four anchors. Three passages there were falsified and are corrected in this PR — the forbidden-token list, the recognised write-shape table row (insert\|create\|update->insert\|update), and the.create()exclusion prose. A fourth, the capability-inference table, is left listingcreateand gains a sentence saying why: that matcher is deliberately over-inclusive (it also namespatch,remove,get,list), andcreatecan no longer reach inference at all.No release-owned page is falsified; none was edited. Every
content/docs/releases/*.mdxpage was read for a hook-body/sandboxcreateclaim and there is none — a zero paired with a live control (the same corpus and vocabulary returns 5 hits forsandbox/ctx.api).Other zeros in the sweep, each with its control: no page or skill outside
hook-bodies.mdxspellsctx.api.object(...).create(control: the.insertform hits 4 files); the same failing query run at the branch base hits exactly the two lines this PR edits, proving the query is live.skills/objectstack-data/references/data-hooks.md's repository-method and capability tables already listinsert / update / delete / upsert / updateMany / deleteManywithoutcreate— accurate before and after.Serial constraints
Re-measured independently of the dispatch, over 21 open PRs, FULL pagination, 705 file rows, 0 empty file lists: every file this PR edits has 0 holders. The reading has a live control —
packages/spec/src/contracts/scoped-context.tscorrectly reports PR #16783 as its holder, which is the file this PR only reads.验收备注 — noted, not filed
CAPABILITY_PATTERNS'api.writeregex inextract-hook-body.tsstill listscreate. With the refusal running first,.object(...).create(can no longer reach capability inference, so that arm is dead for this spelling. Left alone deliberately: the matcher is documented as over-inclusive and already names three other spellings the VM does not install (patch,remove, plusget/liston the read arm), so removing one of them alone would make the set less coherent, not more. Dead code, not a defect — noted, not filed. Carrier: any PR that revisits that matcher's membership as a set.READONLY_ACTION_INSERT_SILENCEinvalidate-readonly-action-writes.tsstill listscreateamong its silenced methods. Its reason there is about elevation (an action body'sctx.apiis already system-context), which is still true and was never the sandbox argument — so, unlike the hook-side exclusion, nothing it says has become false. What has changed is only that the verb can no longer arrive. Out of this card's declared file surface and left untouched. Carrier: thedomain:devxlane, which ownspackages/lintand is named in this card's file-surface declaration.hook-bodies.mdxdid not name.sudo(either, although the build has refused it since A hook cannot elevate, so a hook-written computed column cannot be protected by field-leveleditable: false— the guard and the writer are the same door #14010. The one bullet this PR had to write is the accurate statement of that list, so it names both members; this is a by-product of writing the row correctly, not a second change.File surface — declared, including the supplements to the dispatch's four
The dispatch declared four files across two lanes. Delivered, nine:
packages/cli/src/utils/extract-hook-body.tsdomain:clipackages/lint/src/validate-hook-body-writes.tsdomain:devxpackages/lint/src/validate-readonly-hook-writes.tsdomain:devxpackages/cli/test/extract-hook-body.test.tsdomain:clipackages/lint/src/validate-readonly-hook-writes.test.tsdomain:devxpackages/lint/src/validate-hook-body-writes.test.tsdomain:devx.create({ email: 'b' })line with a valid field, which produced no finding before the withdrawal and none after, so it would have passed while measuring nothing. Replaced with a misspelled-field case that is a real pinpackages/lint/src/validate-action-body-writes.test.tsdomain:devxcontent/docs/automation/hook-bodies.mdx.changeset/refuse-create-at-hook-body-lowering.mdpatchon both published packages, with the FROM -> TO migration linepackages/runtime/src/sandbox/quickjs-runner.tsandpackages/spec/src/contracts/scoped-context.tswere read only, as declared.Governed surfaces: none.
docs/adr/**·.claude/**·skills/**·AGENTS.md·CLAUDE.mdare all absent from this diff (checked againstGOVERNED_SURFACES, with a control proving the check fires).Generated by Claude Code
Generated by Claude Code