Repository navigation
fix(cli): os data delete's human arm reads the server's success flag (#19413) - #19431
os-project-manager merged 4 commits into
Conversation
…lag (#19413) The `--format json` and `--format yaml` arms lower the server's `DeleteDataResponse.success` into their own `deleted` key; the default human-readable arm printed `Record deleted: <id>` unconditionally. One command, one call, two output formats able to state opposite facts about whether a row is gone. The human arm now reads the same flag and prints a warning sentence when the server says the deletion did not happen. The exit code does not move: the two machine arms publish `success: true` (the envelope's command-completed flag) beside `deleted: false` and exit 0, so moving only this arm would re-create the defect in the exit code, and moving all three would narrow a published CLI accept set. `delete-arms-agree.test.ts` is the package's first test for this command: it pins both sentences whole, the three arms' agreement for one and the same result, and the exit code in both directions. Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf Co-authored-by: Claude <noreply@anthropic.com>
…ix (#19413) Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf Co-authored-by: Claude <noreply@anthropic.com>
…ta-delete-human-arm-reads-the-flag
…that #19411 landed (#19413) The changeset described the `success: false` path as unreachable, which was true of the tree this branch forked from and stopped being true when #19411 merged. `MetadataProtocol.deleteData` now returns `success: deleted !== 0`. Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check2 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 — 25 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a0ecbd928b130a0e8bbc37c7182a5176e37f6107 && git checkout a0ecbd928b130a0e8bbc37c7182a5176e37f6107
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 488f4f546556527d03e9afe92b7ffe466f342a2a 3283cefddb24bd488852da82efc9287828407421 && git checkout -B drift-repro 488f4f546556527d03e9afe92b7ffe466f342a2a && git merge --no-ff 3283cefddb24bd488852da82efc9287828407421
node scripts/docs-audit/affected-docs.mjs --json 488f4f546556527d03e9afe92b7ffe466f342a2a |
ACCEPT —
|
| probe | reading |
|---|---|
this.exit / process.exit added or removed in the diff |
0 |
| ⭐ control — the same probe over the whole file, both refs | 2 → 2 (the catch arm's two, untouched) |
The control is non-zero on both sides, so the diff's zero is a reading and not a broken probe.
git fetch origin main <branch> leaves FETCH_HEAD pointing at the first ref, so git diff $(merge-base) FETCH_HEAD was main against main — an empty diff whose "0 hits" meant nothing, and whose 2 → 2 control looked perfectly healthy because both sides were the same tree. Caught only because merge-base == branch was printed beside it. ⇒ ⛔ print the two shas a diff is actually between, every time; a control cannot detect a probe that is comparing a ref with itself.
The remedy, read in the source
if (result.success === false) → printWarning(...), else today's sentence. Three things about it are right in ways the order did not ask for:
=== false, not falsiness, with the reason stated where a later reader will meet it:falseis the protocol's positive "no row was deleted", while an absent orundefinedflag from an off-contract server is not a signal at all — and turning "no signal" into "not deleted" would make this command deny deletions that really happened. That matchesMetadataProtocol.deleteData's own reading of the driver contract.printWarning, notprintError— the command completed; the server declined. Those are different facts and the CLI has a carrier for each.- The exit-code reasoning is written into the code, not just the PR. Moving only the human arm off
0would re-create this very card's defect one layer down — the same call exiting0under--format jsonand non-zero by default. That is the sharpest sentence in the round, and it is the argument that settles the level.
Pins
All four the order required, plus two controls it did not ask for:
POSITIVE CONTROL — a success: false body reaches the command through the REAL SDK⭐ the anti-impostor control: it proves the seam under test is the real one, not a mock that would pass whatever the code did.LIT CONTROL — success: true still prints today's sentence, byte for bytesuccess: false — the human arm does NOT assert a deletionall three arms state the SAME fact, for one and the same resultthe exit code is 0 on BOTH arms and all three formatsa genuine transport failure still exits 1 — the not-deleted arm did not swallow the error path
The ablation is the right shape too: mutating if (result.success === false) → if (false) turned exactly two red — the not-deleted pin and the three-arms-agree pin — while the lit control and both exit-code pins stayed green, so it did not simply break everything. Restore proven by blob identity (160538b32cd2 == HEAD, git diff HEAD empty), ⛔ not by an editor's exit code.
⚠️ A premise moved under this round, and the dev caught it
The dispatch order and the card both record PR #19411 as open and still a draft, and conclude the contradiction is therefore latent. It is not latent any more. Verified independently on origin/main:
f9e16d856b— "fix(security,metadata-protocol): a packaged permission set's DELETE stops reporting a deletion it did not perform (fix(security,metadata-protocol): a packaged permission set's DELETE stops reporting a deletion it did not perform #19411)" — is onmain.packages/metadata-protocol/src/protocol.ts:11465now readssuccess: deleted !== 0, against a lit control ondeleteDatain that package.- This PR's own merge-base is
f9e16d856b, so it is built on the tree where the contradiction is live.
⇒ the fix is no longer pre-emptive; it closes a contradiction that a user can hit today. Nothing about the remedy changes — but the record should not keep saying "latent", so it is corrected here.
Docs sweep — done, clean negative, and it cost two impostor controls
The drift check derived 2 anchors and listed 0 pages, stating in its own words that this is "not a clean bill of health". This diff changes a user-visible CLI sentence, which is exactly the class the check structurally cannot see (a page quotes the output; the check anchors on the emitter). So it was swept by hand on origin/main 488f4f5465:
Record deleted:→ 0 files incontent/docs. No page documentsos data deleteat all.- ⭐ Lit control, found only on the third attempt:
content/docs/deployment/cli.mdx:597quotes✓ Build complete (74ms)verbatim — glyph included — fromprintSuccessatpackages/cli/src/commands/compile.ts:1008; andcontent/docs/getting-started/build-with-claude-code.mdx:73quotes✓ Environment created!fromenvironments/create.ts:141. ⇒ this corpus does transcribe CLI success lines, so the zero above is an absence the instrument could have seen.
⛔ The two impostors, recorded because counting hits would have given a wrong green twice:
Record created→ 1 hit, which looked like the siblingprintSuccesssentence. It isreleases/v17/17-0.mdx:1553, a Studio flow-node trigger name ("Record created or updated"). Unrelated.data delete→ 1 hit, which looked like the command. It isreferences/api/protocol.mdx:730, a metadata-delete protocolsuccessfield row. Unrelated.
Both were satisfied by a mechanism other than the one under test, and both were detectable only by opening the hit. ⇒ no page is falsified by this change; nothing to file.
Level and gates
'@objectstack/cli': patch, Clause-②: no. skip-changeset refused by the dev's measurement from the artefact side (the new sentence reaches dist/commands/data/delete.js, with the unchanged Record deleted sentence from the same file as the lit control and the new test file absent from dist as the negative control). Not minor + BREAKING, because — per the verification at the top — no published accept set moves.
File surface is exactly the order's fence: delete.ts + one new sibling test + a changeset. No fourth file.
Readying and arming auto-merge.
Generated by Claude Code
Fixes #19413
Clause-②: no
os data deleterenders three output faces from one call. The--format jsonand--format yamlarms lower the server'sDeleteDataResponse.successinto their owndeletedkey — that is what #5638 landed, and its note is still in the command. Thedefault human-readable arm did not read it at all: it printed
Record deleted: rec_1unconditionally. One command, one call, two output formats able to state opposite facts
about whether a row is gone.
Re-derived premise — the card's line numbers, and then the order's
The card cites
:68-77. Re-derived on the tree this branch forked from(
origin/main576d5df660), the numbers are the dispatch order's, exactly:if (flags.format === 'json') {deleted: result.success,(json arm)deleted: result.successprintSuccess(...Record deleted...)— the defectThe order records #19411 as open and still a draft, and says the contradiction is
therefore latent. It merged while this was being written (
f9e16d856b, onmain), andthis branch has merged
mainsince.MetadataProtocol.deleteDataused to return theliteral
success: true; it now returnssuccess: deleted !== 0, so a package-declaredsys_permission_set— whose delete is an ADR-0005 reset that re-projects the row ratherthan removing it — answers
success: falseon the live door.⇒ the contradiction is now reachable on
main, not latent: from that commit on,os data delete sys_permission_set rec_1printedRecord deletedfor a row the samecommand's
--format jsonarm reported asdeleted: false. See Acceptance notes for thetriage consequence, which is not this PR's to write.
What the order left open, and what the measurement settled
Which carrier.
printWarning(⚠ ...), notprintError(✗ ...): the commandran and the server answered;
✗reads as a failed run and would imply the non-zero exitthis deliberately does not take.
The exit code does not move — on either arm, in any format. The two machine arms
already publish
success: true, the CLI envelope's "the command completed" flag,beside
deleted: false, and they exit0.0would re-create this very defect one layer down:the same call exiting
0under--format jsonand non-zero by default. That is thecard's own shape, relocated from the sentence to the exit code.
succeeds today would start failing — which is a contract change, and this card carries
no ruling for one.
⇒ exit
0in both directions, and pinned in both directions so the next change cannotmove it silently. That is what makes this a
patchrather than theminor+**BREAKING**The flag is read as
=== false, not as falsiness — the same readingMetadataProtocol.deleteDatatakes of the driver contract, and the one #19411 reaffirmedin its own note ("only a POSITIVE zero is read as 'not removed'"). An absent or
undefinedflag from an off-contract server is no signal at all, and turning "no signal"into "not deleted" would make this command deny deletions that really happened.
The escape hatch was checked and does not apply
The order offers "the human arm cannot read the flag without a change in
@objectstack/client's return type" as a legitimate outcome. Measured, it is false:DeleteDataResultalready declaressuccess: boolean, required — no widening needed.unwrapResponsestrips an envelope only when the body carries both a booleansuccessand adatakey, so the flat delete payload passes through verbatim.fetchwrapper throws only on a non-2xx status, so a200carryingsuccess: falsereaches the command intact.All three are asserted by the POSITIVE CONTROL case rather than left as prose, so if any
of them stops holding, that case reddens before the rest of the file can measure a fiction.
Test — the package's first for this command
packages/cli/src/commands/data/delete-arms-agree.test.ts. There was no existing testfile for
data/delete.ts(the onlydelete*.test.tsin the package sit undersrc/commands/meta/, which are the harness precedents this follows). Six cases:success: falsebody reaches the command through the realSDK, and the
truebody arrives just as verbatim. Nothing imports from fix(security,metadata-protocol): a packaged permission set's DELETE stops reporting a deletion it did not perform #19411.success: truestill prints today's sentence, asserted as thewhole output, byte for byte.
success: false— the human arm does not assert a deletion.the assertion the card is actually about.
0on both arms across all three formats.1— the control for pin 5, so that0readsas a decision rather than as this command having lost the ability to fail.
Both sentences are pinned whole, not by substring:
Record deleted: rec_1andNot deleted: rec_1 — the server reported the deletion did not happenshare the tokendeleted, so atoContainpin would be satisfied by either.Reverse verification — run on the final tree,
3283cefddbThrough
scripts/ablation-replace.mjs, so the mutation is proven on disk rather thantrusted to an editor's exit code.
if (result.success === false) {replaced withif (false) {— the pre-fix behaviour exactly:Direction observed: turns red, on exactly the two pins the guard is about — and the
lit control and both exit-code pins stayed green, so the ablation did not simply break
everything. Restore proven byte-identical to
HEAD, not by an exit code.Evidence
vitest run --project unit ...delete-arms-agree.test.tspnpm --filter @objectstack/cli exec vitest run --project unitpnpm --filter @objectstack/cli typecheckpnpm --filter '@objectstack/cli^...' buildscripts/pm/dispatch-gates.mjspackages/cli'sintegrationtier is declared to CI, not run locally: this diff touchesno spawn entry (
bin/,test/helpers/serve-process.ts) and no driver or kernel bootpath, so the new file lands in
unitbypackages/cli/vitest-tiers.ts's predicate.Two families first answered
PREREQUISITE NOT MET(exit 3, "nothing was measured")because the worktree had no full
dist/; both were re-run green afterpnpm build, andone more was killed by this runner's own 300s cap and re-run green with a real budget.
None of the three is recorded as a failed measurement.
Changeset — the level was measured, not judged
patch, on@objectstack/cli.skip-changesetis refused by measurement: the package'spublished
files[]shipsdist, and after a build the new sentence is present indist/commands/data/delete.js, with the unchanged success sentence from the same file asthe lit control and the new test file absent from
distentirely as the negative control.Not
minor+ breaking: the exit code and the accept set are both unchanged, and the--format json/--format yamlbytes are untouched in both directions.Acceptance notes
Governance. This diff touches no governed surface (Prime Directive #14) — only
packages/cli/**and one.changeset/entry.File surface. Exactly the order's fence:
delete.ts+ one new sibling test + achangeset. No fourth file was needed.
For the triage seat, not for this PR. The
os-samtriage comment on the card states amachine-checkable upgrade trigger: "When #19411 merges,
re-grade this card
priority:p1+security." That merge has now happened. This PRwrites no labels and re-grades nothing — the trigger is recorded here so the seat that
owns grading sees it.
Out-of-scope observations, noted and not filed — neither is a defect, a contract breach or
an AI-metadata trap, so neither meets the three filing classes:
packages/cli/test/remote-api-commands.test.tsasserts only staticoclif metadata (
description,args,flags) for eleven commands including this one —no behaviour. It is why the package could carry a
data/deleteimport and still have nobehavioural pin. Observation about coverage shape, not a defect. Carrier: this PR is the
one that adds the behavioural pin, so nothing is left hanging.
--format tableoption is accepted by the flag declaration butreaches the same
elsearm as any unrecognised value, sotableand the default renderidentically for this command. Pre-existing, unchanged here, and no author-facing metadata
is dropped. Carrier: none — recorded only so the next reader of this arm does not read
the
elseas table-specific.Generated by Claude Code