Skip to content

Commit 2811a63

Browse files
feat(cli): os meta register takes --force, forwarded as the SDK's force (#23480) (#23519)
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](https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1f3ac4f commit 2811a63

3 files changed

Lines changed: 509 additions & 9 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
feat(cli): `os meta register` takes `--force`, so a CLI user can send the acknowledgement the `409 DESTRUCTIVE_CHANGE` refusal prescribes (#23480)
6+
7+
Clause-②: yes (widening)
8+
9+
**What is new.** `os meta register` declares a boolean `--force` flag, off by default and with no short alias. When given, it is forwarded as the SDK's `SaveMetaItemOptions.force`, sent as `?force=true`:
10+
11+
```bash
12+
os meta register object --data object-def.json --force # applies a save that drops or transforms existing data
13+
```
14+
15+
**Why.** The save door `PUT /api/v1/meta/:type/:name` refuses an `object` save that removes a field or changes a field's type incompatibly, with `409 DESTRUCTIVE_CHANGE` and a message ending "re-submit with ?force=true to proceed." The command had no way to send that parameter, so a CLI user was told to do something the CLI could not do.
16+
17+
**What a refusal now prints.** The server's message is passed through unchanged. On a run without `--force`, one more line names the flag: in the text output as a warning line under the error, and in the `--format json` envelope as a new `hint` key beside `error`, `code` and `httpStatus`. Every other failure is reported exactly as before, with no `hint`.
18+
19+
**Unchanged.** Without `--force`, the command sends the same request as before: no `force` parameter at all, never `?force=false`. A non-destructive save with `--force` lands exactly as the same save without it. `--force` combines with `--package`.

0 commit comments

Comments
 (0)