Skip to content

fix(rest): the environment-scoped ?layers=true successor Link names the request path, not the route template (#20508) - #20528

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-20508-scoped-layers-link
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-20508-scoped-layers-link

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20508
Clause-②: no

What was wrong

On RestServer's environment-scoped mount (api.enableProjectScoping), the deprecated ?layers=true spelling of the item read answered Deprecation: true and a successor-version Link built from metaPath. On that mount metaPath is the route template, so the Link named the path /api/v1/environments/:environmentId/meta/view/lead_all/layers, with a literal :environmentId in it. A client that followed the header requested that path.

What changed

packages/rest/src/rest-server.ts, the ?layers=true call site of metaItemLayersDeprecationHeaders only. RestServer now passes the request's own path (IHttpRequest.path), parsed as a URL path the way the runtime dispatcher's requestedItemPath reads its request URL: without the trailing slash. The shared helper in meta-item-read-gate.ts is untouched (no template replace anywhere).

The parse matters for one measured reason: the Hono adapter hands handlers a decodeURI'd path (c.req.path). A name requested as lead%20all reaches the handler as lead all. The parse percent-encodes it again, so the Link stays a valid URI reference. A request that carries no path gets Deprecation: true alone, which is what the helper prescribes for a transport that cannot say where it serves the item.

Pins (triage grade 5879492075):

  • the scoped answer's Link names /api/v1/environments/env_1/meta/view/lead_all/layers;
  • the unscoped control's Link still names /api/v1/meta/view/lead_all/layers.

Both pins run through the real HonoHttpServer (packages/rest/src/meta-item-layers-deprecation-link.test.ts), because the path comes from the adapter. The same file pins the encoded-name case. The mock-harness file meta-item-layered-route.test.ts now passes the path field that IHttpRequest declares required (its hand-built request omitted it). It also pins the no-path branch.

Measurements (PM mechanism hypotheses)

All at fb386074f5 (unfixed) or 439dd81bd3 (fixed), through the real HonoHttpServer with enableProjectScoping: true, projectResolution: 'optional'.

  • H0: confirmed. Unfixed, GET /api/v1/environments/env_1/meta/view/lead_all?layers=true answered 200, deprecation=true, with a Link naming /api/v1/environments/:environmentId/meta/view/lead_all/layers. The unscoped control GET /api/v1/meta/view/lead_all?layers=true named /api/v1/meta/view/lead_all/layers, which is correct.

  • H1: half confirmed. The request's own path is available at the call site as req.path. The Hono adapter sets it from c.req.path, and the Node conformance port from url.pathname. It does not carry a percent-encoded name unchanged under Hono: c.req.path is decodeURI'd. A probe route read the following for the scoped path:

    requested name req.path segment after the parse dispatcher's requestedItemPath
    lead_all lead_all lead_all lead_all
    lead%20all lead all lead%20all lead%20all
    lead%2Fall lead%2Fall lead%2Fall lead%2Fall
    lead%25all lead%25all lead%25all lead%25all
    l%C3%A9ad léad l%C3%A9ad l%C3%A9ad
    lead%5Fall lead_all lead_all lead%5Fall
    env env%201 env 1 env%201 env%201

    After the parse, six of the seven rows match the dispatcher byte for byte. The seventh is %5F, a percent-encoded unreserved character, which Hono decodes to _. The two spellings are equivalent under RFC 3986 section 6.2.2.2, and they name the same route.

  • H2 (ablation): the red set is larger than predicted. The mutation put back the template argument (${metaPath}/${req.params.type}/${req.params.name}) through scripts/ablation-replace.mjs, which confirmed it landed (anchor 1 to 0, blob cb57de8250e2 to 004a11651dba). The result was 3 red and 13 green of 16: the scoped pin, the scoped encoded-name pin, and the mock no-path pin. The PM predicted the scoped pin alone. The two extra reds are pins this PR adds, and each is a scoped or no-path case the template argument gets wrong. Both unscoped controls (Hono and mock) stayed green. Restore was proven: blob after restore cb57de8250e2 equals the blob at HEAD, and git diff HEAD was empty.

  • Second ablation, of the parse. It replaced the parse with the raw req.path. The first attempt was a no-op: the tool refused because the replacement text requestPath already occurred inside the anchor, so the count did not rise, and it restored without running the tests. The re-run used a unique marker and landed (blob cb57de8250e2 to 48fa04adf547). Exactly 1 test went red: the encoded-name pin, which received a raw space in lead all/layers. Restore was proven the same way.

Verification

At 439dd81bd3:

  • pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2: 222 files, 4235 passed, 40 skipped.
  • pnpm --filter @objectstack/rest typecheck: exit 0, which includes check:test-typecheck. tsc -p tsconfig.test.json --listFiles lists both edited test files.
  • pnpm lint (full repo, not narrowed): exit 0.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 62 commands. 60 exited 0. Two exited 3 with PREREQUISITE NOT MET, because both need the whole workspace built: check:dual-build-cjs-loads and check:type-check-debt. Those two are NOT MEASURED. The whole-workspace build was not run on this shared host. --ran reconciliation: 62 derived, 60 run, 2 NOT-MEASURED, 0 UNRUN.
  • Roster gates whose roster lives under a changed directory: check-changeset-fixed, check:error-code-casing and check:filter-alias-parity all exited 0.
  • node scripts/check-issue-citations.mjs --base origin/main, after merging origin/main (already up to date at fb386074f5): exit 0.

Declared narrowing — verification ran UNLOCKED. scripts/pm/os-verify-lock.sh
could not take the shared verify lock on this host: no usable flock. The shared
verify lock is declared Linux-only (flock is util-linux, and a stock macOS does
not ship it), so the command below was run directly, without the lock —
a declared narrowing, not a silent one. No serialization guarantee held for this
run, nor for any sibling agent in this container while it ran.

pnpm --workspace-concurrency=2 --filter '@objectstack/rest^...' build
pnpm --filter @objectstack/rest build
pnpm --filter @objectstack/rest typecheck
pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2
pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/meta-item-layers-deprecation-link.test.ts src/meta-item-layered-route.test.ts

Acceptance notes

  • The unscoped answer changes for encoded names, and only for them. Unfixed, the unscoped Link was assembled from the decoded route parameters. Measured on fb386074f5 through Hono: lead%2Fall named /api/v1/meta/view/lead/all/layers, a different path; lead%25all named lead%all, an invalid percent-encoding; lead%20all and l%C3%A9ad put a raw space and a raw non-ASCII character in the header. After the fix, each keeps its encoding. For every name that needs no encoding, the unscoped Link is byte-identical. The changeset says so.
  • The path parse now exists twice. Once is the runtime dispatcher's requestedItemPath in packages/runtime/src/domains/meta.ts, and once is inline at this call site. Moving one copy into the shared seam, meta-item-read-gate.ts, is outside this claim's file surface: that file and packages/runtime/** are read-only here, and PR fix(rest): the layered view answers an absent name with the plain read's 404, as it answers an unpublished app (#20507) #20527 is editing the seam. carrier: none. Noted, not filed.
  • Both transports name the successor from the path their adapter reports. A host that mounts the app under a parent that strips a prefix would name the inner path, on both transports alike. NOT MEASURED; no such host was composed here.

Generated by Claude Code

…ath, not the route template

RestServer built the deprecated spelling's successor Link from metaPath,
which on the environment-scoped mount is the route template, so the
header named /environments/:environmentId/.../layers. It now passes the
request's own path (IHttpRequest.path), parsed as a URL path the way the
runtime dispatcher reads its request URL: without a trailing slash, and
percent-encoded again where the Hono adapter hands the path over decoded.

Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/environment-routing.mdx (via /environments/:environmentId (route, a path literal in a comment on a changed line))
  • content/docs/concepts/north-star.mdx (via /environments/:environmentId (route, a path literal in a comment on a changed line))
  • content/docs/deployment/publish-and-preview.mdx (via /environments/:environmentId (route, a path literal in a comment on a changed line))
  • content/docs/deployment/single-project-mode.mdx (via /environments/:environmentId (route, a path literal in a comment on a changed line))
  • content/docs/protocol/kernel/http-protocol.mdx (via /environments/:environmentId (route, a path literal in a comment on a changed line))
  • content/docs/ui/forms.mdx (via /environments/:environmentId (route, a path literal in a comment on a changed line))

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

  • content/docs/releases/implementation-status.mdx (via /environments/:environmentId (route, a path literal in a comment on a changed line))

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

Coarse fallback — 15 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 fb386074f57b234c98c40938aae7a0486ad50e8b → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json fb386074f57b234c98c40938aae7a0486ad50e8b

⚠️ 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 fb386074f57b234c98c40938aae7a0486ad50e8b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 439dd81bd37090d5175b79c654413f22d7a13a92
Local-runs: none

Inputs read: card #20508 body and its three comments (triage grade 5879492075, claim 5880574313, os-dev-report 5880873462); PR #20528 body, file list (4 files, +170/-3) and the net diff against main; the head's check-runs at 23:53 UTC. Sources at the head read through gh api contents, nothing built or run: packages/rest/src/meta-item-read-gate.ts (the helper), packages/runtime/src/domains/meta.ts (requestedItemPath), packages/rest/src/rest-server.ts (the call site and the mount setup), packages/rest/src/meta-item-layered-route.test.ts at the head and at base fb386074f5, packages/spec/src/contracts/http-server.ts (IHttpRequest), packages/plugins/plugin-hono-server/src/adapter.ts (path: c.req.path), and Hono 4.12.34's getPath / tryDecodeURI.

Check-runs on the head at read: 33 runs; 25 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke), 5 in progress (Lint & Repo Gates, Test Core 1/6, 2/6, 4/6, 5/6), 0 failure. Success includes Test Core 3/6 and 6/6, all three Dogfood Regression Gate shards, Temporal Conformance, Build Core, Dogfood Verify CLI, all four Type Check jobs, Check Changeset, Check PR Size, Governed Surface Queue Guard and both claim guards. The five in progress are recorded as running; no verdict is inferred for them.

① Derived judgments

The card's binding direction (triage grade 5879492075): RestServer builds the Link from the resolved request path, as the dispatcher does, and not by a string replace of the template in the helper. Judged against it:

(1) The scoped Link names the request's own path, and the unscoped control is unchanged for a plain name: RIGHT. The diff replaces the one argument ${metaPath}/${req.params.type}/${req.params.name} with the request's path parsed as a URL path (new URL(...).pathname, trailing slashes stripped, empty read as undefined). On the scoped mount metaPath is ${basePath}${metadata.prefix} with basePath equal to /api/v1/environments/:environmentId (rest-server.ts :4328, :5318), so the old argument was the route template; req.path is what the adapter saw. The new Hono-driven pin asserts the scoped target /api/v1/environments/env_1/meta/view/lead_all/layers and the unscoped control /api/v1/meta/view/lead_all/layers, both rel="successor-version", each after a 200 with exactly one layered read (anti-vacuity). For a plain name the unscoped path is the same string the old argument produced, so the control is byte-identical.

(2) The encoded-name behaviour on the unscoped mount (lead%2Fall, lead%25all, lead%20all): a fix toward the dispatcher's answer, not a new divergence. RIGHT. The dispatcher's requestedItemPath (runtime domains/meta.ts :734) is new URL(url, base).pathname.replace(/\/+$/, ''), undefined when empty, over the raw Fetch Request.url; the WHATWG parser keeps %2F, %25 and %20 as sent. Before this PR the unscoped Link was assembled from req.params.name, the decoded route parameter, so lead%2Fall named .../lead/all/layers (a different path), lead%25all named lead%all (malformed percent-encoding) and lead%20all put a raw space inside a URI reference. After it, req.path comes from Hono's c.req.path (adapter.ts :574), which Hono 4.12.34 getPath runs through decodeURI with %25 pre-guarded to %2525: %2F and %25 survive, %20 and non-ASCII become the raw character; the WHATWG parse re-encodes those, and the three cases the PR names match the dispatcher byte for byte. The residual the dev tabled, %5F decoding to _, is equivalent under RFC 3986 6.2.2.2, names the same route, and is unchanged from before (the decoded parameter already read _). The parse core is the dispatcher's; only the guard differs in shape (startsWith('/') before concatenation onto a fixed origin, where the dispatcher resolves against a base inside try/catch), every path either adapter hands over starts with /, and a leading-slash path concatenated onto a fixed host cannot fail the WHATWG parser, so no shipped transport reaches the difference and the missing try/catch is not a throw path.

(3) meta-item-read-gate.ts untouched: RIGHT. The file list is the changeset, rest-server.ts, one edited test and one new test. The helper's signature metaItemLayersDeprecationHeaders(itemPath: string | undefined) at head :2485 is main's, and its undefined arm (Deprecation: true alone) is the one the new no-path branch relies on. No .replace(':environmentId' or any other template rewrite exists anywhere in the diff.

(4) The edited existing test keeps its assertion: RIGHT. meta-item-layered-route.test.ts base :210 and head :216 carry the identical expected value (/api/v1/meta/object/customer/layers, rel="successor-version"). The harness gained an optional fifth argument, and only the successor-path case passes it (/api/v1/meta/object/customer), the unscoped path that case named all along; the other dispatch callers are unchanged, and none of them reads Link.

Further derived judgments:

  • Accept-set: unchanged. wantsMetaItemLayers, serveMetaItemLayered and the body and status of both spellings are untouched; the diff moves one header argument only. RIGHT.
  • Deprecation: true on both mounts: unchanged. RIGHT.
  • No-path branch (a request with no path, or one not starting with /): Deprecation alone, no Link. Reachable only from a hand-built request: IHttpRequest.path is a required member (spec http-server.ts :35) and the Hono adapter always sets it. Not a public-surface change on any shipped transport, and it is the helper's declared contract for undefined. RIGHT.
  • New test drives the real HonoHttpServer: @objectstack/plugin-hono-server is already a devDependency of @objectstack/rest, and four existing rest tests import it; no new dependency edge. RIGHT.
  • Cross-transport: both transports now name the successor from their adapter's own path statement; a host that strips a mount prefix would name the inner path on both alike (the dev's NOT MEASURED note). That is the direction's "as the dispatcher does", and no worse than the dispatcher. RIGHT.

② Semver level

.changeset/20508-scoped-layers-link.md: '@objectstack/rest': patch, Clause-②: no. RIGHT. The only published package touched is @objectstack/rest; the diff corrects a response-header value (the scoped Link target; the unscoped Link for percent-encoded names, which was mis-pointed or malformed) and adds no export, no authorable key and no accept-set change. A bug fix in a released package takes patch (AGENTS.md Post-Task Checklist 3). Clause-②: no with no arm is right: nothing an author writes is widened or narrowed. The changeset body names both the scoped correction and the encoded-name change on the unscoped mount, so the consumer-facing CHANGELOG states what changes. The PR body carries the same Clause-②: no. Check Changeset: success on the head.

③ Boundary flags

open_questions: none declared. Deviations, each answered:

  1. H2 red set 3 not 1: the two extra reds are pins this PR adds (the scoped encoded-name case and the mock no-path case), each a case the template argument mis-answers; both unscoped controls stayed green. Answered: the prediction predates the added pins, and the reading is consistent with the direction.
  2. First parse ablation a no-op, re-run with a unique marker: process note; the recorded reading (1 red, the encoded-name pin) is the re-run's. Answered.
  3. Two scratch probe files under packages/rest/src, deleted and never committed: the file list is four files, no probe present. Answered.
  4. Mock harness edited to pass the required path, assertion text unchanged: verified, base :210 against head :216. Answered.
  5. label-write first run without a token, re-run with the exported token: process; the assignee write read back MATCHES per the report; not a review matter. Answered.
  6. Commit trailers model-free per AGENTS.md: the head commit carries Claude-Session and Co-authored-by: Claude with no model name; repo rule. Answered.
  7. Worktree removed before posting, post-stamped run read-only from the shared checkout: process. Answered.
  8. Verification ran UNLOCKED, declared verbatim in the PR body: a declared narrowing; the head's check-runs are the gate verdicts here, and the local reading is advisory. Answered.

out_of_scope_findings, each answered or escalated:

The PASS below is the contract verdict on the diff at this head; 5 check-runs were still in progress at read and none had failed, and their conclusions are not inferred here.

Implemented-by: claude/issue-20508-scoped-layers-link
Reviewed-by: local_1d2a197c-c20e-4e90-9be8-413d4d432289

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 00:00
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 1378ec7 Sep 29, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20508-scoped-layers-link branch September 29, 2026 00:17
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

1 participant