Skip to content

fix(cli): os data delete's human arm reads the server's success flag (#19413) - #19431

Merged
os-project-manager merged 4 commits into
mainfrom
claude/issue-19413-data-delete-human-arm-reads-the-flag
Sep 20, 2026
Merged

os-project-manager merged 4 commits into
mainfrom
claude/issue-19413-data-delete-human-arm-reads-the-flag

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #19413

Clause-②: no

os data delete renders three output faces from one call. The --format json and
--format yaml arms lower the server's DeleteDataResponse.success into their own
deleted key — that is what #5638 landed, and its note is still in the command. The
default human-readable arm did not read it at all: it printed Record deleted: rec_1
unconditionally. 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/main 576d5df660), the numbers are the dispatch order's, exactly:

what line
if (flags.format === 'json') { 69
deleted: result.success, (json arm) 74
yaml arm, same deleted: result.success 77
printSuccess(...Record deleted...) — the defect 79

⚠️ The premise moved under this branch: #19411 landed mid-task

The order records #19411 as open and still a draft, and says the contradiction is
therefore latent. It merged while this was being written (f9e16d856b, on main), and
this branch has merged main since. MetadataProtocol.deleteData used to return the
literal success: true; it now returns success: deleted !== 0, so a package-declared
sys_permission_set — whose delete is an ADR-0005 reset that re-projects the row rather
than removing it — answers success: false on the live door.

⇒ the contradiction is now reachable on main, not latent: from that commit on,
os data delete sys_permission_set rec_1 printed Record deleted for a row the same
command's --format json arm reported as deleted: false. See Acceptance notes for the
triage consequence, which is not this PR's to write.

What the order left open, and what the measurement settled

Which carrier. printWarning ( ⚠ ...), not printError ( ✗ ...): the command
ran and the server answered; ✗ reads as a failed run and would imply the non-zero exit
this 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 exit 0.

  • Moving only the human arm off 0 would re-create this very defect one layer down:
    the same call exiting 0 under --format json and non-zero by default. That is the
    card's own shape, relocated from the sentence to the exit code.
  • Moving it on all three arms would narrow a published CLI accept set — a script that
    succeeds today would start failing — which is a contract change, and this card carries
    no ruling for one.

⇒ exit 0 in both directions, and pinned in both directions so the next change cannot
move it silently. That is what makes this a patch rather than the minor + **BREAKING**

  • ADR-0087 shape the order names for the narrowing branch.

The flag is read as === false, not as falsiness — the same reading
MetadataProtocol.deleteData takes of the driver contract, and the one #19411 reaffirmed
in its own note ("only a POSITIVE zero is read as 'not removed'"). An absent or
undefined flag 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:

  • DeleteDataResult already declares success: boolean, required — no widening needed.
  • unwrapResponse strips an envelope only when the body carries both a boolean
    success and a data key, so the flat delete payload passes through verbatim.
  • the fetch wrapper throws only on a non-2xx status, so a 200 carrying
    success: false reaches 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 test
file for data/delete.ts (the only delete*.test.ts in the package sit under
src/commands/meta/, which are the harness precedents this follows). Six cases:

  1. POSITIVE CONTROL — a success: false body reaches the command through the real
    SDK, and the true body 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.
  2. LIT CONTROL — success: true still prints today's sentence, asserted as the
    whole output, byte for byte.
  3. success: false — the human arm does not assert a deletion.
  4. All three arms state the same fact for one and the same result, both ways round —
    the assertion the card is actually about.
  5. The exit code is 0 on both arms across all three formats.
  6. A genuine transport failure still exits 1 — the control for pin 5, so that 0 reads
    as a decision rather than as this command having lost the ability to fail.

Both sentences are pinned whole, not by substring: Record deleted: rec_1 and
Not deleted: rec_1 — the server reported the deletion did not happen share the token
deleted, so a toContain pin would be satisfied by either.

Reverse verification — run on the final tree, 3283cefddb

Through scripts/ablation-replace.mjs, so the mutation is proven on disk rather than
trusted to an editor's exit code. if (result.success === false) { replaced with
if (false) { — the pre-fix behaviour exactly:

ablation-replace: anchor   x1 -> x0
ablation-replace: replace  "if (false) {"  x0 -> x1
ablation-replace: blob     160538b32cd2... -> ced8a46366b9...
ablation-replace: ok mutation landed: anchor 1 -> 0, blob 160538b32cd2 -> ced8a46366b9

  × `success: false` — the human arm does NOT assert a deletion
  × all three arms state the SAME fact, for one and the same result
  Tests  2 failed | 4 passed (6)

  AssertionError: expected [ false, false, true ] to deeply equal [ false, false, false ]

ablation-replace: ok restored: blob == HEAD (160538b32cd2) and `git diff HEAD` is empty

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

what command result
new pin, final tree vitest run --project unit ...delete-arms-agree.test.ts 6 passed
package unit tier pnpm --filter @objectstack/cli exec vitest run --project unit 221 files / 3120 tests passed, exit 0
package typecheck pnpm --filter @objectstack/cli typecheck exit 0 (test layer compiles too)
dependency closure pnpm --filter '@objectstack/cli^...' build exit 0
derived gate families scripts/pm/dispatch-gates.mjs 61 derived, 61 run, 0 NOT-MEASURED

packages/cli's integration tier is declared to CI, not run locally: this diff touches
no spawn entry (bin/, test/helpers/serve-process.ts) and no driver or kernel boot
path, so the new file lands in unit by packages/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 after pnpm build, and
one 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-changeset is refused by measurement: the package's
published files[] ships dist, and after a build the new sentence is present in
dist/commands/data/delete.js, with the unchanged success sentence from the same file as
the lit control and the new test file absent from dist entirely as the negative control.

Not minor + breaking: the exit code and the accept set are both unchanged, and the
--format json / --format yaml bytes 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 + a
changeset. No fourth file was needed.

For the triage seat, not for this PR. The os-sam triage comment on the card states a
machine-checkable upgrade trigger: "When #19411 merges,
re-grade this card priority:p1 + security."
That merge has now happened. This PR
writes 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:

  • noted, not filed: packages/cli/test/remote-api-commands.test.ts asserts only static
    oclif metadata (description, args, flags) for eleven commands including this one —
    no behaviour. It is why the package could carry a data/delete import and still have no
    behavioural pin. Observation about coverage shape, not a defect. Carrier: this PR is the
    one that adds the behavioural pin, so nothing is left hanging.
  • noted, not filed: the --format table option is accepted by the flag declaration but
    reaches the same else arm as any unrecognised value, so table and the default render
    identically 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 else as table-specific.

Generated by Claude Code

…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>
…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 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): node scripts/docs-audit/affected-docs.mjs --json 488f4f546556527d03e9afe92b7ffe466f342a2a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from a0ecbd928b130a0e8bbc37c7182a5176e37f6107 — the merge of head 3283cefddb24bd488852da82efc9287828407421 into base 488f4f546556527d03e9afe92b7ffe466f342a2a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Copy link
Copy Markdown
Collaborator Author

ACCEPT — patch confirmed, and the premise that moved is measured

Head judged: 3283cefddb24bd488852da82efc9287828407421, merge-base f9e16d856b. Read at 2026-09-20T20:15Z.

⭐ The one claim the whole changeset level hangs on, verified with a lit control

If this diff moved the exit code off 0, a script that succeeds today would start failing — a narrowing of a published CLI accept set, which ships minor + **BREAKING** + ADR-0087, ⛔ never patch. So the patch verdict rests entirely on the exit code not moving, and that is the one thing worth proving rather than reading:

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.

⚠️ My first run of this check was vacuous and I nearly published it. 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: false is the protocol's positive "no row was deleted", while an absent or undefined flag 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 matches MetadataProtocol.deleteData's own reading of the driver contract.
  • printWarning, not printError — 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 0 would re-create this very card's defect one layer down — the same call exiting 0 under --format json and 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 byte
  • success: false — the human arm does NOT assert a deletion
  • all three arms state the SAME fact, for one and the same result
  • the exit code is 0 on BOTH arms and all three formats
  • a 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:

⇒ 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 in content/docs. No page documents os data delete at all.
  • ⭐ Lit control, found only on the third attempt: content/docs/deployment/cli.mdx:597 quotes ✓ Build complete (74ms) verbatim — glyph included — from printSuccess at packages/cli/src/commands/compile.ts:1008; and content/docs/getting-started/build-with-claude-code.mdx:73 quotes ✓ Environment created! from environments/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:

  1. Record created → 1 hit, which looked like the sibling printSuccess sentence. It is releases/v17/17-0.mdx:1553, a Studio flow-node trigger name ("Record created or updated"). Unrelated.
  2. data delete → 1 hit, which looked like the command. It is references/api/protocol.mdx:730, a metadata-delete protocol success field 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

@os-project-manager
os-project-manager marked this pull request as ready for review September 20, 2026 20:15
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 20, 2026
Merged via the queue into main with commit 24162f9 Sep 20, 2026
36 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-19413-data-delete-human-arm-reads-the-flag branch September 20, 2026 20:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants