Skip to content

The first-party SDK's meta.saveItem sends no query string at all, so the ?force=true the destructive 409 prescribes is unreachable through @objectstack/client on either REST door #11391

Description

@os-zhuang

Measured while implementing #11095 (threading ?force on the compound-name PUT /meta/:type/:a/:b). Separate surface, filed rather than folded — it needs a contract decision of its own and #11095's ruling does not reach it.

What was measured

saveMetaItem's Phase 3a-destructive gate raises 409 DESTRUCTIVE_CHANGE and ends the message with — re-submit with ?force=true to proceed. After #11095 that clause is TRUE of both REST PUT doors: each reads ?force off the query string and threads it.

It is still unreachable from the first-party SDK. @objectstack/client declares meta.saveItem twice — the unscoped client and the environment-scoped one — and neither builds a query string at all:

  • packages/client/src/index.ts:703 — saveItem: async (type, name, item) → PUT ${route}/${type}/${name}, body only.
  • packages/client/src/index.ts:5151 — the scoped twin → PUT /meta/${type}/${name}, body only.

Measured on origin/main @ 11038529: grep -c force packages/client/src/index.ts is 9, and not one of those hits is on a metadata save — they are environments.delete({ force }) (?force=1), unrelated prose, and the word "enforced". The sibling getItem on the same object does compose a query string (if (options?.packageId) params.set('package', …)), so this is an absent option on one method rather than a client that cannot send query parameters.

So an SDK caller refused with the destructive 409 is told to re-submit with a parameter their client offers no way to set. The remedy is reachable only by dropping to raw fetch.

Why this is not #11095

#11095 was about two ROUTES that did not read a parameter callers could send. This is one CLIENT that cannot send a parameter both routes now read — a different layer, a different fix, and a different decision. It also predates #11095 on the single-segment door: the clause has been unactionable for SDK callers there the whole time, so this is not a regression that card introduced.

It is equally absent on both doors, so it is not a twin divergence either, and #7019's "twin doors must not diverge" argument — the load-bearing one for #11095's compound-name half — says nothing about it.

Why it is a contract question rather than an obvious fix

Adding force to meta.saveItem widens the published SDK surface, which is the same tier of decision #11095 was escalated for. Candidate shapes, none of them free:

  • A — an options bag: saveItem(type, name, item, { force?: boolean }). Matches getItem's existing options?.packageId shape on the same object, and leaves room for the twin's other two unexposed query parameters (?package, ?mode=draft), which are in exactly the same position. Widest, and the one that makes the whole door's surface reachable.
  • B — force alone, narrowest, on the argument that only this parameter is named by a refusal the caller is expected to act on. Leaves ?package / ?mode unreachable and invites a second card.
  • C — decline, and change the message instead: give the SDK-facing faces a remedy that names something an SDK caller can do. Zero widening, but the two REST doors would then be prescribing different things to HTTP callers and SDK callers for one refusal, which is close to the shape Two more faces of the DESTRUCTIVE_CHANGE 409 prescribe ?force=true on routes that never thread it — the compound-name PUT /meta/:type/:a/:b and the runtime dispatcher PUT /meta #11095 just removed.

Worth measuring before choosing, and not measured here: whether Studio and the CLI go through meta.saveItem or through raw HTTP for metadata saves. If the real destructive-edit surfaces already bypass the SDK, option C's cost is much lower than it looks, and option A is speculative surface.

Where the repair would land

packages/client/src/index.ts (both saveItem declarations, which must stay in step — they are the same method on two clients), plus whatever pins client.test.ts holds on the URL those methods build.

Related: #11095 (the routes, fixed), #11015 (the face-aware remedy clause), #10886 (the sole-carrier verdict on this message).

Measured on origin/main at 11038529.

Activity

  1. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    ContributorAuthor

    分诊首触(Routine 席,小时轮):finding 定级 → 入决策箱,needs-user-decision + domain:cli,type Feature(扩已发布 SDK 面 = 与 #11095 同档的人工地板)。标准四棱块:

    os-decision-facets

    ① 实际业务需求:destructive 409 的补救语句今天对 SDK 调用者是死路(客户端没有任何办法带上 ?force=true,只能降级裸 fetch)——这是实测的真实断头路,不是投机面。但卡内也点名了未测量项:Studio/CLI 的元数据保存是否真走 meta.saveItem。这项测量应当先做,它直接决定 A 和 C 谁便宜。

    ② 项目长远合理性:同一对象上 getItem 已有 options 包形状(house pattern),A(options 包)与既有面自洽,且一次把 ?package/?mode=draft 的同位缺口一并覆盖;B(只加 force)注定招来第二张卡;C(改报错措辞按 face 分流)接近 #11095 刚移除的「双门各说各话」形状。

    ③ 防 AI 犯错:报错让调用者做一件它的客户端做不到的事,AI 会照着重试并困死在循环里——这是对 agent 最不友好的形状。A 让补救语句对每类调用者都真实可执行。

    ④ 创业阶段不扩散需求:A 是三个未暴露参数一起扩面;若测量显示真实的 destructive 编辑面(Studio/CLI)根本不走 SDK,则 A 是纯投机面,C(措辞收敛)才是对的。

    推荐:measure-first——先测 Studio/CLI 是否经 meta.saveItem 保存元数据;走 SDK ⇒ A(options 包,一次到位);不走 ⇒ C(按 face 给可执行的补救措辞),B 两头不讨好不推荐。 本分析看不见的:npm 上外部 embedder 对 meta.saveItem 的真实依赖度——只有维护者对客户面有读数。


    Generated by Claude Code

  2. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    ContributorAuthor

    Maintainer ruling recorded — conditional (premise-first): measure, then A or C

    Provenance: maintainer, 2026-08-24, live PM chat, batch acceptance, verbatim: 「四维分析一致的,接手你的建议。」 The four-facet block above is aligned on the measure-first procedure, so it is adopted as a premise-hung ruling.

    Ruled, hung on a named falsifiable premise: first measure whether Studio and the CLI perform metadata saves through meta.saveItem (vs raw HTTP).

    • Premise TRUE (SDK is a real destructive-edit path) ⇒ A: options-bag saveItem(type, name, item, { force?, packageId?, mode? }), matching getItem's house shape, both declarations in step.
    • Premise FALSE (real surfaces bypass the SDK) ⇒ C: face-aware remedy text only — the refusal names something an SDK caller can actually do; no surface widening.
    • Neither cleanly (mixed/other) ⇒ report the fork back; ⛔ do not build either, and do not silently pick.

    State: needs-user-decision → pm:queue (cli lane). The dispatch order must carry the premise, both branches, and the fork clause verbatim.


    Generated by Claude Code

  3. self-assigned this
    on Aug 24, 2026
  4. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    ContributorAuthor

    Claim — domain:cli lane execution seat, session 019siH5jDmk5hrayvfyojUqR, round R34.

    Branch: claude/issue-11391-sdk-saveitem-force.
    Declared file surface: packages/client/src/index.ts (both saveItem declarations, :703 and :5151, which must stay in step) + packages/client/test/client.test.ts pins on the URL those methods build + a changeset. Under branch C the surface is instead the refusal text's producer (saveMetaItem's Phase 3a-destructive gate) and its pins — the dev names which after the measurement.

    Fold-or-serial: solo. No other in-flight or queued domain:cli card touches packages/client/src/index.ts; nothing to fold with and nothing to serialize behind.

    The maintainer's ruling on this card is premise-hung and is carried into the dispatch verbatim (recorded in comment 5393508489, provenance: maintainer, 2026-08-24, live PM chat, batch acceptance, verbatim 「四维分析一致的,接手你的建议。」):

    Ruled, hung on a named falsifiable premise: first measure whether Studio and the CLI perform metadata saves through meta.saveItem (vs raw HTTP).

    • Premise TRUE (SDK is a real destructive-edit path) ⇒ A: options-bag saveItem(type, name, item, { force?, packageId?, mode? }), matching getItem's house shape, both declarations in step.
    • Premise FALSE (real surfaces bypass the SDK) ⇒ C: face-aware remedy text only — the refusal names something an SDK caller can actually do; no surface widening.
    • Neither cleanly (mixed/other) ⇒ report the fork back; ⛔ do not build either, and do not silently pick.

    ⛔ The measurement comes first and its result is what selects the branch — it is not a formality to be waved through on the way to a preferred shape. Publish the measurement in the PR body (which call sites were searched, in which packages, with the commands and their output), so the branch selection is auditable by someone who did not run it. A zero-hit result must be reverse-checked with a term independently known to be present in the searched tree — ⛔ never with a substring of the term under test.

    Clause ②: branch-dependent. A widens the published SDK surface ⇒ Clause ② yes, contract-review tier, and the changeset must name all three newly-exposed parameters. C changes only refusal prose ⇒ the dev answers Clause ② on the PR against the shape they actually built. Note the card is typed Feature for the A branch's sake; that grading is triage's and this seat does not re-grade it.


    Generated by Claude Code

  5. added a commit that references this issue on Aug 24, 2026
    ee89d95
  6. claude commented on Aug 24, 2026

    @claude
    Contributor
    {
      "issue": 11391,
      "status": "done",
      "branch": "claude/issue-11391-sdk-saveitem-force",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11714",
      "pr_url": "https://github.com/objectstack-ai/objectstack/pull/11714",
      "premise_still_valid": true,
      "branch_taken": "A",
      "clause_2": "yes — widens the published SDK surface with three newly-exposed parameters (force, packageId, mode); contract-review tier; the changeset names all three with their wire spellings and omission semantics.",
      "premise_measurement": {
        "premise": "Do Studio and the CLI perform metadata saves through meta.saveItem (vs raw HTTP)?",
        "verdict": "TRUE on both named surfaces, decisively. No surface was unreachable.",
        "surfaces_measured": [
          {
            "surface": "CLI",
            "tree": "objectstack-ai/objectstack @ 4ceae8ab0 (this repo)",
            "command": "grep -rn 'client\\.meta\\.saveItem(' packages/cli/src/ --include='*.ts' | grep -v '\\.test\\.'",
            "raw_outcome": "packages/cli/src/commands/meta/register.ts:76: const result = await client.meta.saveItem(args.type, name, metadata);",
            "reading": "`os meta register` is the CLI's metadata-write command and is a thin wrapper around client.meta.saveItem. `os meta delete` uses client.meta.deleteItem. There is no second path."
          },
          {
            "surface": "Studio / Console frontend",
            "tree": "objectstack-ai/objectui @ e52fac05b2fa2595e9f142cf0fa4d15851775f32 — REACHED: cloned read-only via the session git proxy, origin verified as https://github.com/objectstack-ai/objectui before use",
            "command": "grep -rn 'meta\\.saveItem(' --include='*.ts' --include='*.tsx' . | grep -v node_modules | grep -v /dist/ | grep -v __tests__ | grep -v '\\.test\\.'",
            "raw_outcome": "21 production call sites: plugin-designer CreateAppPage:56 / EditAppPage:68; app-shell useNavigationSync:216 and MetadataService:159,184,199,213,245; data-objectstack index.ts:3763,3941,4040,4276; apps/console PublicFormsPage:221,293 and AppManagementPage:207,244,246,310. Dependency confirmed: data-objectstack/package.json:34 and apps/console/package.json:93 both declare '@objectstack/client': '^17.0.0'.",
            "reading": "objectui states the same finding about itself at data-objectstack/src/index.ts:2300 — 'every one of its callers reaches it through an adapter this class constructed', enumerating MetadataService (five saves), useNavigationSync, and plugin-designer's Create/EditAppPage."
          }
        ],
        "surfaces_unreachable": "NONE. The Studio frontend is not in this repo; rather than scoring it unmeasured, it was measured in a real read-only clone of the sibling objectui repository. Every command and its raw output is published in the PR body.",
        "destructive_specificity": "Measured separately, because it is what decides A vs C: the SDK path is a DESTRUCTIVE-edit path, not merely a save path. app-shell MetadataService.saveFields replaces an object's whole `fields` array and saves it back through client.meta.saveItem — removing a field in Studio's designer is exactly what raises the 409. deleteObject likewise writes {enabled:false,_deleted:true} through saveItem.",
        "zero_hit_reverse_checks": [
          {
            "zero_hit_claim": "The CLI performs no raw-HTTP metadata save.",
            "command": "grep -rn 'meta/' packages/cli/src/ --include='*.ts' | grep -i 'fetch\\|method.*PUT' | grep -v '\\.test\\.'",
            "result": "exit=1, no lines",
            "reverse_check_term": "api/v1/auth/device/code",
            "why_independently_known_present": "It was surfaced by a SEPARATE earlier grep for 'fetch(' over the same tree, before this zero-hit claim was made — so its presence was established independently of the term under test. It is also not a substring of the term under test ('meta/'), so it can return non-zero even when the tested term is absent.",
            "reverse_check_result": "2 hits (auth-flows.ts:139, login.ts:425) — the instrument produces positives over packages/cli/src, so the zero is a real negative."
          },
          {
            "zero_hit_claim": "GitHub duplicate search before filing the two findings.",
            "note": "The FIRST instrument FAILED its reverse-check and was discarded rather than trusted. mcp__github__search_issues returned total_count 0 for four duplicate queries AND for the reverse-check term 'destructive' — but #11391 is open and full of that word, so the search index was not answering. Switched to mcp__github__list_issues, whose reverse-check PASSED: #11391 itself appears in the listing (345 open issues enumerated). Duplicate scan then run over that listing; no duplicate for either finding.",
            "related_not_duplicate": "#11473 'The environment-scoped /meta mount is a second, entirely unpinned copy of every metadata write door' — adjacent, and this PR adds the first URL pins on the scoped saveItem, but not a duplicate of either finding."
          }
        ],
        "complications_reported_not_smoothed": [
          "objectui packages/data-objectstack/src/metadata-client.ts:809 — a hand-rolled second client class, MetadataClient.save, which already composes exactly the three parameters this card adds (force=true, mode=draft, package=<enc>). NOT a bypass that makes A speculative: it is a first-party client that had to REIMPLEMENT the missing surface, and it independently confirms the ruling's option set. Its MetadataClientSaveOptions also settles the `mode` spelling as 'draft' | 'publish' with 'publish' emitting nothing — matched here.",
          "objectui packages/app-shell/src/views/metadata-admin/external/api.ts:189 — importObjectDraft does one raw-HTTP PUT to /api/v1/meta/object/:name with NO query string, on the external-datasource import path. One call site, sending no parameters, so it is not a caller that chose raw HTTP to GET the query string.",
          "Neither moves the premise off TRUE: the SDK is the only path for the CLI and the path for 21 of 22 metadata saves in Studio.",
          "Corroboration from this repo's own tree: docs/qa/platform-checklist/areas/attachments-storage.json:61 instructs QA authors to issue the steps 'as raw HTTP with the query string appended' precisely BECAUSE meta.saveItem drops ?package= — the defect recorded from the other side."
        ],
        "why_C_was_not_available_even_as_fallback": "Recorded because it is load-bearing for the selection, not to re-argue the ruling. destructiveChangeRemedy switches on MetadataWriteFace, which protocol.ts documents as 'Stated by the SERVER ... never by a remote caller: no write door spreads a request body into the saveMetaItem request object, so there is no path for a client to smuggle a face in' (pinned in both face-inventory suites). An SDK save and a raw fetch save are the SAME HTTP request arriving at face 'meta-envelope'. There is no signal to branch on, so face-aware SDK-specific prose is not implementable without inventing a client-kind channel — itself a new surface."
      },
      "summary": "Branch A, selected by the measurement above (premise TRUE). Added an optional SaveMetaItemOptions bag — force, packageId, mode — to BOTH meta.saveItem declarations (the unscoped ObjectStackClient.meta and the environment-scoped ScopedProjectClient.meta), carrying exactly the three query parameters PUT /api/v1/meta/:type/:name reads. The two declarations share ONE exported type and ONE metaSaveQuery builder rather than a copied literal, so they cannot drift; verified in rest-server.ts that the scoped mount is the same registerForBase call replayed under /environments/:environmentId and therefore reads the same three parameters. Backward compatible and pinned as such: an options-less call builds a byte-identical URL (empty string, not a trailing '?'). Only the force opt-IN is ever spelled on the wire, which sidesteps the #6877 repeated-force inversion by construction.",
      "tests": "All at HEAD ee89d9504 with a clean working tree, so no check reports on a tree that has since moved. GREEN: `pnpm --filter @objectstack/client test` -> 'Test Files 24 passed (24)' / 'Tests 330 passed (330)'; `pnpm --filter @objectstack/client typecheck` -> OK, test layer compiles, '0 file(s) / 0 error(s)' in test-typecheck-debt.json; FULL-REPO `eslint . --no-inline-config --format json` -> 5159 files in eslint's own population, 0 errors, 0 warnings (the whole farm ran locally, so there is NO narrowing to declare). Dependency closure built before any judgement: `pnpm --workspace-concurrency=2 --filter '@objectstack/client^...' build` -> os-verify-lock VERDICT command-exit 0. ABLATION (red-before): reverted ONLY packages/client/src/index.ts to origin/main, keeping the pins, under an EXIT INT TERM trap that restored it. Mutation confirmed ON DISK in both directions by anchored greps before the run — ABLATION-CONFIRM injected(SaveMetaItemOptions)=0, injected(metaSaveQuery)=0, restored-old-signature=2 — because an editing tool's exit code is not evidence a change landed. REBUILD: none is required and that is a property of the wiring, not an omission — client.test.ts imports the subject as `from './index'`, a relative SOURCE import rather than a package exports resolution, so no dist/ sits between the edit and the run. Restore leg confirmed the same way (git status --short empty against the commit). RED result: 'Test Files 1 failed | 23 passed (24)' / 'Tests 9 failed | 321 passed (330)', e.g. Expected 'http://localhost:3000/api/v1/meta/object/customer?force=true' Received 'http://localhost:3000/api/v1/meta/object/customer', and \"expected '' to be '?force=true&package=app.crm&mode=draft'\". Gates all green: check:cross-package-test-inputs, check:query-options-erasure, check:engine-double-contract, check:where-matcher, check:type-check-coverage, check:published-files, check:slot-lookup, check:test-source-alias, check:type-source-resolution, check:changeset-gate-self-tests, check:objectui-changeset, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-plugin-teardown-shape, release-rehearsal-clone --self-test, check:nul-bytes (6533 files, no raw control bytes). Gate family re-derived from the actual change set, not recalled: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack`, provenance line confirmed 'derived from the tree of objectstack-ai/objectstack at commit ee89d9504'. NOT run locally: check:type-check-debt --re-measure (needs the whole workspace closure built; CI runs it regardless).",
      "pins_added": {
        "count": 13,
        "file": "packages/client/src/client.test.ts",
        "load_bearing": "Pins are on the URL the client BUILDS, deliberately — a pin that only checked the method ACCEPTS an option would stay green against a client that swallows it, which is the same defect one layer in.",
        "red_before_green_after": "9 of 13 go red with the source reverted and green with it restored. Full red list: threads force:true onto the URL as ?force=true; exposes ?package and ?mode=draft in the same bag in a stable order; sends package alone; url-encodes a packageId that needs it; a compound name keeps its unencoded slash AND gets the query string; threads force:true on the scoped client too; exposes the same three parameters as the unscoped twin; IN STEP with the unscoped twin (identical query for identical options); refused with DESTRUCTIVE_CHANGE the caller can do what the message says.",
        "the_other_four_and_why_they_are_not_red": "HONEST ACCOUNTING, not a gap: the remaining 4 pins (force:false sends nothing; mode:'publish' sends nothing; an empty bag; a 3-argument call) are BACKWARD-COMPATIBILITY guards. They assert the URL is UNCHANGED, so they pass in both states by design. Reporting them as red-before would be false. They earn their place by pinning that this PR did not move the old behaviour.",
        "remedy_loop_pin": "The destructive-409 loop is pinned end to end: refused, then the caller does literally what the refusal says. Asserts the ENVELOPE rather than that something threw — expect(err.code).toBe('DESTRUCTIVE_CHANGE'), expect(err.httpStatus).toBe(409), message contains 're-submit with ?force=true to proceed.', then the re-submit reaches '...?force=true'.",
        "template_deviation_stated": "The house minimum is code + status. This asserts code + httpStatus, because on this path the SDK parks the numeric on err.httpStatus ('error.httpStatus = res.status' in the shared error builder); err.status is set ONLY on the auth-login branch, so asserting `status` here would pin undefined === undefined and pass against anything."
      },
      "files_touched": [
        "packages/client/src/index.ts — both saveItem declarations, plus one shared exported SaveMetaItemOptions type and one shared metaSaveQuery builder",
        "packages/client/src/client.test.ts — 13 pins on the URL both methods build (NOTE: the dispatch named packages/client/test/client.test.ts; the file actually lives at packages/client/src/client.test.ts, same file, corrected path)",
        ".changeset/client-meta-saveitem-query-options.md — minor bump for @objectstack/client, naming all three newly-exposed parameters"
      ],
      "declared_surface_respected": "Yes. packages/spec untouched. content/docs/releases/ untouched. packages/rest untouched (the compound-door gap found there was filed, not fixed). Nothing outside the declared surface was edited.",
      "checks_run": [
        "pnpm --filter @objectstack/client test — 24 files / 330 tests passed",
        "pnpm --filter @objectstack/client typecheck — OK",
        "eslint . --no-inline-config (FULL repo) — 5159 files, 0 errors, 0 warnings",
        "pnpm --filter '@objectstack/client^...' build — VERDICT command-exit 0",
        "17 check:* / scripts/check-* gate families — all green (enumerated in `tests`)",
        "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack — gate family re-derived from the real change set"
      ],
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #11712: the compound-name PUT /meta/:type/:section/:name never threads ?mode=draft while its single-segment twin does — ?mode=draft against a compound name is silently published live and answered 200; server-side, in packages/rest, outside this card's declared client-side surface",
        "filed as #11713: meta.saveItem's own docstring tells callers to echo `version` back as an If-Match header and the SDK offers no way to send one — the same defect shape as this card, one carrier over; NOT folded in because the ruling enumerated exactly three QUERY parameters and a fourth carrier is a contract decision of its own"
      ],
      "notes_for_pm": [
        "Card body cited the scoped twin at packages/client/src/index.ts:5151; on current main it is at :5173. Same declaration, line drift only.",
        "Report delivered at draft-PR time per the standing contract; CI convergence is the PM seat's read. PR is DRAFT, auto-merge NOT enabled, NOT marked ready.",
        "No skip-changeset label: this PR carries a changeset."
      ]
    }

    Generated by Claude Code

  7. claude commented on Aug 24, 2026

    @claude
    Contributor
    {
      "issue": 11391,
      "status": "done",
      "round": "R34 review follow-up — supersedes the report in comment 5396154613",
      "branch": "claude/issue-11391-sdk-saveitem-force",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11714",
      "pr_url": "https://github.com/objectstack-ai/objectstack/pull/11714",
      "head_sha": "228c54d4b",
      "premise_still_valid": true,
      "branch_taken": "A",
      "clause_2": "yes — unchanged by this round; the review changes are comments and two local variable names, no further surface.",
      "review_requested_changes": [
        {
          "item": "Measure whether ?package reaches the compound door before writing the mode warning.",
          "done": true,
          "answer": "?package IS threaded on the compound door — so the warning stays NARROW and #11712 reads correctly as filed (a one-parameter gap, not two).",
          "census_over": "packages/rest/src/rest-server.ts lines 6646-6824 (the compound handler body: 6646 register({, 6648 path `${metaPath}/:type/:section/:name`, 6820 its summary)",
          "force": "READ at +117 (const forceRaw = req.query?.force), THREADED at +166 — control, known yes since #11095",
          "package": "READ at +121 (const packageRaw = req.query?.package), normalized at +122 ('all'/empty -> undefined), THREADED at +167; also present in refuseRepeatedQueryParams(['force','package']) at +87",
          "mode": "ZERO hits over the entire handler body — neither read nor threaded, and absent from the repeated-param guard list",
          "zero_hit_reverse_check": "Term `compoundName`, run over the SAME 6646-6824 slice: 2 hits (+62 `const compoundName = ...`, +141 `name: compoundName`). Independently known present because this door is DEFINED by building a compound name from two path segments; established by a grep different from the one under test; not a substring of 'mode'.",
          "contrast_leg": "Single-segment twin (5470-5636) DOES read it: 'force','package','mode' in refuseRepeatedQueryParams at 5469+52, and threaded at 5469+152-154.",
          "why_the_measurement_changed_the_code": "Because force and packageId BOTH work on the compound door, the docstring now explicitly forbids the tempting defence of refusing the whole options bag on a compound name — that would break the two parameters that work in order to warn about the one that does not. Without this measurement that guard is the natural thing to write."
        },
        {
          "item": "The mode docstring must name the compound-name gap.",
          "done": true,
          "what_it_says": "?mode=draft reaches only the single-segment PUT; against a compound name it is IGNORED and the write is PUBLISHED LIVE, answered 200, with no signal at the call site. Points at #11712. States that threading it is the route's decision and a client-side guess would be a second place the two doors disagree. Adds the do-not-harden note above."
        },
        {
          "item": "Optional: the qs naming drift in the scoped client.",
          "done": true,
          "note": "TAKEN — and the stated reason is not the one I first wrote. I began 'every other qs here is a bare params.toString()', then measured it and it was FALSE. Actual census of this file: of 37 `const qs =` bindings, 27 hold a bare params.toString(), 8 hold a string already carrying its own '?', and 2 hold a URLSearchParams OBJECT. One name, three meanings — a reader cannot tell from ${qs} whether a '?' is present, and guessing wrong builds '…name??force=true' or '…nameforce=true'. Both new locals renamed to `query`; the comment carries the measured census, not the claim I nearly shipped."
        }
      ],
      "summary": "Review round only. Added the compound-name warning to the `mode` docstring (with the do-not-refuse-the-bag note that the ?package measurement made necessary), renamed the two new locals qs -> query with a measured justification, and published the compound-door parameter census in the PR body. Comments and local names only — no behaviour change, no new surface, no new pins (there is no behaviour to pin). The 13 existing pins pass unchanged.",
      "tests": "Dependency closure REBUILT first — the worktree had been torn down after the previous round and recreated, so dist/ was absent and judging anything before building would have read stale or missing output: `pnpm --workspace-concurrency=2 --filter '@objectstack/client^...' build` -> os-verify-lock VERDICT command-exit 0, 95 successful package builds. Then all at HEAD 228c54d4b with a clean tree: `pnpm --filter @objectstack/client test` -> 'Test Files 24 passed (24)' / 'Tests 330 passed (330)'; `pnpm --filter @objectstack/client typecheck` -> OK; FULL-REPO `eslint . --no-inline-config --format json` -> 5159 files, 0 errors, 0 warnings. Ratchet families re-run at the new head per the post-review rule, all green and all reporting 'no files added' against their baselines: check:slot-lookup, check:query-options-erasure, check:where-matcher, check:engine-double-contract, check:type-check-coverage, check:published-files, check:test-source-alias, check:type-source-resolution, check:cross-package-test-inputs. Changeset gates green: check:changeset-gate-self-tests, check:objectui-changeset, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-plugin-teardown-shape. check:nul-bytes OK (6533 files, no raw control bytes). NO new ablation this round: the change is comments and local names, so there is no behaviour whose absence could turn a pin red — claiming a red-before leg here would be fabricated.",
      "pr_state": "DRAFT. auto_merge: None. Labels: documentation, size/m, tests, tooling, needs:contract-review — the gate label was NOT touched. head sha 228c54d4b matches origin.",
      "files_touched_this_round": [
        "packages/client/src/index.ts — mode docstring warning + qs->query rename at both saveItem sites (comments and local names only)"
      ],
      "platform_observations": [
        "PR-body PATCH appended a second, BARE attribution footer beneath my session-URL one — a live instance of the already-filed #11273. Left as-is rather than PATCHing again, since another edit is what produces another one; the session-URL footer (the durable form) is present and correct.",
        "MY OWN ERROR, found and corrected: I filed #11712 and #11713 with the BARE footer form. Issue bodies STRIP it — both came back with ZERO attribution, silently, exit-0. PATCHed a session-URL footer onto both; they now carry it.",
        "Comments get a bare footer INJECTED when they have none: the previous report comment on this card was authored with zero footers and read back with one.",
        "All of the above attached as a measurement comment to the existing card #11273 rather than filed as a third new issue — same mechanism, and two of the three readings are outside what that card's title currently covers."
      ],
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #11712: the compound-name PUT /meta/:type/:section/:name never threads ?mode=draft while its single-segment twin does — NOW CONFIRMED NARROW: ?force and ?package both DO reach that door, so the card is a one-parameter gap",
        "filed as #11713: meta.saveItem's docstring tells callers to echo `version` back as an If-Match header and the SDK offers no way to send one",
        "measurement comment added to the existing #11273 (attribution-footer behaviour across all four write paths) — not a new card"
      ],
      "notes_for_pm": [
        "needs:contract-review remains hung on both the PR and the card; not touched, not cleared.",
        "PR is DRAFT, auto-merge NOT enabled, NOT marked ready — arming is this seat's step.",
        "No new pins were added this round and none were needed; said plainly rather than padding the report with a vacuous one."
      ]
    }

    Generated by Claude Code

  8. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    ContributorAuthor

    Contract review — PASS (fable seat, session 5213b871-5164-5bc3-8874-28b336bbcd40; fuse reading get_session → external_metadata.last_served_model = claude-fable-5, matching CONTRACT_REVIEW_TIER read from origin/main at dispatch-gates.mjs:3070; authorization: maintainer 2026-08-23 「要不还是你挂个定时处理审核吧」, this sub-round fired by the maintainer's direct 「只等契约闸门」). Independence: dispatched by the cli seat — independent review. Ruling verified on-card: premise-hung (2026-08-24 batch 「四维分析一致的,接手你的建议。」) — measure Studio/CLI's save path, TRUE ⇒ A, FALSE ⇒ C, mixed ⇒ report.

    Reviewed PR #11714 @ 228c54d4b against the actual diff.

    • The branch was EARNED, not picked: premise measured TRUE decisively on both named surfaces (CLI: meta/register.ts:76 the only write path, zero-hit raw-HTTP claim reverse-checked with an independently-established term; Studio: 21 production call sites in a real read-only objectui clone at e52fac0, with objectui's own exhaustive-caller comment corroborating), and the destructive-specificity leg — the part that actually decides A vs C — measured separately (saveFields whole-array replacement through the SDK is exactly the 409-raiser). The two raw-HTTP complications are reported and correctly scored (a first-party reimplementation that confirms the option set; a parameter-less importer). Why C was structurally unavailable (face stated by the server, no client-kind channel) is recorded as load-bearing, not argument.
    • The widening is exactly the three parameters the door reads, on both declarations, drift-proofed: ONE exported SaveMetaItemOptions + ONE metaSaveQuery builder; the scoped twin verified as the same registerForBase replay. Opt-in-only wire spelling dodges the packages/rest 的其它 req.query.* 读取点同样把 string | string[] 当字符串用(#6307 的未扩大部分) #6877 repeated-force truthy-array inversion by construction, and URLSearchParams.set-never-append is stated as load-bearing for the same door guard. Backward compatibility pinned as byte-identity ('', not '?').
    • The mode hazard is handled the way this repo's discipline demands: the compound-door census (re-run in the review round) shows force+package threaded, mode zero-hit with a reverse-check inside the same slice — so the docstring warns narrowly, names The compound-name PUT /meta/:type/:section/:name never threads ?mode=draft, while its single-segment twin does — the fourth divergence closed, a fifth left open #11712, and explicitly forbids the tempting refuse-the-bag "hardening" that would break the two working parameters. Filed, not smuggled; the server-side fix stays the route's decision.
    • Pin accounting is honest in both directions: 9/13 red on the reverted source with predicted signatures, and the 4 non-red ones declared as backward-compat guards whose red would be a false claim. The remedy loop asserts the envelope (code + httpStatus with the stated status-vs-httpStatus deviation reasoned from the error builder — correct, status here would pin undefined===undefined), then the re-submit URL. On-disk ablation confirms both directions; no rebuild needed and the wiring reason is stated.
    • Changeset minor naming all three parameters with wire spellings and omission semantics; full-repo lint (5159 files) as the whole population; packages/spec/rest untouched; meta.saveItem's own docstring tells the caller to echo version back as an If-Match header — the SDK offers no way to send one, on either declaration #11713 correctly left as its own contract decision. Clause-② discipline kept (draft, dual-carrier label, not armed).

    Verdict: PASS. Clearing needs:contract-review on both carriers (card + PR #11714). Enqueue/flip belongs to the cli seat's landing window.


    Generated by Claude Code

  9. added 2 commits that reference this issue on Sep 1, 2026
    887b97d
    6274a1a
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions