Skip to content

feat(cli): os meta register takes --force, forwarded as the SDK's force (#23480) - #23519

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-23480-meta-register-force
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-23480-meta-register-force

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23480
Clause-②: yes

What this does

os meta register takes --force, so a CLI user can send the acknowledgement that the save door's 409 DESTRUCTIVE_CHANGE refusal prescribes ("re-submit with ?force=true to proceed").

  • The flag. --force is a boolean, off by default, with no short alias, in the house shape of this CLI's other --force flags (migrate apply, migrate audit-metadata-bodies and the other migrate commands: Flags.boolean({ description, default: false })). Its help text says it applies a save that drops or transforms existing data, and that the server refuses such a change with 409 DESTRUCTIVE_CHANGE without it.
  • The mapping. metaRegisterOptions maps --force to SaveMetaItemOptions.force: true only when the flag is given. When it is absent (oclif hands false), no member is added, so an unflagged run still hands the SDK no options bag and sends the same request as before. --force combines with --package. The empty---package refusal still runs first.
  • The refusal the user sees. The command already passed the server's message through verbatim (measured below), and it still does. On a DESTRUCTIVE_CHANGE refusal from a run without --force, one CLI-side line now names the flag:
    • text output: a warning line under the error line;
    • --format json: a new hint key beside success, error, code and httpStatus, following the hint key the migrate commands already put in their refusal envelopes (hint: OCCUPANCY_HINT, hint: 'pass --yes').
    • Every other failure is reported exactly as before, with no hint. A run that did send --force gets no hint, because it has nothing left to re-run with.
  • The metaRegisterOptions docblock no longer names force as deliberately unmapped. It now lists the two mapped members and says that ifMatch and mode remain unmapped.

Measurements (the dispatch's mechanism assumptions)

All taken in this worktree, branched from origin/main 1418de4c7.

  • A1, the one mapping place: confirmed. metaRegisterOptions is the only place the command builds SaveMetaItemOptions, and its docblock named force as unmapped ("this command declares no flag for them"). That docblock is updated.
  • A2, the SDK forwards force as ?force=true: confirmed on packages/client/src/index.ts. metaSaveQuery runs if (options.force) params.set('force', 'true'), behind a truthy guard, so false and undefined send nothing. Both saveItem twins call it. On the real door, --force puts ?force=true on the wire and the protocol receives force: true (pin 2 below).
  • What the command printed before this change, from a throwaway probe (deleted afterwards). The probe drove the BASE register.ts (1418de4c7) through the same real-door harness: it saved probe_obj with fields title and note, then re-registered it without note.
    • --format json: exit 1, {"success": false, "error": "object/probe_obj would drop or transform existing data: Field 'note' removed — existing data in this column will become inaccessible. — re-submit with ?force=true to proceed.", "code": "DESTRUCTIVE_CHANGE", "httpStatus": 409}.
    • Text: exit 1, the same message on one error line.
    • --force: exit 2, Nonexistent flag: --force. The stored fields stayed ["note", "title"].
    • So the server's message already passed through verbatim, and the change adds one line beside it rather than rewriting it.
  • Generated reference: nothing to regenerate. The package commits no oclif manifest, and README.md carries no flag table for this command. No page under content/docs/** outside the release-owned releases/ tree names this command's flags. skills/objectstack-platform/references/operations.md names the command without flags.

Where the edits sit

All inside the claim's file surface:

  • packages/cli/src/commands/meta/register.ts: the flag, the metaRegisterOptions mapping and docblock, DESTRUCTIVE_CHANGE_FORCE_HINT, metaRegisterRefusalHint, one example, and the hint in the catch.
  • packages/cli/src/commands/meta/register-force.test.ts: new. It copies the real-door harness of register-package.test.ts rather than adding to that file, so each file keeps its own wall time under the 10 s line.
  • .changeset/23480-cli-meta-register-force.md: @objectstack/cli minor, Clause-②: yes (widening). It needs no ADR-0087 disposition, because it is not a breaking changeset (check-adr-0087-registration: "this PR adds no declared-breaking changeset").

There is no SDK change and no server change.

Tests

The pins run through the real oclif command, the real ObjectStackClient, the real RestServer route, the real ObjectStackProtocolImplementation and real SQLite sys_metadata tables. Each pin has a negative control.

  1. Unflagged, a field removed: exit 1, code: 'DESTRUCTIVE_CHANGE', httpStatus: 409. The server's message still carries ?force=true, and hint names --force. The stored body is deep-equal to the one before. No force reaches the protocol, and the wire search is empty. The text output shows the same two things. This pin is the negative control for pin 2.
  2. --force, the same re-register: exit 0, and the stored fields are ["title"] (the field is dropped). The protocol receives force: true, and the wire reads ?force=true.
  3. Non-destructive (a field added), with and without --force on two identical items: both exit 0, and the stored bodies are identical apart from the name. The probe sees force only on the flagged run.
  4. A failure that is not DESTRUCTIVE_CHANGE (the empty---package refusal) carries no hint, and nothing reaches the door.

Unit cases cover the declared flag (boolean, default: false, no char, no allowNo, help names 409 and DESTRUCTIVE_CHANGE), the metaRegisterOptions mapping, and the metaRegisterRefusalHint guard. The guard returns undefined for --force, for another code and for a local error.

Runs, each under scripts/pm/os-verify-lock.sh, at a56f6340e:

  • Dependency closure: pnpm turbo run build --filter='@objectstack/cli^...' --concurrency=2 gave Tasks: 60 successful, 60 total. Then pnpm --filter @objectstack/cli build exited 0.
  • pnpm --filter @objectstack/cli typecheck: exit 0, check:test-typecheck: OK. tsc --noEmit --listFiles lists register-force.test.ts.
  • vitest run --project unit --maxWorkers=2: Test Files 288 passed (288), Tests 4314 passed (4314).
  • vitest run --project integration on register-force.test.ts and register-package.test.ts: Test Files 2 passed (2), Tests 15 passed (15). register-force.test.ts alone measured tests 4.94s.
  • The rest of the integration tier is declared to CI. No spawn test covers os meta register.

Ablations (one-off, no file kept), each through node scripts/ablation-replace.mjs in wrap mode. The subject is imported relatively from src (./register.js), so no build or dist leg applies.

  • Forwarding. The mutation replaced if (flags.force === true) options.force = true; with void flags.force; /* ABLATION-23480: force forwarding removed */.
    • The anchor count went 1 to 0, and the blob went ab3eeab33107 to ba60503f0913.
    • Result: Tests 3 failed | 4 passed (7). The three red cases were the mapping case, pin 2 (expected 1 to be +0, the 409) and pin 3 (expected undefined to be true).
    • Pin 1 (the negative control), pin 4 and the hint guard stayed green, as predicted.
  • Hint. The mutation replaced ? DESTRUCTIVE_CHANGE_FORCE_HINT with ? undefined /* ABLATION-23480-HINT: hint removed */.
    • The anchor count went 1 to 0, and the blob went ab3eeab33107 to 49fccded87ff.
    • Result: Tests 2 failed | 5 passed (7). The two red cases were the hint-guard case and pin 1, as predicted.
  • Both restores were proven by the tool: the blob is back to ab3eeab33107, equal to HEAD, and git diff HEAD is empty.

Gates

  • Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at a56f6340e: 64 commands. Each was run, and its exit code was captured before any pipe.
  • --ran reconciliation: "64 derived famil(ies) accounted for — 62 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)".
  • NOT MEASURED, declared to CI: check:dual-build-cjs-loads and check:i18n-coverage. Both report PREREQUISITE NOT MET because packages outside this card are not built in this checkout.
  • Verdict lines of note:
    • check-adr-0087-registration: "this PR adds no declared-breaking changeset";
    • check-empty-changeset: "1 declaring changeset(s) added";
    • check:doc-authoring: clean;
    • check:nul-bytes: OK;
    • check:test-source-alias: OK;
    • check:cross-package-test-inputs: OK.
  • origin/main has moved 6 commits since the base (96a7f434a..222293f60). None of them touches packages/cli/src/commands/meta/, packages/client or the REST save door. The one on this test's path, 222293f60, touches the protocol's package-bound object save, and these pins send no --package on an object. So main was not merged before opening, and CI runs the merge ref.

Acceptance notes

Observations, not filed.

  • The save door's author-time rule security-owd-unset refuses an object with no sharingModel, so the probe object here declares sharingModel: 'private'. Without it the seed saves never reach the destructive gate.
  • The destructive gate is object-only on the protocol side. --force on any other type is sent, and the save lands exactly as it would without the flag.
  • A --force save that the gate would otherwise have refused prints the same success line as any save. The door's 200 answer does not say whether the gate was bypassed, so the command cannot either.
  • The real-door harness is now copied in three test files: delete-reset-carriers, register-package and register-force. Sharing it would be a refactor outside this card.

Generated by Claude Code

The save door refuses a change that drops or transforms existing data
with 409 DESTRUCTIVE_CHANGE and names ?force=true as the remedy, which
this command had no way to send. --force (boolean, off by default, no
short alias) maps to SaveMetaItemOptions.force only when given, and a
refusal on a run without it gains one CLI-side line, as text and as
`hint` in the --format json envelope, naming the flag. The server's
message is passed through verbatim.

Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
A destructive re-register without --force is refused 409
DESTRUCTIVE_CHANGE with the server's message verbatim, a hint naming
the flag, and nothing written; with --force it sends ?force=true and
lands; a non-destructive save with --force lands as one without it.
Adds the @objectstack/cli minor changeset (Clause-② yes, widening).

Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
The save door's author-time rule security-owd-unset refuses an object
without one, so the seed saves never reached the destructive gate.

Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 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
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 28 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 222293f60a7fff2779269b20bb4e5ce28a34c935 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from bb9105904ea68f84317de52bfd414714a5c6b8b9 — the merge of head a56f6340e7cfcee06b114a7af4aa8c4c7644ea7c into base 222293f60a7fff2779269b20bb4e5ce28a34c935, 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 bb9105904ea68f84317de52bfd414714a5c6b8b9 && git checkout bb9105904ea68f84317de52bfd414714a5c6b8b9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 222293f60a7fff2779269b20bb4e5ce28a34c935 a56f6340e7cfcee06b114a7af4aa8c4c7644ea7c && git checkout -B drift-repro 222293f60a7fff2779269b20bb4e5ce28a34c935 && git merge --no-ff a56f6340e7cfcee06b114a7af4aa8c4c7644ea7c

node scripts/docs-audit/affected-docs.mjs --json 222293f60a7fff2779269b20bb4e5ce28a34c935

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

domain:spec seat 4 (#18917) · sunhaolin · session session_0121ooBYRmS8ziFXm9ai5YHm · claim 6172457759 on #23480 · reviewed head a56f6340e7cfcee06b114a7af4aa8c4c7644ea7c · 2026-10-14T16:56Z

Verdict: ACCEPT. Not governed (check-governed-merges --pr 23519): 3 files, +509 / −9. This record is also the review at TIER_DEFAULT that the Clause-②: yes declaration owes. The face is a new CLI flag plus one new key in the --format json envelope; no schema or rules text moves.

  1. Shape.
    • The PR is a draft. Its first line is Fixes #23480, and Clause-②: yes sits at line start.
    • The changeset .changeset/23480-cli-meta-register-force.md is @objectstack/cli minor with Clause-②: yes (widening).
    • It carries no ADR-0087 marker: nothing is breaking, and the gate agrees.
  2. The seat's decision against the diff.
    • --force is a boolean, off by default, with no short alias. It follows the house shape of the CLI's other --force flags, and its description names the data loss.
    • metaRegisterOptions maps it to SaveMetaItemOptions.force: true only when given. An unflagged run sends the same request as before: no options bag and no query string.
    • The server's 409 DESTRUCTIVE_CHANGE message passes through verbatim. One CLI-side line is added beside it, only on a run without --force: a warning line in text, and a hint key in the JSON envelope (the key the migrate commands already use).
    • Every other failure is unchanged.
    • Both PM mechanism assumptions held: metaRegisterOptions is the one mapping place, and the SDK sends force only as ?force=true behind a truthy guard.
  3. Gates and evidence.
    • Head check-runs: 34 success, 6 skipped (the full-battery or opt-in set), 0 failures, 0 in progress. mergeable_state is clean.
    • CLI unit tests: 288 files and 4314 tests pass.
    • Integration: register-force.test.ts passes 7/7 through the real oclif command, ObjectStackClient, RestServer route, protocol and SQLite.
    • The pins:
      • the unflagged destructive save is refused, writes nothing and carries the hint;
      • --force applies the save;
      • a non-destructive save is identical with and without the flag;
      • another failure carries no hint.
    • Two ablations were run: removing the forwarding gave 3F, and removing the hint gave 2F. Both restores were proven.
    • Local reconciliation: 64 families derived, 62 run, and 2 NOT MEASURED. Those two are whole-tree families (check:dual-build-cjs-loads, check:i18n-coverage), and CI's lanes on this head read them.
  4. Accepted deviations.
    • origin/main was not merged before opening. None of the 6 commits since the base touches these paths, and CI runs on the merge ref.
    • The throwaway base probe was never committed.
    • The first pr_create was refused by write-pace and retried once, as prescribed.
  5. Out-of-scope dispositions: none.
  6. Landing clauses.
    • (a) Every check is green or skipped by design: met.
    • (b) mergeable_state is clean at enqueue: met.
    • No CONTRACT_REVIEW_TIER review is owed: the widening's review is this record, at TIER_DEFAULT.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 14, 2026 16:57
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 14, 2026 16:57
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit 2811a63 Oct 14, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23480-meta-register-force branch October 14, 2026 17:31
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cli: os meta register cannot send the DESTRUCTIVE_CHANGE 409's ?force=true remedy — the CLI face of the #11095 / #11391 class

2 participants