Skip to content

fix(client): the scoped SDK reads metadata.prefix off the advertised routes instead of restating /meta - #17122

Merged
os-project-manager merged 3 commits into
mainfrom
claude/issue-16675-scoped-meta-prefix
Sep 9, 2026
Merged

fix(client): the scoped SDK reads metadata.prefix off the advertised routes instead of restating /meta#17122
os-project-manager merged 3 commits into
mainfrom
claude/issue-16675-scoped-meta-prefix

Conversation

@claude

@claude claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #16675

Clause-②: no

ScopedEnvironmentClient restated /meta as a literal in all six of its metadata methods, so on a deployment that moved metadata.prefix the scoped half of one SDK called paths the server does not mount while the unscoped half of the same SDK called the right ones. The six now read the advertised value, exactly as their unscoped twins already do.

1. The probe first, not the edit — and it came back RED

Triage made this a hard condition: 「第一步是跑那条探针,⛔ 不是直接改代码 … 读数无论红绿都贴进 PR:绿了说明本卡的前提不成立,那也是有价值的结论」.

The probe is #14879's data-key fixture with the single substitution the card names — crud.dataPrefix becomes metadata.prefix — landed as packages/client/src/client.metadata-prefix.test.ts and committed BEFORE the implementation (692f7fb). Run against the untouched implementation at that commit, on a live server booted at metadata: { prefix: '/metadata' }:

 Test Files  1 failed (1)
      Tests  1 failed | 5 passed (6)

 FAIL > non-default prefix (/metadata) > all six scoped meta methods call the mounted path, not /meta
- Expected                                          + Received
-   ".../api/v1/environments/proj-alpha/metadata"    +   ".../api/v1/environments/proj-alpha/meta"
-   ".../metadata/object"                            +   ".../meta/object"
-   ".../metadata/object/task"                       +   ".../meta/object/task"
-   ".../metadata/object/task"                       +   ".../meta/object/task"
-   ".../metadata/object/task"                       +   ".../meta/object/task"
-   ".../metadata/object/task/history"               +   ".../meta/object/task/history"

The card's premise HOLDS. The two supporting legs passed in the same run and are what make that reading mean something: the raw-fetch leg proves /api/v1/environments/proj-alpha/meta really answers 404 on that deployment while the mounted path answers 200, and the discovery leg proves the server advertises routes.metadata = /api/v1/metadata. Per triage's stated escalation trigger, this reading is the one that lifts the card from priority:p3 — recorded here, not acted on: priority is the triage seat's to set.

After the fix, the same suite is 6/6 green.

2. Six call sites, not five — re-derived at this branch's own head, by symbol

Counted by this.url( inside the ScopedEnvironmentClient region (located by symbol, not by line number), which is the method triage prescribed after its own first attempt answered 1:

region this.url( total of those, /meta naive this.url('/meta
before 15 6 1
after 9 (/batch, /packages ×2, /automation ×6) 0 0

My count agrees with triage's 6 and not with the card's 5, and the naive single-quote query reproduces triage's wrong answer of 1 — five of the six are template literals. The replacement asserted total == 6 in the edit script and refused to write otherwise. The nine surviving this.url( sites are /batch, /packages and /automation, which are mounted off basePath directly and are not this card's key.

3. The negative control — the acceptance criterion, and proof it can distinguish

Triage: 「默认 metadata.prefix = '/meta' 的部署上,这 6 个方法的请求 URL 在改动前后逐字节相同。一个"总是从 discovery 拼"的实现会让第 1 条绿,同时让所有默认部署多依赖一次 discovery 往返。」

Two pinned legs, both full-string equality on all six URLs (never toContain — the realistic non-default value /metadata CONTAINS the conventional /meta, so a substring probe over this pair of values answers noise):

  • a connected client on a default-prefix server calls the six /meta URLs byte for byte;
  • an unconnected client calls the same six URLs, in exactly 6 requests, with no discovery request on the wire.

That second leg is the instrument for the hazard triage named, and it is the only one that can be: on a connected default deployment an always-from-discovery implementation still derives /meta, so the extra dependency is invisible there. It is visible only where the document is absent.

Both legs were then proven capable of failing, from the committed fix, each mutation proven on disk (anchor occurrence counts before/after plus a git hash-object blob that moved off the HEAD blob) and each restored with git checkout HEAD -- ... proven by an empty git diff HEAD, under an EXIT INT TERM trap with absolute paths:

ablation what it models non-default leg connected default unconnected default
pre-fix tree (692f7fb) today's hard-coded /meta RED green green
B — decline returns '' instead of the convention "always rebuilt out of discovery" green green RED
C — metaUrl takes _dataPrefix() wrong prefix accessor RED RED RED

Resolution note for the ablations: the suite imports ./index relatively, so the mutation reaches the subject from source with no dist/ in the path — there is no build state that could have made a mutated run falsely green.

4. The fix

_metaPrefix() is the exact sibling of the _dataPrefix() that #14879 landed in this same file, fallback discipline included — 「通告文档不能确定前缀时退回约定值,⛔ 不得抛错」:

  1. if routes.metadata already ends with the conventional /meta, that IS the prefix — taken first, which is both what makes the change incapable of regressing a working deployment and what keeps a default deployment free of any new dependency;
  2. otherwise routes.data supplies the second equation over the same realBase, and the common run cut back to its last / lands on the base boundary;
  3. anything else declines to /meta. An unconnected client never reaches a rule at all.

The six methods build through a new private metaUrl(), the sibling of the existing dataUrl(): base from _apiBase(), prefix from _metaPrefix(), environment segment between them.

The unscoped half is untouched — 「未加作用域的那一半 ⛔ 不要动」. Verified: getRoute('metadata') is read at the same sites as before and no unscoped method appears in the diff.

5. Clause-② re-derived from the DELIVERED diff

Delivered paths: packages/client/src/index.ts, packages/client/src/client.metadata-prefix.test.ts, .changeset/scoped-sdk-honours-metadata-prefix.md. Nothing under packages/spec/src/**, no *.zod.ts, no error code, no authorable key, and no accept-set of any route moves — this is client-side URL construction only, and it is a pull-back to MetadataEndpointsConfigSchema.prefix, which already declares .default('/meta'), and to routes.metadata, which discovery already advertises. SKILL.md :517: 条款②只指已发布契约面,拉回已声明契约不触它.

Clause-②: no, and stated on the visible facts rather than on a summary: the emitted packages/client/dist/index.d.ts does gain one line_metaPrefix(): string;, immediately beside the _dataPrefix(): string; that already ships — plus private metaUrl;, which carries no signature. No new top-level export, no signature moves, no wire payload member.

6. Verification

Run at 772ef35 unless noted.

  • Probe + siblingsclient.metadata-prefix (the new pin), client.data-prefix ([finding] The client SDK hard-codes /data/${object}… while crud.dataPrefix is live and discovery advertises routes.data = base + dataPrefix — a non-default prefix makes the SDK disagree with the mounts #14879's, to prove that key did not regress), client.environment-scoping, client-url-conformance: 4 files / 21 tests passed.
  • packages/client full suitepnpm --filter @objectstack/client test: 37 files / 468 tests passed.
  • packages/client typechecktsc --noEmit plus check:test-typecheck: green, and the latter reports the test layer compiles under tsconfig.test.json at 0 errors, so the new fixture is genuinely type-checked rather than silently excluded.
  • Dependency closurepnpm --filter '@objectstack/client^...' build: exit 0.
  • Gate families — derived from the real change set with scripts/pm/dispatch-gates.mjs (never a hand-written list), then reconciled with --ran: 58 derived, 58 run, 0 NOT-MEASURED, 0 UNRUN. Three needed a second look and none was a finding: check:skill-examples first returned a PREREQUISITE refusal on an unbuilt client-react/dist and is green after building it; check:type-check-debt first OOM'd at exit 3 ("nothing was measured") and is green re-run at a larger heap, reporting every ledger entry at or under its recorded number.
  • Lint — the repo-wide eslint . --no-inline-config, not a narrowing: 6419 files, 0 errors, 0 warnings, counted from --format json.
  • Docscontent/docs compared by tree object: 1a7f20d at the branch point and 1a7f20d at head, identical, so nothing under it moved and content/docs/releases/ was never touched. scripts/docs-audit/affected-docs.mjs names 18 pages from 7 anchors — not a structural zero — and none is falsified: every one documents the DEFAULT deployment's literals, which this change keeps byte-identical, and none of them claims the prefix is fixed.

One gate is NOT MEASURED and declared rather than counted as a pass: check:dual-build-cjs-loads returns its own exit 3 PREREQUISITE refusal here because 33 packages have no dist/ in this worktree, and it needs a repo-wide build. Its inputs are exports maps and emitted CJS bytes, neither of which this diff touches. CI runs it on a fresh full build.

7. Holder re-measurement — done at claim time, not inherited

packages/client/src/index.ts is the #12104 family's hard-serial hot file, so the dispatch's reading of it was re-measured rather than carried forward. Built from the open PR list (18 open PRs read from the REST pulls endpoint, never from remote branches, where merged-but-undeleted refs read as phantom holders), each PR's head diffed against its own merge-base:

验收备注

  • Triage's acceptance list is discharged item by item above: the probe first (§1, RED), all six sites (§2), _dataPrefix()'s shape including its fallback discipline (§4), the negative control (§3), the unscoped half untouched (§4), and dedup not redone (triage settled it; Four SDK metadata methods build compound-name URLs against routes mounted in one arity only — measured 404, and getHistory/deleteItem encode while getAudit/getReferences/diffItem/rollbackItem do not #12106 is a different failure on the same methods and is not addressed here).
  • Noted, not filed — the two prefix derivations now sit side by side and could drift. _dataPrefix() and _metaPrefix() are one algorithm with the roles of routes.data and routes.metadata swapped, and a future correction to the boundary arithmetic could land in one and not the other. Not extracted here on purpose: the card, triage and the dispatch all describe a second derivation of the same shape rather than a refactor, and rewriting a p2 fix that landed hours ago inside a p3 card trades a real risk for a stylistic gain. Successor: whichever PR next touches either derivation.
  • Noted, not filed — no page documents that these prefixes are configurable at all. Of the 18 pages the docs audit names, every one shows the conventional literals (/api/v1/meta, /api/v1/environments/:environmentId/meta) and none mentions metadata.prefix or crud.dataPrefix. It is a gap, not a falsification, it predates this change, and it is equally true of the data key, so it is not this PR's to close. Successor: whoever documents the RestServerConfig endpoint-prefix keys.

Generated by Claude Code

…16675)

The card's mandated first step: reuse #14879's data-prefix fixture with
metadata.prefix in place of crud.dataPrefix, and record the reading before
touching the implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/client, touching 7 documentable anchor(s).

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/api/environment-routing.mdx (via ObjectStackClient (symbol, a top-level class), /environments/{id} (route, a path literal in ObjectStackClient), /environments/{id}{metadata.prefix} (route, a path literal in ScopedEnvironmentClient))
  • content/docs/api/wire-format.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/concepts/north-star.mdx (via /environments/{id} (route, a path literal in ObjectStackClient), /environments/{id}{metadata.prefix} (route, a path literal in ScopedEnvironmentClient))
  • content/docs/deployment/publish-and-preview.mdx (via /environments/{id} (route, a path literal in ObjectStackClient), /environments/{id}{metadata.prefix} (route, a path literal in ScopedEnvironmentClient))
  • content/docs/deployment/single-project-mode.mdx (via /environments/{id} (route, a path literal in ObjectStackClient), /environments/{id}{metadata.prefix} (route, a path literal in ScopedEnvironmentClient))
  • content/docs/kernel/runtime-services/data-service.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/kernel/runtime-services/storage-service.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/protocol/kernel/http-protocol.mdx (via /environments/{id} (route, a path literal in ObjectStackClient), /environments/{id}{metadata.prefix} (route, a path literal in ScopedEnvironmentClient))
  • content/docs/protocol/kernel/metadata-service.mdx (via /environments/{id} (route, a path literal in ObjectStackClient), /environments/{id}{metadata.prefix} (route, a path literal in ScopedEnvironmentClient))
  • content/docs/protocol/kernel/realtime-protocol.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/ui/forms.mdx (via /environments/{id} (route, a path literal in ObjectStackClient), /environments/{id}{metadata.prefix} (route, a path literal in ScopedEnvironmentClient))

4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via /environments/{id} (route, a path literal in ObjectStackClient), /environments/{id}{metadata.prefix} (route, a path literal in ScopedEnvironmentClient))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/releases/v17/17-2.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/releases/v17/17-3.mdx (via ScopedEnvironmentClient (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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.

Coarse fallback — 14 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 8a70e1bf64e2a63a9a0c597e59524272e0903c8fpackageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 8a70e1bf64e2a63a9a0c597e59524272e0903c8f

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 8a70e1bf64e2a63a9a0c597e59524272e0903c8f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-project-manager
os-project-manager marked this pull request as ready for review September 9, 2026 11:15
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 9, 2026
Merged via the queue into main with commit 032452a Sep 9, 2026
35 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-16675-scoped-meta-prefix branch September 9, 2026 11:45
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/m tests tooling

Projects

None yet

2 participants