feat(cli): os meta delete can pin its reset and can discard only the pending draft - #13346
Conversation
…in and draft flags
…rom `os meta delete` `os meta delete <type> <name>` was the only in-repo caller of the SDK reset and called it with two arguments, so every CLI reset was the unpinned, full one: a concurrent edit was silently destroyed instead of answering `409 metadata_conflict` (ADR-0008), and the published overlay went with any pending draft. `DeleteMetaItemOptions` landed on both `deleteItem` declarations in #12181; only the CLI surface was missing. Adds `--if-match <version>` (string, forwarded to `options.ifMatch`) and `--draft` (boolean, forwarded as `options.state = 'draft'`). An empty `--if-match` is refused before the client is built rather than silently downgraded to the unpinned reset. `?dropStorage` stays withheld. A run with neither flag sends byte-identical bytes to what it always sent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
📓 Docs Drift Check4 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 — 23 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 016a7e6ab7d02a304f2697ede190f50ce1f9be44 && git checkout 016a7e6ab7d02a304f2697ede190f50ce1f9be44
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b7f645a8a3b91cd26f2cf52a2647c415beb94b28 aa80a3010cacd1aa99ccb7153453ca645ba8dac3 && git checkout -B drift-repro b7f645a8a3b91cd26f2cf52a2647c415beb94b28 && git merge --no-ff aa80a3010cacd1aa99ccb7153453ca645ba8dac3
node scripts/docs-audit/affected-docs.mjs --json b7f645a8a3b91cd26f2cf52a2647c415beb94b28 |
|
Out-of-scope finding from this work, filed rather than folded in: #13347 — the CLI's Linked here rather than added to the PR body on purpose: a body edit normalizes the session-URL attribution footer down to the bare form (measured 2026-08-26), and the body is otherwise exactly as created. Generated by Claude Code |
✅ PM review — ACCEPT once CI is green; ⛔ not while it is running (#13024)Undrafting now; arm follows on a complete green read. ⛔ No rework owed. Clause ②: does not attach — 3 files, none under ✅ All three fences held, verified from the diff
⭐ The best thing here is something I did not ask forAn empty or whitespace-only
And it correctly identifies that the SDK's omit-on-empty rule is right at the SDK layer (an emitted empty header would pin against the empty string and refuse a reset nobody asked to pin) and only becomes a trap at the CLI boundary. Refusal only — a non-empty token is forwarded verbatim, never rewritten, because the version is opaque. Pinned with both limbs measured ( The evidence is driven, not assertedA pin that checked "the command constructed
⭐ The current-version case is the one that stops "always 409" from passing, and the full-reset follow-up is what makes the ⭐ The flag spelling was measured, not preferred: every ⭐ And the human-mode wording moved: Reverse verification predicted 4 red on leg A and measured 5 — ⭐ reported as an under-count in the same direction rather than quietly rounded to "as predicted". Leg B predicted 3, measured exactly 3, with
|
Fixes #13024
packages/cli/src/commands/meta/delete.tswas the only in-repo caller of the SDK reset and called it with two arguments, so every CLI reset was the unpinned, full one — unpinned (a concurrent edit silently destroyed instead of409 metadata_conflict, ADR-0008) and full (the published overlay dropped along with any pending draft).DeleteMetaItemOptions(ifMatch,state) landed on bothdeleteItemdeclarations in #12181; only the CLI surface was missing.The flag spelling, and the convention that decides it
--if-match VERSION— aFlags.string, like every other value-bearing flag in this CLI. It carries an opaque token; it is forwarded verbatim (DeleteMetaItemOptions.ifMatch: "echo it verbatim, never parse it").--draft— aFlags.boolean, not--state active|draft. Measured against the sibling commands rather than chosen by taste:packages/cli/src/commandsenumerates three or more members with a meaningful non-boolean default:--format json|table|yaml(14 commands),--visibility private|org|marketplace,--log-level(6),--package-manager npm|pnpm|yarn|bun,--observability minimal|default|full. There is no two-valuedoptions: [...]flag anywhere in the tree.Flags.booleanthroughout — 115 of them, including--dry-run,--apply,--strict,--pre-release,--submit,--fresh,--step,--stored.state?: 'active' | 'draft'where'active'deliberately sends nothing (the door acts onstate=draftalone and the SDK drops the other spelling).--state activewould be a CLI value with no effect on the wire.--stateis not merely undocumented — it is refused by the parser, pinned beside a positive control that--draftis accepted.FROM to TO — what the CLI sends
/api/v1/meta/TYPE/NAMEcontent-type/api/v1/meta/TYPE/NAMEcontent-type--if-match TOKENcontent-type,if-match: TOKEN--draft?state=draftcontent-type?state=draftcontent-type,if-match: TOKENA flagless run is byte-identical to what this command always sent:
metaDeleteOptionsreturnsundefined(never{}), the SDK's query builder returns the empty string and its header builder returnsundefined, so theheaderskey is omitted entirely. Pinned as a measurement, not as prose — the unflagged case assertssearch === ''and the exact header-name set.Driven evidence, not asserted predicates
New file
packages/cli/src/commands/meta/delete-reset-carriers.test.ts— 20 cases — boots a real stack (realObjectQLon a real SQLite database, realsys_metadata/sys_metadata_history/sys_metadata_audittables created by real DDL, realObjectStackProtocolImplementation, realRestServerroutes) and drives the real oclif command, argv in and printed output out. Two seams are stubbed, both the credential boundary:createApiClient(replaced with a realObjectStackClientwhosefetchbridges into the registered handlers) andresolveExecCtx("better-auth says this bearer holdsmanage_metadata"), the same seam every neighbouring/metadoor test stubs.The pin, both directions:
os meta delete view race_probeexits 0, printsMetadata deleted: view/race_probe, and the other author's row is gone (sys_metadatarows 1 to 0). The probe onprotocol.deleteMetaItemshows noparentVersionkey.--if-match STALE_TOKENexits 1, prints[metadata_conflict] view/race_probe has been modified since you loaded it. Expected parent sha256:... but current is sha256:..., and the other author's row survives (1 row). The probe showsparentVersionequal to the stale token, carried as theif-matchheader with no?ifMatch=on the URL. Asserted on the envelope the operator branches on, never as a bare "something failed".--if-match CURRENT_VERSIONexits 0 and the row goes — so the pin refuses a stale write, not every write. Without this case "always 409" would pass the one above.--format jsoncarries the same refusal machine-readably:{"success": false, "error": "[metadata_conflict] ..."}, exit 1, row intact.--draft, against the full reset:statevaluesactiveanddraft).os meta delete view draft_probe --draftputs?state=drafton the wire, the door threadsstate: 'draft'into the protocol call, and afterwards 1 row remains —state: 'active'. The published overlay is still serving.stateabsent on that request — the positive control that makes the presence assertion above mean something.?state=draftandif-matchtogether,parentVersionandstateboth threaded, only the draft discarded.The CLI's own refusal. An empty
--if-matchis refused before the client is built.os meta delete view v --if-match "$VERSION"withVERSIONunset expands to an empty argument, and the SDK's rule for that layer (an empty token omits the header, because the door reads the header's presence as "pin this") would have run the unpinned reset while the operator's command line reads as pinned — exactly the silent destruction this card exists to prevent. Pinned as a refusal: exit 1, the refusal text in both human and--format jsonoutput, zerocreateApiClientcalls, zero protocol calls, and the row still there — beside a positive control in which the same two instruments both move on a run that is allowed through. A whitespace-only pin is refused the same way; a non-empty token with surrounding whitespace is forwarded verbatim.Human-mode wording. A
--draftrun now printsPending draft discarded: TYPE/NAME. The old sentence would be a false report of what happened on the run where the operator deliberately chose the narrower verb.json/yamlpayload keys are unchanged.Both SDK twins
Measured, this command reaches exactly one of the two textually identical
deleteItemdeclarations:createApiClientbuilds a plainObjectStackClientand passes any environment id as a config value (sent as theX-Environment-Idheader), andrun()holdsclient.meta— the unscoped namespace. That is asserted as a measurement, not assumed: the command's reset lands on/api/v1/meta/view/mount_probe, with no/environments/segment.So the obligation is discharged by driving the bag this command builds through the other twin, against the same booted door — which is why
metaDeleteOptionsis an exported function rather than an object literal insiderun():--if-match STALEbag throughclient.environment('env-13024').meta.deleteItemanswerscode: 'METADATA_CONFLICT',httpStatus: 409, the row survives, and the probe shows the sameparentVersion;--draftbag through the scoped twin discards only the draft and leavesstate: 'active'serving;?state=draft), identicalif-matchheader, differing in exactly one way — the path prefix. And unflagged, both send an empty query and the identical header-name set.Scope fences, held
os meta deleteshould read the current version first and pin automatically is a contract question, not wiring, and is not covered by the inherited ruling. Only the explicit flags are implemented. Nothing in the work made defaulting look necessary — the two behaviours are cleanly separable, and the pin is opt-in on the door and on the SDK too.?dropStoragestays withheld. No flag, under either spelling, pinned with a positive control that the two flags that are declared are found by the same lookup.Reverse verification
Direction predicted before each run; the implementation was committed first so each restore had a real reference, and each leg proved the mutation and the restore on disk by blob hash (
git hash-objectagainstgit rev-parse HEAD:PATH, plus an emptygit diff HEAD), never by an exit code. Nodist/leg: the test imports the command relatively, so vitest resolves the source in the checkout and there is nothing built to go stale between the legs.deleteItem(type, name, options)back todeleteItem(type, name)). Predicted: the four forwarding pins go red, controls stay green. Measured: 5 failed / 15 passed — the prediction under-counted by one,a NON-empty pin is forwarded verbatim, which is a forwarding pin in the same direction. Red: the stale-pin refusal, its--format jsontwin, the draft-only discard, both-carriers-at-once, verbatim forwarding. Green throughout: the whole declared-flag surface, the unpinned baseline, the current-version pin, the unscoped-mount measurement, all three empty-pin refusals, and every scoped-twin case — so the pins discriminate rather than all failing together.throwonly). Predicted: the three refusal cases go red, everything else green. Measured: 3 failed / 17 passed, exactly those three.a NON-empty pin is forwarded verbatimstayed green, which is what distinguishes "the refusal was ablated" from "the file was broken".Verification
Gate families derived from the real change set with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(three-dot against the merge base, working tree included) — not recalled. The union below was re-run after the final commit, onaa80a3010; each verdict is the gate's own pass line, with the exit code captured before any pipe.Green:
check:nul-bytes,check:test-source-alias,check:cross-package-test-inputs,check:engine-double-contract,check:where-matcher,check:objectql-double-limit,check:undeclared-dep-imports,check:changeset-gate-self-tests,check:objectui-changeset,check:pm-half-states,check:keyed-text-bounds,check:published-files,check:slot-lookup,check:page-declaration-shape,check:logger-receiver-detach,check:type-source-resolution,check:query-options-erasure,check:type-check-coverage,check:type-check-debt(the--re-measureratchet),check:i18n,check:i18n-coverage,check:dual-build-cjs-loads,check:shard-attestation, pluscheck-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-ci-filter-parity,check-comment-mask-adoption,check-plugin-teardown-shape.node scripts/check-test-completeness.mjswith no argument is recorded NOT MEASURED (exit 3,PREREQUISITE NOT MET— it grades a savedturbo run testlog, which only CI produces). It is not folded into the green list.check:i18n,check:i18n-coverageandcheck:dual-build-cjs-loadseach refused first withPREREQUISITE NOT MET; all three were cleared by building the closure each named and then really ran.pnpm lint(eslint . --no-inline-config, the whole repo) is clean onaa80a3010— run in full rather than narrowed, so no scoping argument is owed.pnpm --filter @objectstack/cli typecheckexits 0, andtsc --noEmit --listFilesconfirms both edited files are inside that program (this package'stsconfig.jsonincludessrcwith no test exclusion), so the green covers the new test file rather than merely the command.Declared narrowing on the package suite.
@objectstack/cliis this repo's largest suite (measured at ~13 minutes in its ownvitest.config.ts), which does not fit this container's foreground ceiling, so vitest was run on a scoped population instead of the whole package: the new file, every test file that reaches the changed module, and the wholesrc/commands/metadirectory. The population was read from the tool rather than guessed —git grepformeta/delete/MetaDeleteacrosspackages/cli/srcandpackages/cli/testreturns exactly three files, one of which is the module itself — and the two new exported names (metaDeleteOptions,EMPTY_IF_MATCH_REFUSAL) are reachable from nowhere else in the tree by the same grep. 3 test files, 48 tests, all passing onaa80a3010. CI runs the full farm regardless.Generated by Claude Code