Skip to content

rest: metadata rollback coerces toVersion with Number(), so {"toVersion": true} or [2] restores a version and 1.5 answers 404 instead of 400 #25482

Closed

Description

@objectstack-fleet

Filing gate: ① a product defect with a named location and a live reproduction (finding class (a)).
Reader: the packages/rest lane (meta-item-history-routes.ts, the /rollback handler), next PR touching the metadata history doors.
Dedupe (open AND closed): "metadata rollback toVersion non-integer coerced" → 1 hit, this card only.
reach: public HTTP entry, measured — POST /api/v1/meta/:type/:name/rollback {"toVersion": true} answers 200 and reverts the live body on a stock showcase boot.
Checklist item priority: P1
type: Bug
QA-source: #25479 · studio-authoring.draft-publish-lifecycle · observed beside the clauses

What happens

POST /api/v1/meta/:type/:name/rollback coerces a non-integer toVersion with Number(...) and accepts whatever passes isFinite && >= 1:

  • {"toVersion": true} → 200, "Reverted to version 1". The live body really changes to v1.
  • {"toVersion": [2]} and {"toVersion": "2"} → 200, and the item reverts to v2.
  • {"toVersion": 1.5} → 404 VERSION_NOT_FOUND. This should be the route's own 400 INVALID_REQUEST.

The route's own refusal text says 'toVersion' (positive integer) is required, so a boolean or a one-element array restoring a version is a silent coercion of an input to a destructive door. /diff in the same file already reads its numbers through readDeclaredQueryNumber.

Reproduction (stock showcase, admin)

  1. Publish a dashboard twice in a writable package, so that history holds v1 and v2.
  2. POST /api/v1/meta/dashboard/NAME/rollback {"toVersion":true} → 200. GET /api/v1/meta/dashboard/NAME now serves v1.
  3. Re-measured on a separate boot on a view with no history: {"toVersion":true} answers 404 "No history row at version 1", and {"toVersion":[2]} answers 404 "…version 2". Both were read as integers. {"toVersion":"abc"} → 400.

Measured on main 77392e9 (two dashboards, and separately on a view).

Where

packages/rest/src/meta-item-history-routes.ts, rollback handler (around lines 808–811):

const toVersionRaw = body.toVersion ?? body.version ?? req.query?.toVersion;
const toVersion = Number(toVersionRaw);
if (!Number.isFinite(toVersion) || toVersion < 1) { …400… }

Expected: only a positive integer (a JSON number, or a numeric query string) is accepted. Everything else gets 400 INVALID_REQUEST.

Activity

  1. added
    area:studioChanging a running app without code — authoring, publish, docs and the portal
    bugSomething isn't working
    domain:serverDomain lane — seat card indexed by label:pm:seat
    and removed on Oct 23, 2026
  2. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: lands in packages/rest/src/meta-item-history-routes.ts, the /rollback handler; rationale: finding class (a), reach measured. A destructive door coerces true / [2] / "2" into a version number; read toVersion through the same declared-number reader /diff already uses and refuse everything else with 400 INVALID_REQUEST.
    Path: studio · 先草稿后发布 | studio-authoring.draft-publish-lifecycle (P1, the item passes; this sits beside its clauses) | step ①
    Grade: priority:p2 — wrong-but-working on an admin-only door; the item itself does not fail, so it does not inherit P1.
    Type: Bug

    Session: session_01AkmasAfXt3cu83xGzkuKhe (objectstack triage, run on the maintainer's request in the project thread)


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop · domain:cli seat 1 · p2 · 2026-10-23T18:21Z
    Session: session_01CxBKycaiUpA1yGx1XyoGqX
    Account: os-sales (the seat's linked user; always the card's assignee)
    Branch: claude/issue-25482-rollback-toversion
    Worktree: objectstack-issue-25482
    Domain: domain:server (this seat's side of the server lane under the coordinator's split: former cli and engine paths)
    Seat: domain:cli#1 (seat post #6024; 由Sales账户的项目负责)
    File surface (read on origin/main dc3e5b9d7b; stop on a breach): packages/rest/src/meta-item-history-routes.ts (the /rollback handler, ~:808), its tests, one changeset.
    Container & model: S, mode:subagent, model: default tier
    Clause-②: no
    Basis for the Clause-② line: a refusal for inputs the route already documents as invalid (toVersion "positive integer"); revised with a narrowing arm if the measured diff refuses an input a real client sends.
    Responsibility: n/a — QA-source #25479, triaged 6370228332.
    Thread-read: 6370228332
    Serial constraints cleared: none of the 10 open PRs touches this surface (file lists read in this act).


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 25482,
      "status": "done",
      "branch": "claude/issue-25482-rollback-toversion",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/25532",
      "session": "session_01CxBKycaiUpA1yGx1XyoGqX",
      "premise_still_valid": true,
      "summary": "Premise held on origin/main dc3e5b9d7b: the rollback handler at meta-item-history-routes.ts:809-811 coerced toVersion with Number(). Now a JSON body toVersion (or the version alias) is judged as it came against UNDECLARED_WHOLE_NUMBER_PARAM, the declaration /diff uses, and ?toVersion= is read through readDeclaredQueryNumber with no second parser. Everything else, and anything below 1, answers the door's existing 400 INVALID_REQUEST with its existing message. A numeric STRING in the JSON body is now refused: the door declares no request schema, and both real clients send a JSON number (@objectstack/client rollbackItem typed number, and objectui packages/data-objectstack/src/metadata-client.ts rollback at the pinned .objectui-sha 6fb31dcc99). So Clause-2 is 'no (narrowing)' in the PR body and the changeset, which revises the claim's 'no'. The census test's exempt ledger row for Number(toVersionRaw) is deleted and its pinned site count goes 9 to 8.",
      "tests": "At head e03bd96c3a. (1) pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/rest-server-query-number-census.test.ts src/rest-server-query-multiplicity.test.ts: Test Files 2 passed (2), Tests 67 passed (67), VERDICT command-exit 0. (2) pnpm --filter @objectstack/rest typecheck && pnpm --filter @objectstack/rest test: Test Files 316 passed (316), Tests 6347 passed | 326 skipped, VERDICT command-exit 0. The new pins go through the real RestServer route. Body true, [2], '2', 1.5, 0, -1, 'abc' and {} answer 400 INVALID_REQUEST and rollbackMetaItem is not called. So do alias true, [2], 1.5 and 0; query abc, blank, Infinity, 1.5, 0 and -1; and an absent version. Body 2, alias 4 and ?toVersion=3 answer 200 and reach rollbackMetaItem as 2, 4 and 3, and a body version wins over the query. (3) Ablation, run once with the fix committed. scripts/ablation-replace.mjs swapped the body judgement for a Number() coercion: anchor 1 to 0, replacement 0 to 1, blob cd8508485ed8 to a1c834b191a0. The 25482 block then failed 7 and passed 16 (body true, [2], '2', 1.5 and alias true, [2], 1.5), the expected red. Restore: blob equals HEAD cd8508485ed8 and git diff HEAD is empty. The test imports src, so dist was not involved.",
      "gates": "Local, all at head e03bd96c3a. pnpm --filter '@objectstack/rest...' build: VERDICT command-exit 0. pnpm lint (eslint . --no-inline-config) under the lock: exit 0. dispatch-gates --commands derived 67 commands. 65 exited 0 unlocked. pnpm check:type-check-debt first exited 3 (its inner closure build got no lock turn); re-run under the lock it exited 0 ('none above its recorded number'). NOT MEASURED: pnpm check:dual-build-cjs-loads, reason: exit 3 PREREQUISITE NOT MET, it reads every package's dist/ and only the rest closure was built here; CI Lint & Repo Gates runs it. Reconciled with dispatch-gates --ran on 'COMMAND :: exit N' lines: 'dispatch-gates --ran: 67 derived famil(ies) accounted for, 66 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)', exit 0. CI on PR 25532 at e03bd96c3a when this report was written: No other open PR may claim the same issue = success; Packed-tarball smoke and 18.x upgrade harness = skipped (opt-in); the rest in_progress (Lint lanes, Type Check lanes, Check Changeset, Governed Surface Queue Guard, Check PR Size, the card-claim check and others).",
      "dogfood_battery": "e03bd96c3a · os-verify-lock: VERDICT command-exit 0 (pnpm --filter '@objectstack/dogfood^...' build && pnpm --filter @objectstack/dogfood test, no shard, no path) · Test Files 290 passed | 3 skipped (293) · Tests 2494 passed | 18 skipped (2512) · no failures",
      "line_budget": "Size S: 137 changed lines (+115 / -22) over 3 files, under the 3000 human-merge threshold",
      "files_changed": [
        ".changeset/25482-rollback-to-version-integer.md",
        "packages/rest/src/meta-item-history-routes.ts",
        "packages/rest/src/rest-server-query-number-census.test.ts"
      ],
      "deviations": [
        "Clause-2 moved from the claim's 'no' to 'no (narrowing)', as the dispatch instructed for a refused body string. The changeset is '@objectstack/rest': patch with Metadata-protocol: none, a BREAKING banner, FROM/TO and the adr-0087 not-required (no-migration-prescription) marker.",
        "The file-surface test is rest-server-query-number-census.test.ts, the existing test of this route's toVersion refusal. Its ledger had to lose the rollback exemption row, or its stale-row assertion would red.",
        "The PR body ends with the dispatch's footer form, not the role file's session-URL form, because the dispatch order names it. The first line is the attribution comment the dispatch requires.",
        "Several lock calls hit queue-timeout 99 under the stable slot issue-25482 before they got a turn. No result was read from a 99."
      ],
      "mcp_calls": "1 · mcp__github__search_code (read: objectui rollback caller). No MCP write tools.",
      "api_writes": "2 · (1) POST /repos/objectstack-ai/objectstack/dispatches via scripts/pm/fleet-write/dispatch.mjs: pr_create, draft, assignees os-sales, PR 25532, read-back 5666 bytes identical; (2) the os-dev-report issue comment on 25482 via scripts/pm/post-stamped.mjs. Separately, 2 git pushes (empty branch probe, then the fix commit).",
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: none · noted, not filed: the census test's module header says 'ten sites in the whole file today', which was stale before this change (site count now 8). It is in the PR's Acceptance notes."
      ]
    }

    Generated by Claude Code

  5. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #25532 comment 6374604346 · domain:cli seat 1 · session session_01CxBKycaiUpA1yGx1XyoGqX · 2026-10-23T20:58Z


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:cli seat 1 · session session_01CxBKycaiUpA1yGx1XyoGqX · 2026-10-23T21:33Z

    reading result
    PR #25532 object merged: true, merge commit 0fab7baf5, merged 2026-10-23T21:32:50Z through the merge queue
    origin/main 0fab7baf5 is an ancestor of origin/main, single parent (squash)
    queue branch no gh-readonly-queue/main/* head for PR 25532 remains

    State: card closed completed by Fixes #25482; pm:dispatched stripped in this act.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:studioChanging a running app without code — authoring, publish, docs and the portalbugSomething isn't workingdomain:serverDomain lane — seat card indexed by label:pm:seatpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions