Repository navigation
fix(spec): ActionEngineFacade.delete declares the id array the runtime already accepts, and says which convention is the contract - #17608
Conversation
…h convention is the contract `ActionEngineFacade.delete` declared `id: string` while the runtime facade has always accepted `string | string[]`, iterating one `ql.delete` per id. The declaration is now `string | string[]`, and the member's doc comment states what the runtime comment used to record as an accident of two handler suites: both spellings are contract, the array form is a convenience over the same per-row path, and it is neither bulk nor atomic. Two consumers follow from the declaration: - `packages/runtime`'s "tolerant of both conventions" comment is retired (a comment correction only; the arm's behaviour is untouched). - `examples/app-todo/src/actions/task.handlers.ts` drops the hand-rolled `ActionContext` copy of the facade — which existed because the published type could not express its array call, and which had already drifted on `find` — for the published `ActionHandlerContext`. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…#15117) The level is measured, not assumed: nothing is removed and nothing narrows, no runtime behaviour changes, and the sibling re-declaration of the neighbouring member (`find`, #14175) shipped as a patch from the same interface. What ships is a published type that finally describes behaviour that was already served. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…tion-engine-delete-id-array
…#15117) `check:spec-docblock-symbol-anchors` refuses a line number as an anchor form, and the citation added with the widening (`:1471`) was one — a NEW finding, not one of the seven day-one residuals. It is now the symbol anchor `packages/runtime/src/action-execution.ts#buildActionEngineFacade`, which is also the more honest citation: this card exists partly because the line numbers the issue quoted had already drifted by three hundred lines. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 140 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 00965726fa29eec4293ba9959a661d0cd29e80b3 && git checkout 00965726fa29eec4293ba9959a661d0cd29e80b3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0918c441188cae8c3990ac8cb38dbb6dd6e7b6e4 8c90c0b0f3cfcc1c321e5cd022c6c4b565c88e9b && git checkout -B drift-repro 0918c441188cae8c3990ac8cb38dbb6dd6e7b6e4 && git merge --no-ff 8c90c0b0f3cfcc1c321e5cd022c6c4b565c88e9b
node scripts/docs-audit/affected-docs.mjs --json 0918c441188cae8c3990ac8cb38dbb6dd6e7b6e4
|
|
Seat adoption record — adopted VERBATIM. Tier verified from the transcript, ⛔ not self-report: 105 harness-stamped Independently re-measured by this seat before adopting — ⛔ the verdict's central claim is not taken on its word: ⭐ The discipline finding of this review is the purest instance of tonight's recurring lesson, and it deserves to be read past this PR. The round reported
⇒ ⭐ A control that lights, a scan that clears, a gate that exits 0 — none of them is a reading unless the instrument could have come back the other way. ⛔ And it is this seat's miss too, not only the round's. I had not read ④ on this PR; I took the round's gate summary and moved on to commissioning the review. The review is what caught it — which is the gate working, and also the reason ④ is a seat pre-check and not a thing to inherit from a report. On the level itself, the verdict does not merely cite a gate — it reads the written rule: an additive widening of a published surface takes at least ⭐ What the verdict confirms, so the rework does not re-litigate it: the contract sentence's five claims are each verified against the arm ( Carrier handling — the seat's protocol, ⛔ not the verdict's sentence about itThe verdict says the carriers "stay until the must-fixes land". ⛔ That is carrier bookkeeping, which is the seat's protocol and outside a reviewer's remit, and the standing rule is explicit: a FAIL clears both carriers exactly as a PASS does — the label means a review is pending, and one has happened. The owed work rides the handover comment on card #15117, ⛔ not a label. ⇒ Disposition of the three cards the verdict names
Contract review (
|
…ged docs page (#15117) Contract review returned FAIL with two must-fixes. 1. The changeset is `minor`, not `patch`. The written rule (`.github/workflows/pr-automation.yml`, maintainer ruling 2026-09-04 batch #35) is that a purely additive widening of a published package's public surface takes at least `minor`, and a commit type may raise a bump but never lower it below what the act requires. The PR's own `Clause-②: yes` line says this widens the accept set, in those words. The `find` precedent it leaned on does not reach: that was a NARROWING, it landed the day the rule was ruled, and the rule disclaims pre-rule `patch` precedents. How the wrong level survived local verification is the more useful half: `check-changeset-no-major` reads the clause-② declaration from the event payload and nothing else, so a local run without `--event` cannot exercise the level axis at all. Its exit 0 was recorded as a reading when the instrument could not have come back the other way. 2. `content/docs/ui/actions.mdx` — the page the repo's own Docs Drift Check flagged on this PR — is re-verified against the rewritten example. Its handler snippet still annotated `ctx: ActionContext`, a type that file no longer declares; it now imports and annotates the published `ActionHandlerContext`. The same snippet also wrote `completed_date`, which is `readonly` on `todo_task` and stamped by the object's `beforeUpdate` hook: copying it made the action refuse itself against `completed_date_required`. Both facts are verified against `task.object.ts` and `task.hook.ts`. The page now states the `delete` convention beside where it already states `find`'s. Folded in: the example's comment attributed to the contract a request the contract does not make. The contract asks for `ActionHandler`; it says so, and says why a file of function declarations annotates `ActionHandlerContext` instead. Comment-only — 11 changed lines, all comments or blank. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
|
Seat adoption record — adopted VERBATIM. Tier verified from the transcript, ⛔ not self-report: 70 harness-stamped ⛔ First: this seat amplified the false claim, and says so before anything elseIn the re-hang record above I wrote that the round's Re-measured by this seat before adopting — ⛔ not taken on the reviewer's word either: ⇒ A handler's ⭐ The round read the #7036 history and wrote it in the present tense. The removal is right; the stated reason is a false statement about the platform, on a published page, introduced by this push — and the true reason is close to its inverse: the write is not stripped, it lands, and on a re-complete with no transition it would overwrite the real completion timestamp.
⭐ The reviewer also tested this seat's own instruction instead of obeying itThe commission asked it to judge whether the round's refusal to re-run the gate was right, and warned it off reflexive praise. It found the round's premise — "the level question here has exactly one instrument: CI with the payload" — is false by the gate's own header: ⛔ This seat had praised that refusal too — as "harder than re-running" and "the correct generalisation". The generalisation is correct; the application was not, because a second instrument existed. Both halves now stand on the record together. Landing state⇒ What this re-read confirms and the rework must not re-litigate: the changeset is Contract review — narrow re-read (
|
…d field (#15117) The previous push removed `completed_date` from the handler snippet, which was right, and then explained it with a mechanism that is false. Re-measured: - A handler's `ctx.engine` runs ELEVATED — `buildActionExecutionContext` returns `{ ...base, isSystem: true }` (`packages/runtime/src/action-execution.ts`) — and the read-only strip is gated `if (!opCtx.context?.isSystem)` (`packages/objectql/src/engine.ts`). Nothing is stripped on that path, so "stripped from the payload before the record is validated" never happens. - The refusal does not exist either, for any caller. The hook's stamp is unconditional on the transition and its own docblock says that is precisely so a caller-supplied value is overwritten and survives the strip; a live test asserts it — "a caller that still sends `completed_date` is not punished for it — the hook value wins". The REJECTED row whose mechanism the page described is labelled "Measured before the fix". The true reason is close to the inverse, and is now what the page says: the write is not stripped, it LANDS, and on a write that is not a completion transition the hook does not stamp, so the handler's "now" silently replaces the real completion timestamp. I read the #7036 history at the three sites I cited and wrote it in the present tense, without reading the four sites the conclusion depended on. A citation that exists is not a citation that entails. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
|
Contract review verdict adopted verbatim — PASS. Seat readings before adopting (2026-09-11T05:01Z): tier from the review transcript Transport de-escaping applied by the seat before posting, and declared: Contract review — final re-read (
|
| page says | site | holds? |
|---|---|---|
a handler's ctx.engine runs elevated (isSystem) |
action-execution.ts:1420-1422 { ...base, isSystem: true } — unconditional, even for an absent ec; the facade binds it once at :1459 and passes it on every ql.update (:1467); both ctx.engine producers use it (:2091, domains/actions.ts:728) |
yes |
| the read-only strip is gated on not a system write, so it does not run on a handler's write | engine.ts:12213 if (!opCtx.context?.isSystem) around stripReadonlyFields; opCtx.context is options.context (:11199); and the pre-hook withholding of caller readonly keys is gated the same way (:11466), so nothing is hidden-then-restored over a hook stamp on a system write either. The one strip that does run on a system write (stripReadonlyWhenFields, :12195) skips any field without readonlyWhen (rule-validator.ts:679), which completed_date has none of |
yes |
completed_date is readonly on todo_task, stamped by the beforeUpdate hook on the completion transition |
task.object.ts:81-84 readonly: true; task.hook.ts:78-80 stamps only when data.status === 'completed' && previous?.status !== 'completed'; previous is bound from the pre-update read before the hook runs (engine.ts:11693, :11729, dispatch :11735), with no isSystem skip on the dispatch |
yes |
| on the transition the hook overwrites a handler-supplied value | :79 is a plain assignment, not ??= — the docblock's "UNCONDITIONAL" (:36-40) is about not deferring to a caller's key, which is exactly this |
yes |
| re-completing an already-completed task is not stamped | with data.status === 'completed' and previous.status === 'completed', :78 is false and the else if at :85-89 is false (data.status !== 'completed' fails) — neither branch touches completed_date |
yes |
| so nothing overwrites and nothing strips, and the handler's "now" replaces the real timestamp | no stamp (above), no strip (above), completed_date_required passes on a payload carrying a value, and driver.update (:12256) writes hookContext.input.data as-is |
yes |
| "more dangerous here than in a form PUT" | a form PUT is non-system: strip runs, and the live test at task-completion-trigger.test.ts:327-343 shows a forged value outside a transition is stripped for that caller |
yes, as a comparison |
The earlier verdict's "the stamp is unconditional" and the page's "stamps on the transition" are not in tension: the stamp is unconditional given a transition (:79) and gated on a transition (:78). The page states both halves correctly. The correction is not wrong in a new direction.
2. The PR body
The Docs section's second bullet now states the mechanism the sites above entail: buildActionExecutionContext = envelope + isSystem: true; strip gated on !opCtx.context?.isSystem; the hook's stamp unconditional on the transition so a caller value is overwritten; the :287 test named by its title (verbatim match); the REJECTED row's "Measured before the fix" label (:228); and "on a write that is not a completion transition the hook does not stamp, so the handler's 'now' silently replaces the real completion timestamp." All true.
The "exactly one instrument" sentence is retracted in the body's own words and replaced with the header's --base <sha> --event event.json (check-changeset-no-major.mjs:26-31), which is what the header says.
3. The instrument
The pair is real and I reproduced it independently, with the level read out of committed state. One payload (built from the live PR: labels documentation, size/m, tests, tooling, needs:contract-review, protocol:ui, body 13,740 bytes carrying - **Clause-(2): yes**), one base 3ef96b4712, two detached trees:
tree 2653b827af committed '@objectstack/spec': patch -> exit 1 "This PR declares clause-(2) YES, and it grades NO package ... - @objectstack/spec: patch"
tree 8c90c0b0f3 committed '@objectstack/spec': minor -> exit 0 "LEVEL AXIS: this PR declares clause-(2) `yes`, and no package ... is graded `patch`."
The red leg is on the very head CI refused (2653b827af, job 103136662669) and its text is line-for-line the job log's, down to the changeset path and the - @objectstack/spec: patch row. So this is a lit pair that reproduces a known red, not one assembled to match one: the instrument and its input are the same, and the answer flips with the committed level.
"The gate reads from git" is the real cause of the inert working-tree legs. scan() (:546-556) diffs merge-base..head with git diff --name-status ... -- '.changeset/*.md' and reads content with showOrNull(head, file) = git show <head>:<path> (:484-490); head defaults to HEAD (:1742). Nothing in that path opens the working tree (the readdirSync/readFileSync reader at :1678-1688 serves --list). Demonstrated without editing anything: from the tree whose disk says minor, --head 2653b827af -> exit 1; from the tree whose disk says patch, --head 8c90c0b0f3 -> exit 0. The disk copy is not consulted, so a working-tree flip cannot light the gate. Control: the first round's probe shape (committed patch, no --event) -> exit 0 with "LEVEL AXIS: NOT APPLICABLE — no pull_request to read a declaration from", which is the payload-less zero the first FAIL was about.
One wording note, not a finding: the body's "the red leg reproduces CI's refusal on the head CI refused" reads as if the round's red leg was 2653b827af; the round's own description (one detached tree, the committed level the only variable) means it was a throwaway commit of the flip. Both are valid legs of the same instrument, and the literal one is reproduced above.
4. Anything outside scope
Nothing. The round diff is the one file; the PR-wide set against 3ef96b4712 is the same six files as before. Check Changeset is success on both runs at 8c90c0b0f3 (state, not exit code); every other check is success or skipped except Lint & Repo Gates, in_progress at read time.
Must-fix before landing: none.
Cards, not blockers:
examples/app-todo/src/actions/task.handlers.ts:45-50— the handler's own[#7036]docblock still tells the pre-[17.0.0-rc.0] Action body ctx.api is bound to a context-less engine facade — every owner-scoped write dies FORBIDDEN while the audit line claims TRUSTED #3914 story ("stripped from the payload ... made this action refuse itself"). It is byte-identical at the merge base (:37-42there), untouched by this PR, and outside its diff; it is the sentence the page originally copied. Not this PR's introduction and not a blocker; a one-line follow-up under the example, or fold it in only if the round is already touching the file.- The two cards from the first review (behavioural pin for the five
deleteclaims; theid != nullskip ataction-execution.ts:1474) stand as filed; nothing here changes them.
Verdict: PASS
Generated by Claude Code
|
Seat status — PR #17608 is CLEARED TO LAND and is held only by a platform channel outage. 2026-09-11T05:11Z. Recorded here rather than in session memory: a wait is a state, and a state that lives only in a session does not exist. All four landing pre-checks are satisfied, on head
|
| # | check | reading |
|---|---|---|
| 1 | at-tier verdict for the current head | PASS at 8c90c0b0f3 — the PR's head.sha re-read at 05:06Z still names that commit. Tier verified from the transcript before adopting: "model" = 73 of 73 claude-fable-5-1, lit control "type":"assistant" = 61. Adopted verbatim above. |
| 2 | check-clause2-carriers --pair 17608 |
exit 0 — "the clause-② declaration is readable in the fixed spelling and both carriers agree" |
| 3 | check-governed-merges --test over the final 6-path diff |
exit 0, 0 of 6 on the register. Lit control: the same predicate with AGENTS.md appended → exit 3, GOVERNED. The instrument could have come back the other way. |
| 4 | every check green, newest-run-per-check-name | 34 distinct names: 30 success, 4 skipped, zero red, zero pending (05:08Z). Lint & Repo Gates had been in_progress at 04:59Z and completed success. Independently corroborated: mergeable_state moved unstable → clean. Check Changeset red was missed on this very PR. |
needs:contract-review was cleared on both carriers (card #15117 and this PR), one stroke each seconds apart, each read back: gate absent, nothing else added or lost.
What is blocking
Nothing about this PR. Draft→ready cannot be reached right now on any channel:
- MCP
update_pull_request—API rate limit already exceeded for user ID 324100929, on three attempts across five minutes. - REST
PATCH /pulls/17608 {"draft":false}— HTTP 200, response's owndraftfieldtrue, read-backdraft = True. Inert, exactly as filed in [finding] Platform reading: RESTPATCH /pulls/{n}with{"draft": false}answers HTTP 200 and silently does nothing — the channel table records the field as unsupported, but on documentation, and a 200 reads as success #17582 and re-measured today rather than recalled. POST /pulls/17608/ccr/ready_for_review— a route this seat learned today from the GraphQL 403's own text (see [finding] Platform reading: RESTPATCH /pulls/{n}with{"draft": false}answers HTTP 200 and silently does nothing — the channel table records the field as unsupported, but on documentation, and a 200 reads as success #17582) — HTTP 503"GitHub is temporarily unavailable. Retry shortly."on seven attempts across 05:09:01Z-05:11:22Z with backoff.
⛔ The seat will not merge by another route, will not bypass the queue, and will not take a green PR's readiness as licence to improvise. It retries.
One finding from the review that is NOT a blocker
examples/app-todo/src/actions/task.handlers.ts:44-50 still carries the pre-#3914 story this PR removed from content/docs/ui/actions.mdx — the same false "stripped from the payload … made this action refuse itself" sentence the page copied from it. Filed as #17629 with the true mechanism and its sites.
The adopted verdict describes that site as "outside its diff". Correcting the record: the lines are untouched, but the file is one of this PR's six (+32/-24). So the review's conditional disposition — "fold it in only if the round is already touching the file" — was in fact available. The seat chose the other branch it offered, deliberately: the verdict is PASS with no must-fix, ⛔ a seat does not manufacture a fourth must-fix on a PR reviewed three times, and this PR declares Clause-②: yes, so any head move re-hangs the gate and buys a fifth review for six lines of prose. #17629 lands separately and serialised after this PR — same file, hard same-file serialisation.
Next action on this PR is mechanical and unchanged: retry the ready flip, then enable_pr_auto_merge, then confirm the queue ref pr-17608-<sha>.
Generated by Claude Code
…ask — the write is NOT stripped, it lands The `completeTask` docblock described a read-only strip that does not run on this path. A handler's `ctx.engine` is bound through `buildActionExecutionContext`, which forces `isSystem: true` (#3914), and the engine's static read-only strip sits inside `if (!opCtx.context?.isSystem)` (`packages/objectql/src/engine.ts`), so on a handler's write it never runs. Consequences the old text had backwards: nothing is stripped, so the write lands; `completed_date_required` never refuses it (it sees a value); and the hook's stamp goes through because the strip never ran, not because the strip made an exception for it. On a write that is not a transition into `completed`, `task.hook.ts` does not stamp at all, so a handler-supplied `completed_date` silently replaces the true completion timestamp. Net: a server-owned field is MORE dangerous in a handler than in a form PUT, not less. The replacement says that, in the example's own voice, matching the corrected `content/docs/ui/actions.mdx` that PR #17608 landed. `status`-only remains the prescription; only the reason changes. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
…of silently skipping it (objectstack-ai#17620) (objectstack-ai#17802) Fixes objectstack-ai#17620 ## 1. The history read — run FIRST, and it clears the fork Triage ruled REMOVE on a **stated premise** — that the guard was never a recorded decision — and told the claimant to verify that premise before writing any diff, with an explicit fork: deliberate-with-a-stated-reason means stop, no diff, decision box. The verification is below. **The premise holds, so the fork does not trigger.** The clone this started in was **SHALLOW** (`git rev-parse --is-shallow-repository` = `true`; 3,265 commits; the oldest commit touching `action-execution.ts` inside that window dated 2026-09-08). A pickaxe answered from truncated history would have attributed the guard to the 2026-07-27 extraction commit — confidently, at exit 0. The clone was unshallowed first (`git fetch --unshallow`: 13,713 commits, `--is-shallow-repository` = `false`), and every reading below is taken on that full history. ### Where it came from **Introduced:** `7d7fee71cc24daf9857543ed0375750ff21c0dc2` — 2026-06-25, os-zhuang, `feat(mcp): native business-action execution (list_actions / run_action) (objectstack-ai#2307)`. Found with `git log --all -S 'if (id != null) await ql.delete'`, which returns exactly two commits: this one and the relocation below. **Control on the same corpus:** `git log --all -S 'buildActionEngineFacade'` returns 13 commits — so the two-commit answer is a reading, not a broken search. **What that commit says about the guard: nothing.** It is an 11-file, 1,193-insertion feature commit. Its message describes the MCP action-execution surface — `registerActionTools`, the `McpActionBridge` seam, `IDataEngine.executeAction` dispatch, the ADR-0066 D4 permission gate — and never mentions ids, nullish values, skipping, or tolerance of any kind. The guard arrives as one unremarked line inside the facade the commit introduces. **The one comment beside it explains the OTHER tolerance** — the array one, the one PR objectstack-ai#17608 made declared. Quoted from that commit's own diff: ```ts + // Tolerant of both the single-id and array conventions handler suites + // use (CRM handlers pass one id; todo handlers pass an id array). + async delete(object: string, idOrIds: string | string[]): Promise<void> { + const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds]; + for (const id of ids) { + if (id != null) await ql.delete(object, { where: { id } }); + } + }, ``` That comment is about `Array.isArray(idOrIds)`. It gives a reason for accepting two ARGUMENT SHAPES. It gives no reason for skipping a nullish ELEMENT, and nothing else in the commit does either. The distinction matters here more than usual: the stated reason attaches to the tolerance that has since been **declared**, and the undeclared one it sits next to inherited none of it. **Relocated, not decided:** `3216344dfa1377109ed8d70dd3181a444157cc9b` — 2026-07-27, `feat(runtime): extract action-execution subsystem — ADR-0076 D11 step ③ PR-8 (objectstack-ai#2462)`. A mechanical extraction of 16 helpers; occurrence counts either side of it are **1 before** (`http-dispatcher.ts`) and **1 after** (`action-execution.ts`), so it moved the line rather than duplicating or authoring it. Its message describes the extraction and says nothing about the guard. **Untouched by the card that made its neighbour declared:** `ea2940d1` (objectstack-ai#17608) edited only the comment two lines above. The guard line itself is byte-identical across it. ### Four further searches, each reported with what makes its result a reading | search, full history, all refs | result | why that result is a measurement | |---|---|---| | `git log --grep='nullish' -i` | 15 commits, none about this guard | the search returns a populated set, so the absence of this guard from it is a reading | | `git log --grep='null id' -i` | 1 commit, about seed-time `os.user.id` resolution | as above | | `git log --grep='skip.*null' -iE` | 8 commits, none about this guard | lit control on the same corpus: `--grep='ActionEngineFacade' -i` returns 3 | | `git grep 'ActionEngineFacade' -- docs/adr` | ADR-0096 only, and only about identity/context binding (`buildActionExecutionContext`) — nothing about ids | the grep finds the symbol, so the absence of an id ruling is a reading | And one absence measured in the working tree rather than in history: **no test anywhere pins the skip.** The only pin on this value says the opposite — `packages/spec/src/ui/action-params.test.ts` carries `// @ts-expect-error — "delete nothing" is the EMPTY ARRAY, never a null id`. ⇒ **No commit message, no code comment, no ADR and no test states a reason for the nullish skip.** The premise triage named is confirmed rather than assumed, the fork stays shut, and the ruled direction is what this PR implements. ## 2. The diff `packages/runtime/src/action-execution.ts` — located from the **symbol**, `buildActionEngineFacade`, not from the card's line number. As it happens `:1474` was still the guard when this claim opened, and the report says so rather than leaving it implied. The loop's `if (id != null)` is gone. Every id now reaches `ql.delete(object, { where: { id }, context })` as written. **Why removing it is enough to make the value loud, and why that is not an accident of some particular engine.** `ObjectQL.delete` dispatches on one question, and the answer is a shared, gated predicate rather than a local `if`: `resolveEngineDeleteDispatch` in `@objectstack/metadata-core` routes `by-id` only for a **truthy scalar** `where.id`, falls to `multi` only when `options.multi` is truthy, and otherwise **rejects**. The facade sets no `multi`, so a nullish id lands on the reject arm and the call throws `ENGINE_DELETE_REJECT_MESSAGE` — `Delete requires an ID or options.multi=true`. There is no path on which the nullish id could instead widen into a predicate delete: `multi` is absent, so the unscoped-multi arm is unreachable from here. No new refusal vocabulary was invented for this. Adding a facade-level ADR-0112 code would have been a new declaration, which is the half of this card that is explicitly above a seat. ## 3. Acceptance — driven readings, red before green Both runs are the same command on the same tree, with only the guard between them. **Before (guard present), `src/action-engine-facade-nullish-id.test.ts`: 3 failed | 3 passed.** ``` × refuses a nullish ELEMENT of the array form instead of skipping it AssertionError: expected undefined to be an instance of Error × refuses a nullish SINGLE id (the non-array spelling) the same way AssertionError: expected undefined to be an instance of Error × stops AT the nullish element — ids before it are deleted, ids after it untouched TypeError: Cannot read properties of undefined (reading 'message') ``` `expected undefined to be an instance of Error` IS the defect, stated by the instrument: the call resolved, carrying nothing, having deleted nothing. **After (guard removed): 6 passed (6).** The three that were red are the refusal readings; the three that were green throughout are the controls, and they are the half that proves the removal took nothing else with it: - a well-formed single id still deletes, with the elevated caller envelope still on the call; - the **declared array form** still issues one `ql.delete` per id, in order — `['case_1','case_2','case_3']` arrives as exactly that; - an **empty array** still deletes nothing and resolves, which is the member doc's own sentence. Those three passing BEFORE the fix is what makes them controls rather than decoration: they were already true, and the diff had to leave them true. **The refusal is pinned by name, never by `toThrow()`.** The assertion compares `err.message` **exactly** against the imported `ENGINE_DELETE_REJECT_MESSAGE`, and asserts `err instanceof Error` beside it. A bare `toThrow()` would stay green against any unnamed `Error` — including whatever an unfixed arm might throw for an unrelated reason — which is precisely the shape this card is about. This particular refusal is a plain `Error` carrying no ADR-0112 `code`/`status`, so the exported constant is the whole of its named surface and the pin says so in the test's own docblock. **The double is bound to the producer, not to a copy of it.** The fake engine's `delete` opens with `assertEngineDeleteDispatch(options)` — the same predicate `ObjectQL.delete` dispatches on. A hand-rolled id check in the double would have let this file stay green against a facade that still swallowed the value, which is the `check:engine-double-contract` failure mode exactly. The new double is recorded in `scripts/engine-double-contract.pinned.json` (regenerated with `--write`: **1 added or grown, 0 lost**). ## 4. Verification — every reading below taken at `3d2026622`, the final commit | what | result | |---|---| | `pnpm --filter '@objectstack/runtime^...' build` (dependency closure) | exit 0 | | `vitest run --project local src/action-engine-facade-nullish-id.test.ts` | **before: 3 failed / 3 passed** · **after: 6 passed (6)** | | `pnpm --filter @objectstack/runtime test` (whole package) | exit 0 — **260 files / 3,619 tests passed** | | `pnpm --filter @objectstack/runtime typecheck` | exit 0 — `tsc --noEmit` clean; test layer compiles, debt ledger unmoved at 27 files / 191 errors / 69 pinned signatures | | `pnpm lint` (repo-wide, `eslint . --no-inline-config`) | exit 0 | | derived gate families — `scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` | **68 commands, all exit 0** | | `scripts/pm/dispatch-gates.mjs --ran <record>` reconciliation | exit 0 — *68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN* | | `pnpm check:engine-double-contract` | exit 0 — *862 pinned*, after `--write` recorded **1 added or grown, 0 lost** | | `scripts/pm/check-clause2-carriers.mjs --pair 17802` | exit 0 — declaration readable, both carriers agree, no widening tell | Two of the 68 first answered **exit 3** — `check:dual-build-cjs-loads` and `check:type-check-debt`, both `PREREQUISITE NOT MET`, which is those gates' own distinct code for *nothing was measured* and is neither a pass nor a finding. Both read built output that a fresh worktree does not have. After `turbo run build --filter='./packages/*' --filter='./packages/*/*'` (72/72 tasks successful) both were re-run and both exit 0; that is the number in the table. What is **outside** this account, stated rather than implied: the 48 artifact-roster families, the 11 declared wide-population families, the 5 path-scheduled CI jobs and the always-runs tail are each outside the derived 68 and are CI's. The card's integration-shaped consumers — the dogfood suites that drive `examples/app-todo`'s `deleteCompletedTasks`, the one first-party caller of the array form — are in that set and are declared to CI here rather than claimed. Clause-②: no This PR puts no new key on any published payload, so the level axis stands down and the changeset is a `patch`. The reasoning is worth writing out because the direction is the interesting part: what changes is an **undeclared runtime tolerance being withdrawn**, which SHRINKS the accepted set toward the type that is already published — `string | string[]`, which has never admitted nullish. Nothing is added to a schema, an index, an export or an accepted-value set; no declaration moves in either direction. It is still an observable change, and for exactly one population: **untyped hosts**, who used to get a silent resolve and now get a named rejection. The changeset is written for them, with the one-line fix, and does not read as a correction of PR objectstack-ai#17608 — because it is not one. That PR's contract sentence is true: under the declared type no typed caller ever reached this line. ## Acceptance notes - `packages/spec` was **not touched**. The `never a null id` comment at `action-params.test.ts` is cited as evidence throughout and edited nowhere. - **No sibling sweep.** The history read surfaced no family — the pickaxe over the full history returns this one site, and the sweep the filing seat declined to run is still not run here. Nothing was filed, because nothing was found to file. - Noted, not filed: `buildActionEngineFacade`'s `insert` arm still reads `(res as any).id ?? (data as any).id`, and `find` still unwraps `rows.value`. Both are pre-existing, both are outside this card's one-guard fence, and neither is a defect anyone has demonstrated — an observation, and it stays one. Successor: whoever next opens this facade under a card of its own. --- Authored by the `domain:cli` execution seat on the dispatch at objectstack-ai#17620 (comment) — session https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c. Durable attribution is stated here in prose because a PR body's trailing footer block on this surface is the platform's to write, not the author's. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…d — the handler write is not stripped, it lands (objectstack-ai#17817) Fixes objectstack-ai#17629 Clause-②: no ## What this changes One docblock, on `completeTask` in `examples/app-todo/src/actions/task.handlers.ts`. Nothing else in the tree moves. The old text described a read-only strip that **does not run on this path**, and it did so in the direction that leaves a copier less safe. PR objectstack-ai#17608 removed the identical prose from `content/docs/ui/actions.mdx`; this is the other half — the example the docs page had copied it from, and the demonstrated origin of the falsehood that cost objectstack-ai#17608 three review rounds. ## The mechanism, re-measured in this worktree Re-derived here rather than inherited from the card, at `objectstack-ai/objectstack` `c9eb773ba` (the branch base): | site | reading | |:--|:--| | `packages/runtime/src/action-execution.ts:1420` | `buildActionExecutionContext(ec)` returns `{ ...base, isSystem: true }` — unconditional, no branch | | `packages/runtime/src/action-execution.ts:1458-1459` | `buildActionEngineFacade` calls it once and threads the result as `context` into every `ql.insert` / `update` / `delete` / `find` the handler's `ctx.engine` makes | | `packages/objectql/src/engine.ts:12227` | the static read-only strip (`stripReadonlyFields` + its `reportDroppedFields`) is inside `if (!opCtx.context?.isSystem)` — single-row update path | | `packages/objectql/src/engine.ts:12410` | the same gate on the multi-row update path | | `packages/objectql/src/engine.ts:11480` | the pre-hook withholding of caller read-only keys is gated identically | | `examples/app-todo/src/objects/task.hook.ts:78` | the stamp is conditional: `data.status === 'completed' && previous?.status !== 'completed'` — the **transition** only | ⇒ On a handler's write the strip never runs, so the write **lands**; and a completion write that is not a transition is never stamped, so a handler-supplied `completed_date` silently replaces the true one. A server-owned field is **more** dangerous in a handler than in a form PUT, not less. This agrees with the mechanism in the dispatch and with the corrected `content/docs/ui/actions.mdx` (read directly, lines 157-171). **Line-number drift, reported as asked:** the card cites `engine.ts:12213` and `:11466`; at `c9eb773ba` those gates sit at `:12227` and `:11480` (+14). `action-execution.ts:1420` is unmoved. Everything above was located from the symbol, not the line number. ## Acceptance 1 + 2 — whole-file grep, with a lit control Not only the three lines the card quotes. File read by state at `c9eb773ba` (pre) and at this PR's head (post). **Pass A — the retracted claim's own phrases.** | pattern (case-insensitive) | pre | post | |:--|--:|--:| | `stripped from the payload` | 1 | 0 | | `before the record is validated` | 1 | 0 | | `refuse itself` | 1 | 0 | | `the strip lets through` | 1 | 0 | | `lets? through` | 1 | 0 | | `completed_date_required` | 1 | 0 | | **union, distinct lines** | **5** (lines 45, 46, 47, 48, 50) | **0** | **Pass B — bare mechanism tokens** (`strip|read-?only|server-owned|refuse|elevated|isSystem`), a deliberate superset, because the *true* sentence necessarily reuses the same vocabulary: **7** matching lines post-change, all of them lines 48-63 — inside the new docblock, and each one a true statement. Reported rather than driven to zero: a zero here would only mean the replacement had stopped naming the mechanism. **Lit controls, same pass, same file:** | control | pre | post | why it is lit | |:--|--:|--:|:--| | `completed_date` | 3 (L45, L48, L70) | 3 (L48, L57, L87) | fires, and reaches **outside** the docblock (`cloneTask`'s destructure) — so the pass really covers all 127/144 lines | | `objectstack-ai#7036` | 2 (L45, L78) | 2 (L45, L95) | fires, and reaches `massCompleteTasks` | | `zzz_absent` (negative) | 0 | 0 | the matcher is not matching everything | ⇒ The card's quoted span **was** the whole survival in this file — but that is now a measurement, not the assumption the card warned against. **Also checked and deliberately left alone** (both accurate as written, neither states the retracted mechanism): - `examples/app-todo/src/objects/task.object.ts:74-80` — scopes its claim explicitly to *"a non-system caller's write"*, where the strip really does run. True. - `examples/app-todo/src/objects/task.hook.ts:24-48` — same, and its "a value a hook wrote is a platform value and survives" is the `hookWrittenKeys` / `suppliedValues` behaviour on the non-system path. True. - `task.handlers.ts:95` (`massCompleteTasks`) — cross-references `completeTask`'s `status`-only rule. The **prescription** survives the correction unchanged; only its reason changes, and the reason now lives one `{@link}` away. Left as-is, and the new docblock names bulk re-completion as the concrete non-transition case so the cross-reference lands somewhere useful. ## Acceptance 3 + 4 — the replacement Written for someone who will copy it: the hazard first, the concrete wrong line (`completed_date: new Date().toISOString()`) second, and the silence of the failure stated outright — no refusal, no dropped-field report, the write simply lands. It names symbols (`buildActionExecutionContext`, `isSystem`) rather than file line numbers, so it cannot rot the way the citations in this PR body can. ## Acceptance 5 — changeset: skipped, with a reading Not an assumption: - **`files[]` reading.** `examples/app-todo/package.json` declares `"private": true` and **no `files[]` at all**. Across the workspace: 70 publishable manifests, all 70 declaring `files[]`, and **zero** of their `files[]` entries mention `example`; no publishable package lives under `examples/`. - **Built-output grep with a positive control** — see the check block below; symbol count zero, control lit. - **Reachability grep.** Nothing copies this file into a shipped package. `git grep` for its unique symbols outside `examples/app-todo`: `exportTasksToCSV` 0 files, `massCompleteTasks` 0 files, `deleteCompletedTasks` 2 files — both of which only *name* it in prose (`packages/spec/src/ui/action-params.test.ts:501`, `packages/runtime/src/action-execution-destructive.test.ts:241`), neither a copy of the content. ⇒ Nothing published moves. A false statement about a mechanism becomes a true one, inside a private package. ## Acceptance 5, measured half — built-output grep with a positive control Run against the fully built tree at `9d7d2497e` (`pnpm turbo run build --filter='!@objectstack/docs'` — 73/73 tasks, exit 0). The scan surface is every path the 70 publishable manifests declare in `files[]`: **186 roots, 2967 shipped files, 247.8 MiB**. | needle | shipped hits | reading | |:--|--:|:--| | `exportTasksToCSV` (symbol unique to the changed file) | **0** | the file's content is in no shipped artifact | | `massCompleteTasks` (ditto) | **0** | ditto | | `what a handler names, lands` (the NEW sentence) | **0** | this PR ships no text | | `stripped from the payload` (the RETRACTED sentence) | 4 | **not this file** — `packages/spec/dist/analytics.zod-*.d.{ts,mts}` (the `onFieldsDropped` docblock from `packages/spec/src/contracts/data-engine.ts`, which correctly scopes its claim to *caller-supplied* fields) and `packages/plugins/plugin-auth/CHANGELOG.md` (a whitelist strip, a different mechanism) | | `buildActionExecutionContext` — **positive control** | **13** | matcher lit across the shipped set | | `ActionHandlerContext` — **positive control** | **5** | matcher lit | | `zzz_absent_token` — negative control | 0 | the matcher is not matching everything | ⇒ Symbols zero, controls lit. Nothing this diff touches reaches a consumer. `skip-changeset` applied additively (`POST objectstack-ai/issues/17817/labels`, HTTP 200) and read back on two legs — `GET /pulls?head=…` and `GET objectstack-ai/issues/17817/labels` both return `['size/s', 'skip-changeset']`, so nothing was stripped by a concurrent write. ## Verification Exit codes captured **before** any pipe. Gate families derived mechanically, not guessed: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` → **33 commands**, all run. | what | result | |:--|:--| | 33/33 derived gate commands | **exit 0** — includes `check:nul-bytes` (8463 text files, no raw control bytes), `check:system-context-census` (110 elevation read sites / 91 symbols — the census this PR's prose talks about), `check:comment-mask-adoption` + `-corpus`, `check:keyed-text-bounds`, `check:reference-carrier-shape` (6645 files), `check:examples-live-imports`, `check:published-files`, `check:dts-closure`, `check:lean-entry-closure`, `check:sourcemap-no-sources-content`, `check:dual-build-cjs-loads` (104 require entry points / 67 packages / 620 CJS files) | | `pnpm --filter @objectstack/example-todo typecheck` | **exit 0** — and the changed file is genuinely inside the program: `tsc --noEmit --listFiles` lists `examples/app-todo/src/actions/task.handlers.ts` (28 files from `app-todo/src`), so the green is a measurement, not an exclusion | | `pnpm --filter @objectstack/example-todo exec vitest run --maxWorkers=2` | **exit 0** — 4 files, **106 tests passed** | | `pnpm turbo run build --filter='@objectstack/example-todo^...'` | **exit 0** — 59/59 tasks (the affected package's dependency closure) | | `npx eslint . --no-inline-config --format json` | **exit 0** — the whole repo, **6645 files, 0 errors, 0 warnings**. Run in full rather than narrowed, so no narrowing claim is needed | | control-byte self-scan on the changed file | no match (`grep -naP` over the C0/DEL classes) | **First-pass NOT MEASURED, then resolved:** four gates (`check:dts-closure`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:sourcemap-no-sources-content`) exited **3** on the unbuilt tree — their own words, "PREREQUISITE NOT MET … NOT a pass and NOT a finding". They were re-run after the build and are among the 33 greens above. `check:dual-build-cjs-loads` needed the *whole* tree, not just this card's closure, and got it. **Genuinely NOT MEASURED here, by design, and left to CI:** the five path-scheduled CI jobs `dispatch-gates` names as having no local invocation (`Test Core` 6 shards, `Temporal Conformance`, `Dogfood Regression Gate`, `Dogfood Verify CLI`, `Build Core`), the 11 families whose declared population is too wide to place, and the 51 artifact-roster families whose silence is a fact about a list rather than about these paths. None of them is reported as green. Authored by Claude Code, session `session_01TSf4DV7ziu4V5j73e46b7c` (https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c). This body was edited through a bare REST `PATCH /pulls`, the one channel measured to append its own attribution block, so the footer below is the platform's and this line is the durable record. --- _Generated by [Claude Code](https://claude.ai/code)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #15117
needs:contract-reviewhangs on both carriers from the moment this PR exists, and nothing lands until an at-tier review returns.What the two ends said
packages/spec/src/ui/action-params.zod.ts:242delete(object: string, id: string)— one idpackages/runtime/src/action-execution.ts:1471async delete(object: string, idOrIds: string | string[]), iteratingql.deleteonce per id:1469examples/app-todo/src/actions/task.handlers.ts:18ActionContextcopy of the facade, reaching the array form at:87Measured on this branch's merge base
86c50528, not taken from the card — the card cites the runtime arm at:1174-1181, which had drifted by about three hundred lines. Lit control:ActionEngineFacadeoccurs 5 times inaction-params.zod.ts, so those readings are readings.What this PR does
delete(object: string, idOrIds: string | string[]), and the member's doc comment now states the contract rather than leaving a reader to infer it from a runtime comment two packages away: both spellings are contract; the single id is one row, the array is a set; the array form is a convenience over the same per-row path, never a bulk or atomic delete (no transaction around the set, a part-way failure leaves the earlier ids deleted); an empty array deletes nothing and resolves. The interface-level docblock, which saidfindwas the one member whose argument shape the signature never settled, now names both.ActionContextis gone, replaced byActionHandlerContextfrom@objectstack/spec/uion all 8 handlers. That also removes the already-driftedfindcopy at:27(the pre-ActionEngineFacade.find(object, query)takes a bare filter whileinsert/update/deletetake explicit shapes — the type says neither, and reading it wrong returns empty with no error #14175query: Record...spelling).ActionHandlerContextis used rather thanActionHandlerfor the annotation becauseActionHandlerreturnsunknown, which would erase the precise return types ofcloneTaskandexportTasksToCSV; the two are named together in the file's own comment.action-params.test.tsgains a#15117block modelled on its#14175neighbour: a type-levelAssertthat the slot is exactlystring | string[](read off the interface, never retyped), positive controls for both conventions and the empty set, and four@ts-expect-errorrefusals.Proven by behaviour, both directions
Every reading below is a real run in this worktree; exit codes were captured before any pipe.
The defect, before the widening — the example annotated with the published type, against the unwidened
dist:After —
pnpm --filter @objectstack/example-todo typecheckexits 0 with zeroerror TSlines. The single-id convention still type-checks (pinned as a positive control in the spec suite).Lit control — the same call site with an obviously-wrong argument, on the widened type, is still refused:
That control matters twice: it proves the green above is not a program that stopped checking the file. Restore was proven by state —
git hash-objectequal to the HEAD blob, whole-treegit status --porcelainempty.Ablation — the widening reverted in
src,packages/specrebuilt, andablation-dist-preflightused to prove the mutation reached the artifact the consumers resolve through (marker absent from all 216 built files) before any colour was believed:Line 485 is the
Assertpin, so the pin is capable of failing. The restore leg rebuilt and re-proved the marker present in 2 built files; the tree was verified clean by whole-treegit status --porcelainand by blob hash, and both channels went back to green.Verification
pnpm --filter @objectstack/spec typecheck— exit 0 (src, scripts and the test layer;check:test-typecheckOK).pnpm --filter @objectstack/spec exec vitest run --project local src/ui/action-params.test.ts— 32 passed.pnpm --filter @objectstack/runtime typecheck— exit 0; the fouraction-execution*suites — 119 passed.pnpm --filter @objectstack/example-todo typecheck— exit 0.pnpm --filter @objectstack/spec check:generated— all 15 generated artifacts up to date, after a real build (nothing to regenerate).scripts/pm/dispatch-gates.mjsderives for this diff were run, all green — 84 before the review, and 106 after it re-derived withcontent/docs/**in the change set;--ranreconciles 106 derived / 106 run / 0 unrun oncc5b7925cb. Across both rounds nine arrived first as prerequisite refusals — an unbuiltdist, orcheck:type-check-debtOOM-ing under a tighterNODE_OPTIONSthan its own CI-shaped 6144 MB ceiling — each of which says in its own words that nothing was measured. Every one was satisfied and re-run; ⛔ not one was reported as a pass, and ⛔ not one as a finding. The single genuine local finding was mine:check-spec-docblock-symbol-anchorsrefused the:1471line anchor I had written, repaired to a symbol anchor.pnpm lint— the repo-wideeslint . --no-inline-configpass, not a narrowed subset — exit 0, re-run at each head; the current head is8c90c0b0f3.origin/mainwas merged in withscripts/pm/os-regen-merge.sh; the spec generated-artifact gates were re-run green on the merged tree.Changeset
minoron@objectstack/spec. Corrected under contract review — this PR first shippedpatch, and that was wrong. The written rule (.github/workflows/pr-automation.yml, maintainer ruling 2026-09-04 batch #35) is that a purely additive widening of a published package's public surface takes at leastminor, and a commit type may raise a bump but never lower it below what the act requires. This PR's ownClause-②: yesline says the change widens the accept set, in the same words. Thefindprecedent the first version leaned on does not reach: #14175 was a narrowing, it landed the day the rule was ruled, and the rule explicitly disclaims pre-rulepatchprecedents. "Nothing is removed and nothing narrows" remains true in the changeset body — it argues the change is not breaking, which nobody disputed.⭐ How the wrong level survived this PR's own verification, recorded because the shape matters more than the fix. The first report cited
check-changeset-no-major --base origin/main :: exit 0as evidence the level was fine. That gate reads the clause-② declaration from the event payload and nothing else, so a local run with no--eventcannot exercise the LEVEL axis at all: the exit 0 said the script ran, not that the level was graded. CI, which had the payload, had already refused 86 seconds earlier. A gate that exits 0 is not a reading unless the instrument could have come back the other way.⛔ And the first correction of that paragraph was itself wrong, so it is corrected here rather than quietly dropped. It said "the level question here has exactly one instrument — CI with the payload". That is false by the gate's own header:
scripts/check-changeset-no-major.mjsdocuments--base SHA --event event.jsonas the level axis driven offline — labels and body only, no token, no network — and I authored this body and knew the labels, so writing that payload was always within reach. Declining to re-run the instrument that could not answer was right; concluding that no instrument could was not. The response to "your probe could not have come back the other way" is to pick up one that can, not to stop measuring.So it is picked up. One detached tree, one payload, one base, the committed level the only variable — the gate reads the changeset out of git, not the working tree, which is why a working-tree flip measures nothing (my first two attempts at this pair were exactly that mistake, and both came back green):
The red leg reproduces CI's refusal on the head CI refused. The shipped head reads exit 0 on the same axis. That is a reading: the instrument came back the other way when the level was wrong.
examples/app-todois private andpackages/runtime's change is a comment, so neither is in the changeset.Docs — the flagged page, disposed of
The repo's Docs Drift Check named
content/docs/ui/actions.mdxon this PR (via ActionEngineFacade). It is owed, and it is fixed here rather than dismissed:src/actions/task.handlers.ts, still annotatedctx: ActionContext— a type that file no longer declares after this PR. It now imports and annotates the publishedActionHandlerContext, with a sentence on why a copy of the context drifts silently, and why a file of function declarations annotates the context type rather thanActionHandler.completed_date. Removing it is right; the reason this PR first gave for removing it was false, and is corrected. The first version said the write is stripped and made the action refuse itself againstcompleted_date_required. It is not, and it does not. A handler'sctx.engineruns elevated —buildActionExecutionContextreturns the caller's envelope plusisSystem: true— and the read-only strip is gated on!opCtx.context?.isSystem(packages/objectql/src/engine.ts), so nothing is stripped on that path at all. The refusal does not exist for an ordinary caller either: the hook's stamp is unconditional on the transition precisely so a caller-supplied value is overwritten and survives the strip, and a live test asserts it — "a caller that still sendscompleted_dateis not punished for it — the hook value wins". TheREJECTEDrow whose mechanism I described is labelled "Measured before the fix". The true reason is close to the inverse, and is what the page now says: the write is not stripped, it lands, and on a write that is not a completion transition the hook does not stamp, so the handler's "now" silently replaces the real completion timestamp. I had cited three sites that exist; the conclusion depended on four others I had not opened. A citation that exists is not a citation that entails.find's convention in a Callout at the old:149-162; thedeleteconvention now sits directly beside it — both spellings contract, one delete per id in order, no transaction, an empty array resolving.content/blog/protocol-first-development.mdxalso shows the old single-id signature. It is a dated post, so it is not owed and is deliberately untouched.验收备注 (acceptance notes)
Observed, deliberately not filed and not fixed here:
check:spec-docblock-symbol-anchorscarries 7 day-one residual line-number anchors in spec docblocks, one of them thefindtwin of the citation this PR adds (:1183). Each already has a dated residual row and a prescribed repair in the gate's own output, so the gate is the carrier — no card is owed. This PR's own citation is a symbol anchor, not a line anchor.examples/app-todo/src/actions/register-handlers.ts:78hand-rolls the ObjectQL registration seam (registerAction, whose handler slot is(ctx: any)returningunknown) — a different surface fromActionEngineFacade, in a file this card does not name, and the(ctx: any)is exactly the untyped-bag shapeActionHandlerexists to replace. Not a defect, not a contract violation, not an authoring trap, so not filed; the natural home for it is a card on typing the registration seam, which does not exist today.examples/app-todo'stest/files cannot resolve their workspace deps until those packages are built. That is a prerequisite, not a defect, and it is why the first typecheck reading in this worktree carried a pile of TS2307 alongside the real TS2345.⛔ Draft on purpose: do not flip ready, enqueue, or arm auto-merge — the contract review returns first.
Generated by Claude Code