Skip to content

test(runtime): pin ActionEngineFacade.delete's partial-failure shape against a mid-array datastore rejection - #17925

Merged
claude[bot] merged 1 commit into
mainfrom
claude/issue-17619-facade-delete-midlist-rejection-pin
Sep 13, 2026
Merged

test(runtime): pin ActionEngineFacade.delete's partial-failure shape against a mid-array datastore rejection#17925
claude[bot] merged 1 commit into
mainfrom
claude/issue-17619-facade-delete-midlist-rejection-pin

Conversation

@claude

@claude claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #17619

Tests only. No behaviour change, no contract-text change, nothing touched in ActionEngineFacade.delete itself.

What this adds

One new file, packages/runtime/src/action-engine-facade-delete-partial-failure.test.ts, pinning the clause the card called "the only one whose violation is silent to the caller" — the declared partial-failure shape of ActionEngineFacade.delete's array form:

There is no transaction around the set: a failure part-way through leaves the ids before it deleted and the ids after it untouched, and the rejection a caller sees is the one that stopped it.

Plus the mechanical ledger row the gate asked for (see Gates below).

The pin's exact assertions

Both cases drive buildActionEngineFacade against an engine double whose delete opens with the producer's own assertEngineDeleteDispatch(options) — so dispatch ACCEPTS every id in the array — and then refuses a well-formed mid-array id for a datastore reason (DatastoreRefusal, carrying an ADR-0112 code/status envelope). The double records every ATTEMPT as well as every deletion.

Case 1 — delete('crm_case', ['case_1', 'case_2', 'case_3']), case_2 refused:

# assertion clause it pins
3 expect(err).toBe(refusal) the rejection propagates AS-IS — object identity, so a wrapper or a re-thrown copy fails
3 expect(err.code).toBe('PERMISSION_DENIED') · expect(err.status).toBe(403) the envelope a handler catches on survives
1 expect(ids(ql.deleted)).toEqual(['case_1']) ids BEFORE it are deleted
2 expect(ids(ql.attempted)).toEqual(['case_1', 'case_2']) ids AFTER it are NEVER CALLED

Case 2 — case_2 and case_3 both refused: expect(err).toBe(first), expect(err).not.toBe(second), same two ledgers. That is "the rejection a caller sees is the one that stopped it" read as FIRST, not merely "some rejection".

Assertion 2 is the one the sibling file structurally cannot make: its double records only successful deletions, so "untouched" can only be inferred from an absence. Here it is read off the calls the arm actually made.

Premise correction — read this before the acceptance

The dispatch order and triage ruling 5650883389 both state that the sibling pin's rejection comes from "#17620's nullish pre-guard, which refuses the element before ql.delete is called at all". Measured at origin/main 5741ff10c, that is false, and the card is still right.

buildActionEngineFacade's delete arm at head carries no pre-guard at all:

async delete(object: string, idOrIds: string | string[]): Promise.of.void {
    const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
    for (const id of ids) {
        await ql.delete(object, { where: { id }, context });
    }
},

#17620 REMOVED the if (id != null) that used to open that loop — the file's own comment says so at action-execution.ts:1473, and action-engine-facade-nullish-id.test.ts's docblock says it again: "Every id now reaches ql.delete(object, { where: { id }, context }) as written, and the engine's own dispatch predicate answers that call". So the sibling pin ALREADY enters the sequential await loop and its rejection ALREADY comes from ql.delete.

The residue survives for a narrower reason, and it is the reason that decides which ablation proves anything: the only rejection CAUSE pinned anywhere is ENGINE_DELETE_REJECT_MESSAGE, a malformed-argument refusal raised before the driver is asked to do anything. An arm that caught rejections, re-threw the dispatch refusal and swallowed the rest would keep every sibling case green while continuing past a permission denial to delete rows the caller was told were untouched. That is the shape this file pins against, and it is the realistic refactor — discriminating on the error's type is a thing this repo's own rules elsewhere ask authors to do.

Consequence for acceptance item 2: the ablation it names LITERALLY (a plain try/catch that continues) does NOT produce the stated contrast, because it reds the sibling file too. Measured, both ways, below. The kind-discriminating variant does produce it exactly.

Ablation — two legs, both executed, mutation proved on disk, restore proved by state

Driver: a script with trap restore EXIT INT TERM, absolute paths, restore spelled git checkout HEAD -- PATH (never the bare form, which would restore the mutation out of the index). Both suites run in ONE vitest invocation per leg, so "new pin red" and "sibling green" are readings of the same on-disk state. The fix was committed FIRST (7ec6d1eb2), so every restore has a real commit to restore from.

Subject resolution: the pin imports its subject as ./action-execution.js — a RELATIVE, same-package import, so vitest reads src/, not dist/. No dist preflight applies; the mutation reaching the subject is proved directly by the verdict flipping.

Leg A — error-KIND-discriminating swallow (rethrow the dispatch refusal, continue past everything else)

BASELINE  HEAD:packages/runtime/src/action-execution.ts = 1bd873a79ab7d59aba2d27153ee4df35a3cf231f (worktree identical)
python: one replacement written
ON-DISK   injected 'ABLATION_A_KIND_DISCRIMINATING_SWALLOW' x2 · replaced bare-await line x0 · blob 113f5a70ac06d5e82b7b45f1a2c059cd5d21006c
RUN_EXIT=1
 Test Files  1 failed | 1 passed (2)
      Tests  2 failed | 6 passed (8)
 ✓ src/action-engine-facade-nullish-id.test.ts > refuses a nullish ELEMENT of the array form instead of skipping it
 ✓ src/action-engine-facade-nullish-id.test.ts > refuses a nullish SINGLE id (the non-array spelling) the same way
 ✓ src/action-engine-facade-nullish-id.test.ts > stops AT the nullish element — ids before it are deleted, ids after it untouched
 ✓ src/action-engine-facade-nullish-id.test.ts > controls: a well-formed single id still deletes
 ✓ src/action-engine-facade-nullish-id.test.ts > controls: the declared ARRAY form still deletes every id, in order, one call each
 ✓ src/action-engine-facade-nullish-id.test.ts > controls: an empty array still deletes nothing and resolves
 × src/action-engine-facade-delete-partial-failure.test.ts > stops at the refused id — earlier ids deleted, later ids NEVER CALLED, rejection as-is
 × src/action-engine-facade-delete-partial-failure.test.ts > delivers the FIRST rejection — a later refusal is never reached, let alone reported
   AssertionError: expected undefined to be DatastoreRefusal: row case_2 is not delet… // Object.is equality
RESTORE   blob 1bd873a79ab7d59aba2d27153ee4df35a3cf231f vs HEAD blob 1bd873a79ab7d59aba2d27153ee4df35a3cf231f
RESTORE   git diff HEAD -- packages/runtime/src/action-execution.ts => ''  (empty = restored)
RESTORE   git status --porcelain -- packages/runtime/src/action-execution.ts => ''  (empty = index and tree both back)
RESTORE   PROVED by state (blob equality + empty diff + empty porcelain).

This is the card's proof: the new pin goes red, all six sibling cases stay green, in the same run, on the same bytes. The new coverage is additive, not a restatement.

Leg B — blanket try/catch-continue (acceptance item 2's literal wording)

ON-DISK   injected 'ABLATION_B_BLANKET_SWALLOW' x2 · replaced bare-await line x0 · blob d20dd41fa6442a7d4b855b77dcc89a44534b473f
RUN_EXIT=1
 Test Files  2 failed (2)
      Tests  5 failed | 3 passed (8)
 × action-engine-facade-delete-partial-failure.test.ts  (2 of 2)
 × action-engine-facade-nullish-id.test.ts  (3 of 6 — the three refusal cases)
 ✓ action-engine-facade-nullish-id.test.ts  (3 controls)
RESTORE   blob 1bd873a79ab7d59aba2d27153ee4df35a3cf231f vs HEAD blob 1bd873a79ab7d59aba2d27153ee4df35a3cf231f
RESTORE   PROVED by state (blob equality + empty diff + empty porcelain).

Reported as measured rather than omitted: under the literal wording BOTH files red, so this leg cannot demonstrate that the new pin adds coverage. It is the evidence for the premise correction above, and the reason leg A is the ablation of record.

An actual ql.transaction(...) wrapper is not expressible against these doubles at all — neither declares a transaction member, so the arm would die of a TypeError in every case in both files, which is a broken harness and not a contrast.

Verification

run verdict
pnpm --filter @objectstack/runtime test (full local project) exit 0 — 262 files / 3633 tests passed
pnpm --filter @objectstack/runtime typecheck exit 0 — check:test-typecheck: OK · 27 files / 191 errors / 69 pinned signatures, ledger unmoved
pnpm lint (eslint . --no-inline-config, WHOLE repo, no narrowing) exit 0 — 1m36s, zero findings
dependency-closure build, then turbo run build over all packages exit 0 — 72 tasks successful

Every heavy run went through scripts/pm/os-verify-lock.sh (slot issue-17619-dev); the queue was empty throughout, so no wait was contended with the sibling dev on #12271.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 59 commands from this diff. All 59 were run with $? captured BEFORE any pipe. Reconciliation with --ran:

Run reconciliation — 59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN.

57 exited 0 on the first pass. Two exited 3 — PREREQUISITE NOT MET, which is neither a pass nor a finding:

  • pnpm check:dual-build-cjs-loads — "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/. … Run pnpm build first. This is NOT a pass: nothing was measured."
  • pnpm check:type-check-debt — "--re-measure cannot run: 2 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk -- @objectstack/hono, @objectstack/runtime."

Both name the same missing thing: a built workspace. After pnpm exec turbo run build --concurrency=2 --filter='./packages/*' --filter='./packages/*/*' (72/72 successful), both were re-run and both exited 0check-type-check-coverage --re-measure: OK — 5 ledger entr(ies) re-measured in 83.0s, 55 raw tsc error(s) total, none above its recorded number. Both readings are reported here separately, as asked.

pnpm check:engine-double-contract is the one gate the diff actively moved. Before the ledger row it said, verbatim:

RETAINED [delete]: packages/runtime/src/action-engine-facade-delete-partial-failure.test.ts pins 1 engine double(s) that the pinned ledger does not record. New pinned coverage is GOOD and nothing is wrong with your change — the ledger just has to learn about it, or it never protects this file. Run node scripts/check-engine-double-contract.mjs --write and commit.

Done exactly that: 796 (file, verb) row(s), 1 added or grown, 0 lost — one row, no losses, no other file moved.

Changeset

skip-changeset, on the gate's own route 2. Check Changeset's failure message (.github/workflows/pr-automation.yml) offers two routes and marks this one PREFERRED, verbatim:

  1. It releases nothing (.github/, .claude/, skills/, docs/, content/,
    examples/, tests-only, and the like)
    -> apply the 'skip-changeset' label. <<< PREFERRED
    The label is a gate-level exemption. It produces NO input for
    changesets/action, so it cannot affect a release.

Measured rather than assumed, against @objectstack/runtime's declared files (dist, README.md, CHANGELOG.md) on a freshly built tree:

symbol hits under packages/runtime/dist
DatastoreRefusal (new, test-only) 0
buildActionEngineFacade (positive control) 2

And scripts/engine-double-contract.pinned.json appears in no package's shipped output at all. Nothing published moves, so there is no package to name.

Acceptance notes

  • noted, not filed: triage ruling 5650883389 and this order both attribute the sibling pin's rejection to a facade-level nullish pre-guard that does not exist at head. The ruling's INTENT holds and is delivered; only its stated mechanism is wrong, and the correction is recorded above rather than as a card — the seat that wrote the ruling is the one carrier that will read it, and no file or PR other than this one is affected. Successor: none.
  • noted, not filed: acceptance item 2's literally-named ablation (a plain transaction, or a plain try/catch that continues) cannot produce the contrast it asks for, for the same reason. Measured in leg B. Same carrier, same reason not to file.
  • Untouched, as required: the existing cases in action-engine-facade-nullish-id.test.ts, ActionEngineFacade.delete's behaviour and contract text, packages/spec/**, and update / find.

Draft, and left that way: flipping to ready and arming auto-merge are the dispatching seat's acts, after the contract review of record.


Generated by Claude Code

…against a datastore-side rejection

`ActionEngineFacade.delete`'s member doc declares that a failure part-way
through the array form leaves the ids before it deleted, the ids after it
untouched, and delivers the rejection that stopped it. The existing
`action-engine-facade-nullish-id.test.ts` reaches the sequential await loop,
but every rejection it pins is the dispatch predicate's malformed-argument
refusal. The causes the sentence exists for are the opposite kind: a
well-formed by-id delete the datastore refuses.

Adds a pin driving `buildActionEngineFacade` against an engine double whose
`delete` accepts dispatch (it calls the producer's own
`assertEngineDeleteDispatch`) and then refuses a well-formed mid-array id for a
datastore reason. The double records every ATTEMPT as well as every deletion,
so "the ids after it are untouched" is read off the calls the arm actually
made. The rejection is pinned by object identity, plus the code/status
envelope it carries.

No behaviour change: tests and the regenerated pinned ledger only.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7135cf057e90dbe6d9177aef17e525da7277f68bpackageMentionDocs.

@github-actions github-actions Bot added the tests label Sep 13, 2026
@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

Contract review — PR #17925 (card #17619)

Head reviewed: 7ec6d1eb2111202d3f4add28028a111dc20edd36. Two paths, +185/−0. Readings taken 2026-09-13T05:15–05:20Z against origin/main = 7135cf057e90dbe6d9177aef17e525da7277f68b.

⚠️ Independence: the implementer is a mode:subagent dev of this same PM session; ⛔ not an arm's-length second opinion.

① ⛔ FIRST: the mechanism in triage's ruling AND in this seat's order is FALSE. The dev measured it; this seat confirms it at source.

Triage's ruling 5650883389 says the sibling pin's rejection 「那是 #17620 给 facade 加的前置守卫:它在调用 ql.delete 之前就拒绝了那个元素」, and this seat's order 5651159089 relayed it and added its own gloss (「that is #17620's guard, which refuses the element before ql.delete is called at all」). Measured on origin/main:

The existing pin already enters the sequential await loop and its rejection already comes from ql.delete. ⭐ The dev reported this instead of quietly building to the wrong mechanism — which is the whole value of the report channel, and it is the second time tonight a delivery corrected an upstream statement rather than ratifying it.

② The residue is still real — for a NARROWER reason, and the narrower one is better

The only rejection cause pinned anywhere was ENGINE_DELETE_REJECT_MESSAGE: a malformed-argument refusal the dispatch predicate raises before the driver is asked to do anything. ⇒ an arm that re-threw that and swallowed everything else would keep all six sibling cases green while continuing past a permission denial. The card's ask — pin the partial-failure shape against a datastore rejection — is untouched by the mechanism error. ⇒ the ruling's intent holds and was delivered; only its stated mechanism was wrong.

③ ⛔ Acceptance item 2's LITERAL ablation is unachievable, and that is measured rather than argued

Item 2 names 「把循环包进事务(或 try/catch 续跑)⇒ 新 pin 变红;而 …nullish 用例仍然绿」. The dev ran both legs, each with on-disk mutation proof taken before any verdict and a restore proved by state (blob hash equal to HEAD's, git diff HEAD empty, git status --porcelain empty), and both suites in one vitest invocation so the two readings are of the same on-disk state:

leg ablation reading (the dev's, ⛔ not re-run here)
B plain blanket try/catch continue — item 2's literal wording Test Files 2 failed (2) / Tests 5 failed | 3 passed (8)reds the sibling file too ⇒ ⛔ cannot demonstrate added coverage
A error-kind-discriminating swallow both new cases red; all six nullish cases named green ⇒ the contrast the card exists to prove

⭐ And a literal ql.transaction wrapper is not expressible against these doubles at all — neither declares a transaction member, so that arm dies of a TypeError in every case in both files: a broken harness, ⛔ not a contrast. ⇒ the acceptance's letter was impossible and its intent was met, with leg B kept as the evidence rather than dropped.

④ The pin is STRONGER than the acceptance asked for — verified at source

  • 「later ids NEVER CALLED」 is read off an attempt ledger (ids(ql.attempted)).toEqual(['case_1','case_2'])) that the sibling double does not have — ⛔ not inferred from what was deleted.
  • 「propagates as-is」 is asserted by object identity: expect(err).toBe(refusal), plus .code === 'PERMISSION_DENIED' and .status === 403. ⇒ a wrapper cannot pass it; a message match would have let one through.
  • 「the FIRST rejection」 is expect(err).toBe(first) and expect(err).not.toBe(second).

⑤ Red lines, measured on the delivered head

packages/spec/ 0 · docs/adr/ 0 · content/docs/releases/ 0 · CHANGELOG.md 0. The existing action-engine-facade-nullish-id.test.ts is untouched (0 occurrences in the diff) — item 3 held. No behaviour or contract text moved — item 4 held. Nothing extended to update/find — item 5 held.

scripts/engine-double-contract.pinned.json is a clean ADDITION: one new row (file = the new test, verb: delete, pinned: 1) beside the existing entry; no existing number moves, nothing is relaxed. It was produced by the gate's own --write remedy after the gate went red by design on new pinned coverage — ⇒ a ratchet moving up, ⛔ never a weakening.

⑥ Changeset — none, via skip-changeset, and the route was measured

The gate's own words, route 2: 「It releases nothing (… tests-only, and the like) → apply the skip-changeset label. PREFERRED.」 Label present and read back (size/m, tests, skip-changeset). ⭐ Corroborated by two independent instruments: the docs-drift check reported 0 changed packages / 「no opinion」, and the dev measured DatastoreRefusal0 hits under packages/runtime/dist on a freshly built tree with buildActionEngineFacade2 as the positive control. ⇒ nothing ships.

⑦ Boundary flags

a. The mechanism correction is carried, ⛔ not filed. Its only reader is the seat that wrote the error — triage for the ruling, this seat for the order — and no file or PR other than this one is affected. ⇒ recorded here and on the card; this seat carries it to triage rather than opening a card for prose on a settled matter.
b. Two gates exited 3 = PREREQUISITE NOT MET on a fresh worktree (check:dual-build-cjs-loads, check:type-check-debt), both naming the missing build; re-run after a 72/72 build, both 0. ⛔ Neither reading is reported as the other. 59 families derived, 59 run, 0 NOT-MEASURED, 0 UNRUN.
c. ③ is NOT yet met: at 05:20Z, 32 names — 19 success / 5 skipped / 8 in progress / zero failures. ⛔ The flip waits.

Independence pair

Implemented-by: claude/issue-17619-facade-delete-midlist-rejection-pin (mode:subagent)
Reviewed-by: os-sales — domain:cli execution seat, issue #6024, session_01TSf4DV7ziu4V5j73e46b7c
Independence: SELF-REVIEW — the implementer is a subagent of the reviewing seat's own session

Tier: default judgment — 「余席条款②复核 = 默认判断档自审加门禁」.

Verdict

PASS. The delivery met the acceptance's intent on every item and falsified its letter on item 2 with evidence rather than quietly substituting a different ablation. ⛔ No carrier to clear; --pair 17925 exit 0. ⇒ the landing pre-check reduces to ③, which this head does not yet meet.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

1 participant