Repository navigation
fix(security,metadata-protocol): batchData's delete arm stops reporting a deletion it did not perform - #19451
Conversation
… a deletion it did not perform The third by-id delete door. Written before the fix so the red reading is taken against the unfixed base. Claude-Session: https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE Co-authored-by: Claude <noreply@anthropic.com>
…ng a deletion it did not perform The third and last by-id delete door. `success` was a literal for every engine result that was not the driver contract's `false`, so a row that MATCHED and was deliberately NOT removed — a package-declared `sys_permission_set`'s ADR-0005 reset is the shipped shape — was counted in `succeeded` and reported as deleted. `IDataEngine.delete` declares `Promise<boolean | number>` and `isDeleteResultShape` admits the number arm, so a positive zero is the one value that means "the row is still there". Only that value moves; the `false` arm keeps its 404 and an off-contract `undefined` keeps its #4435 reading, and the row gets no `errors[]` entry. Claude-Session: https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE Co-authored-by: Claude <noreply@anthropic.com>
…nal-success Claude-Session: https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check1 anchor(s) derived from 1 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 — 11 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 c551e001828cbab66f077e59e64766304687ceee && git checkout c551e001828cbab66f077e59e64766304687ceee
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 57ceb9d6d2af35ebf83d0f8cc396cb612540af74 7b9c6376ccce46da8297213cf1cafaf7dbb958bd && git checkout -B drift-repro 57ceb9d6d2af35ebf83d0f8cc396cb612540af74 && git merge --no-ff 7b9c6376ccce46da8297213cf1cafaf7dbb958bd
node scripts/docs-audit/affected-docs.mjs --json 57ceb9d6d2af35ebf83d0f8cc396cb612540af74 |
Fixes #19433
Clause-②: no
What was wrong
POST /api/v1/data/{object}/batchwithoperation: "delete"reported a deletion it did notperform. This is the third and last of the three by-id delete doors to read the engine's
answer: the single-record
DELETE(PR #19411) anddeleteMany(PR #19432) already do, andthis branch is the one that fell between them with no card.
Each row pushed
success: trueas a literal, so every engine result that was not thedriver contract's
falsewas reported as a deletion. Thefalsearm — "no row matched" —has reported honestly since #4435 and #5088. The other ending had not: a row that matched
and was deliberately not removed was counted in
succeededand shipped an envelopebyte-identical to a batch in which every id really went.
sys_permission_setis the shipped shape of it. Deleting a package-declared set is anADR-0005 RESET — plugin-security's write-through tombstones the overlay and the record
re-projects to the declared body instead of vanishing — so the row matched, the write ran,
and the record is still there. On a security-configuration write the envelope told an
operator a permission set was gone while it was still being enforced.
Why this is a defect today — rested on the CONTRACT, not on a shipped handler
IDataEngine.deletedeclaresPromise[boolean | number](
packages/spec/src/contracts/data-engine.ts; square brackets here for the generic, thespelling this family's cards use) — the driver boolean for a by-id write, a COUNT of rows
removed otherwise.
isDeleteResultShapeinpackages/objectql/src/verb-hook-result-shape.tsis a pair of
typeoftests admittingbooleanandnumber, and the ADR-0112 hook gate inpackages/objectql/src/engine.tsuses it. So anafterDeletehandler — or a non-ObjectQLIDataEngine— may legally answer0today, and a numeric zero is the one value thatpositively means "the row is still there".
This PR does not rest on "a shipped handler already returns the 0". That claim was made
on #19412 and the seat could not verify it; the sibling fix stood because the contract
argument is independent, and this one stands the same way.
Located by symbol, because this family's line numbers have drifted four times
grep -n "throw recordNotFoundError" packages/metadata-protocol/src/protocol.tson thisbranch's base (
23f1de078):deleteData:11438runBatchDataLoopcase 'delete':12568runDeleteManyLoop:13120The file's own comment beside the
batchDatasite calls it "the OTHER by-id bulk delete, tenlines from it".
Measured on the unfixed base, before any edit
A fake engine per answer, through
batchDatawith one record:engine.deleteanswerstruesuccess: true,succeeded: 11success: true,succeeded: 10success: true,succeeded: 1,failed: 0success: false,succeeded: 0,failed: 1, noerrorsfalsesuccess: false+errors[0].code RECORD_NOT_FOUND,failed: 1undefinedsuccess: trueMixed batch
['a', 'kept', 'b']wherekeptanswers0, before: all threesuccess: true,succeeded: 3. Atomic with the same engine, before: committed,succeeded: 3,keptsilently still present.
The envelope measurement this card owes — what transfers, and what does not
batchData's envelope is its own, so each of these was read off this file rather thaninherited from
deleteMany. All three shapes turn out to agree, and the reason ismechanical: the two surfaces share the machinery.
deleteManyDatabatchDatasucceeded/failedpartition (#7539)?reconcileStoppedBatch;buildBatchDataResponsethen setssuccess: failed === 0,total: records.lengthatomicarm, aborting onfailed > 0?runAtomicBatchDatadelegates to the sharedrunAtomicBatch, whose abort condition isoutcome.failed > 0continueOnErrorstop apply?catch, and a surviving row throws nothingTwo things about
batchDatathat have no counterpart ondeleteMany, and are thereforepinned separately:
runBatchDataLoopis shared bycreate/update/upsert/delete, and eachcaseowns its ownsucceeded++. Only thedeletecase is touched; the sibling arms keeptheir counters exactly as they were.
returnRecords: falseis abatchData-only projection (buildDeleteManyResponsehasno such flag). It drops
dataand keepssuccess/index/errors, so the one keythis card moves is the key that survives it — pinned.
The fix
The per-row push in
case 'delete'reads the engine result instead of being a literal:Not
falsefor this case. That arm is spoken for by the 404 above, about a record thiscaller can still
GET; answering it here would trade one wrong answer for a louder one. Onlya POSITIVE zero is read as "not removed", and an off-contract
undefinedfrom a third-partydriver keeps its #4435 reading.
And the row gets no
errors[]entry. A surviving record is an OUTCOME, not a fault; thesingle-record door answers the same case with a bare
success: falseon a 200, and thisenvelope's two per-row codes (
ROLLED_BACK,NOT_ATTEMPTED) both describe a row that neverran. Minting a code for this ending is an
ERROR_CODE_LEDGERwidening inpackages/specandbelongs to the spec lane. That was ruled on #19412 and the ruling is carried here, not
re-opened.
HTTP status is untouched — the route hands the protocol result straight to
res.json, sothis stays a 200 whose body reports per row.
On the wire, per row
success: true, insucceededsuccess: true, insucceededsuccess: false, infailed, noerrors[]success: false,errors[0].code RECORD_NOT_FOUNDNOT_ATTEMPTED/ROLLED_BACKBecause the counters partition
results, a surviving row also makes the request-levelsuccessfalse, and anatomicbatch holding one now rolls back rather than committingunder a response that called every row deleted. A non-atomic batch is not stopped by it.
Evidence
All readings at
7b9c6376c(final commit,origin/mainmerged in), worktree clean, base23f1de078.The pin, and that it can fail.
protocol.bulk-record-not-found.test.tsgains a[#19433]block of five cases beside the existing[#5088]one: the NEGATIVE case (engineanswers
0, sosuccess: false,succeeded: 0,failed: 1, noerrorskey, and therecord is still in the store), a CONTROL leg (a positive count still reports a deletion —
without it the pin can pass vacuously), a mixed batch pinning both directions plus the
partition and the un-stopped run, the
atomicarm (the surviving row aborts and the earlierdelete is really undone), and
returnRecords: false. Run against the UNFIXED base first:4 failed / 18 passed. After the fix: 22 passed.
Reverse verification, both legs from the committed state, mutation proven on disk and
restored by blob hash (
scripts/ablation-replace.mjs, mutation and command in one process):success: true+succeeded++aaa92be21a62tod9c2974fa715const removed = deleted !== 0toconst removed = falseaaa92be21a62to39ef16f7b8eeBoth restored: blob equals
HEAD's (aaa92be21a62) andgit diff HEADis empty. The firstleg also proves the suite resolves
src/protocol.tsand notdist/—dist/held the FIXEDbytes at that moment (
success: removedoccurs twice indist/index.js) and the run stillwent red.
Unit + types.
pnpm --filter @objectstack/metadata-protocol test— 183 files(3 skipped) / 2621 passed (19 skipped), exit 0.
typecheckexit 0, andtsc --noEmit --listFilesnamesprotocol.bulk-record-not-found.test.tsonce (negativecontrol: a name not in the program is listed 0 times), so the pin is inside the program the
script advertises. No public export moved, so no consumer package owes a test here.
Gates.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 61 families from this diff; all 61 ran, all exit 0, reconciled with
--rancarrying each exit code — "61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN". Re-derived after
git fetch origin mainand again after merging it: identical list, so the newcheck-dts-referencesfamily that landed onmainmeanwhile is not one this diff owes.Three families (
check:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt) exited 3PREREQUISITE NOT METon the first sweep, were given therepo-wide build CI gives them, and then exited 0; the exit-3 runs are recorded as not
measured rather than as failures. Repo-wide
pnpm lintran unnarrowed(
eslint . --no-inline-config): exit 0, at this same head.Changeset — measured, not assumed.
@objectstack/metadata-protocolisprivate: falsewith
files: ["dist", "README.md", "CHANGELOG.md"], and the edited symbol ships:batchDataoccurs 25 times in the built
dist, and the moved expressionsuccess: removedoccurs inboth
dist/index.jsanddist/index.cjs, against a negative control symbol that occurs 0times. So a changeset is owed and
skip-changesetdoes not apply.patch: a releasedpackage's bug fix whose published accept set does not move — no schema key added or removed,
no closed-set member, no new published export, no registry entry, no request shape touched.
What changes is the VALUE of an existing key, pulled back to what
BatchOperationResultSchema.successalready declares ("Whether this record was processedsuccessfully"), on a schema whose
errorswas already optional. HenceClause-②: no,measured against this diff rather than copied from #19412's verdict.
Acceptance notes
Noticed while measuring, deliberately not carried here:
fail.
reconcileStoppedBatchandbuildRolledBackBatchResponseboth locate the causewith
findIndex(r => !r.success), which since PR fix(security,metadata-protocol):deleteMany's per-rowsuccessstops reporting a deletion it did not perform #19432 can land on a SURVIVING row — anon-success that carries no error. Measured on this branch: a non-atomic
['survivor', 'missing', 'other']delete batch answers row 2 withNOT_ATTEMPTED: "record 0 failed — unknown error; the batch stopped there."when it wasrow 1 that threw; an atomic batch answers a rolled-back row with
ROLLED_BACK: "record 1 failed — unknown error"for a row that survived rather thanfailed. This is already live on
origin/mainthroughdeleteManysince PR fix(security,metadata-protocol):deleteMany's per-rowsuccessstops reporting a deletion it did not perform #19432 landed,and it reproduces identically there — so it is a shared-builder defect, not one this PR
introduces, and fixing it means editing builders all three bulk faces read. Reported for
filing. No pin in this PR asserts either message.
makeStoreEngine's fakedeleteanswers{ deleted: 1 }— an object, which isneither arm of the declared
boolean | number. It happens to be!== 0so the existingcases keep their reading, but it is a test double speaking off-contract. Noted, not filed:
no consumer.
os data delete's unconditional print was already recorded on PR fix(security,metadata-protocol): a packaged permission set's DELETE stops reporting a deletion it did not perform #19411 and is notre-reported here.
deleteManyData的批量删除门仍无条件success: true—— 打包集在批量里照样被报告为「已删除」,与 #19306 刚修的单记录门同形 #19412 is not addressed here; it landed in PR fix(security,metadata-protocol):deleteMany's per-rowsuccessstops reporting a deletion it did not perform #19432 and this branch does not touch therunDeleteManyLoopregion.🤖 Generated with Claude Code
https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE
Generated by Claude Code