Skip to content

fix(rest): the layered view answers an absent name with the plain read's 404, as it answers an unpublished app (#20507) - #20527

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20507-layers-absent-name-oracle
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20507-layers-absent-name-oracle

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20507
Clause-②: no

What this changes

The layered view, GET /meta/:type/:name/layers and the deprecated ?layers=true flag, answered a name with nothing behind it 200 with code, overlay and effective all null. It answered a member's read of an unpublished app 404 RESOURCE_NOT_FOUND. The difference told a member which unpublished apps exist. ADR-0045 §3 says "Hidden" means externally unobservable, consistently across every surface, and the plain read already kept that promise.

createMetaLayeredAnswer in packages/rest/src/meta-item-read-gate.ts is the one chain both transports call after the store read (RestServer, and the runtime dispatcher's /meta domain). It now answers a layered read with no layer present as absent, before the per-caller gate runs. That is the same place the plain read judges absence (createMetaItemAnswer step 1). Both transports already write absent in their own absence envelope, the one they use for an unpublished app. So there is one change, and no transport edit: rest-server.ts and runtime/src/domains/meta.ts are untouched.

The four measurements the dispatch asked for

H0, the reader: nothing depends on the 200. Measured at objectui's pinned .objectui-sha dd3f7e1be3561d63267d7162f3fc0ac52e72834d, by git grep over the tree at that commit.

  • Every wire read of the layered view goes through one method: MetadataClient.layered (packages/data-objectstack/src/metadata-client.ts, lines 1289 to 1304). It reads /layers. On 404 it already returns { code: null, overlay: null, overlayScope: null, effective: null }, the same four null layers the old 200 carried.
  • There is no ?layers= spelling in objectui's non-test source. As a control, the same pattern hits the client's own /layers URL at line 1301.
  • The client has 15 non-test call sites. All are Studio or metadata-admin authoring screens, used by callers who hold an authoring capability:
    • ResourceEditPage.tsx: 1017, 1510, 1649, 1689 and 1723. This is the metadata edit page. Its load effect skips the layered read in create mode.
    • StudioDesignSurface.tsx: 1820 (app navigation), 1949 (leaf designer), 2962 (object designer) and 4015 (flow designer).
    • EmbeddedItemEditor.tsx: 110, the parent re-read before a save.
    • PermissionMatrixEditor.tsx: 512, 841 and 885 (the Access pillar).
    • PackageOwdOverviewPanel.tsx: 155 and 249.
  • Each site reads effective ?? code and takes the draft through getDraft. It reads the protection verdicts defensively: editable !== false, deletable !== false, lock && lock !== 'none', and resettable ?? isArtifactItem. So the four-null result from a 404 renders exactly as the old 200 did. PermissionMatrixEditor.tsx line 834 already relies on it in words: a record the server does not hold "answers the 404 shape".
  • No member-facing caller exists.
  • The SDK (packages/client/src, packages/client-react/src) has zero layered reads. As a control, packages/client/src/index.ts has 26 /meta hits. The route ledger records this route as server-only, consumed by objectui over plain HTTP.

H1, re-measured on main before the fix (fb194c70e5). The new pins ran red against the unfixed chain. As a member, on both spellings and both transports, no_such_app answered 200 with every layer null (plus lock, editable, deletable and resettable). launchpad, the unpublished app, answered 404 RESOURCE_NOT_FOUND. The four published-app controls stayed green. The reading: 6 failed and 4 passed.

H2, what reaches the chain and what a builder now receives. An absent name reaches createMetaLayeredAnswer with every layer null. The protocol documents that effective is never null while another layer is present, so "no layer present" means the name resolves to nothing. The chain answers { kind: 'refuse', refusal: { reason: 'absent' } } before any layer is judged. RestServer writes it with sendMetaItemAbsent: 404 { error: { code: "RESOURCE_NOT_FOUND", message } }. The dispatcher writes it as deps.error('Not found', 404). These are the bytes each transport already sends for an unpublished app.

  • A builder (studio.access) now receives 404 RESOURCE_NOT_FOUND for an absent name, on both spellings and both transports. So do the holder, the member and the author. This is the plain read's absence, which does not depend on who asks.
  • A builder is still served the unpublished launchpad (200).

H3, ablation. I deleted the new branch through scripts/ablation-replace.mjs in WRAP mode. The tool recorded: anchor count 1 to 0, blob 662d87187124 to af0967c88051. Under the mutation I ran the full @objectstack/runtime and @objectstack/rest suites. @objectstack/rest resolves from source in both suites: the runtime vitest config aliases it to ../rest/src/index.ts. So dist is not on the path.

  • Runtime: 6 failed, 4885 passed. The 6 are exactly the absent-name pins: the four "absent name and unpublished app answer the same" cases, the "whoever asks" case, and the ?package= emptied-scope case. The four published-app controls stayed green.
  • Rest: 221 files passed.
  • Restore: the tool's own verdict was that the blob after restore equals HEAD (662d87187124) and git diff HEAD is empty. git status --porcelain was empty afterwards.

Tests

The new pins are in packages/runtime/src/domains/meta-list-projection-parity.test.ts. That file drives RestServer and the dispatcher over the same fixtures:

  • As a member, for each of /layers and ?layers=true on each transport, an absent name and an unpublished app answer the same status, code, body, Vary, Cache-Control and Deprecation. The flag's Link also matches once the requested name is masked, because it names the caller's own path.
  • Control: a published app is still served to the member, with every layer pruned.
  • Every caller gets the same 404 for an absent name, and a builder is still served the unpublished app.

Two existing fixtures pinned the old answer, and I triaged them:

  • The parity ?package= case asserted 200 with all-null layers for payroll?package=crm, which is exactly the answer this change removes. Its purpose, showing that ?package= scopes the code layer, is kept by crm?package=elsewhere: 200, code: null, and the overlay still answers. payroll?package=crm is now an absence pin.
  • The execctx census's protocol double (packages/rest/src/execctx-consumer-census.test.ts) answered getMetaItemLayered with a generic object that carried no layer. Its /layers row read 200 only because the chain served an all-absent body. The double now answers the layered read in its own shape, so the row still measures "serves on its own reading".

Results on the final head 32de5e64fc (origin/main merged):

  • pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2: 222 files and 4239 tests passed (40 skipped). This covers both projects.
  • pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2: 289 files and 4897 tests passed (1 skipped). This covers both projects.
  • pnpm --filter @objectstack/rest --filter @objectstack/runtime typecheck: exit 0, and both packages print check:test-typecheck: OK.
  • Before the tests, the dependency closure was built with pnpm --filter '@objectstack/runtime...' build after the merge.

Gates

  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, derived at 32de5e64fc: 61 families.
    • 60 exited 0.
    • pnpm check:dual-build-cjs-loads exited 3, PREREQUISITE NOT MET: 36 packages have no dist/. That is NOT MEASURED, and a whole-repo pnpm build was not run on this shared host. As a narrowed probe of the one package whose dist this diff changes, require('packages/rest/dist/index.cjs') loads and exports createMetaLayeredAnswer as a function. The diff changes no import, export or module format.
    • --ran reconciliation: "61 derived famil(ies) accounted for — 60 run, 1 NOT-MEASURED".
  • node scripts/check-issue-citations.mjs --base origin/main, run after the merge: every citation this change adds resolves (5 of 5).
  • pnpm lint at 32de5e64fc: exit 0, 35s.

Every build and test command above ran through scripts/pm/os-verify-lock.sh, which printed this for each run. The block below is from the final @objectstack/rest run:

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 --filter @objectstack/rest exec vitest run --maxWorkers=2

Acceptance notes

  • Spec text drift, for the spec seat. GetMetaItemLayeredResponseSchema.effective in packages/spec/src/api/protocol.zod.ts says its value is null "when the item resolves to nothing at all". Through the REST route, that case now answers 404 instead of a body. The text still describes the protocol method's in-process return, which is unchanged. It is outside this card's file surface (packages/spec/** is read-only here), so I have not filed or edited it. Carrier: the spec seat.
  • Consumers not run. packages/qa/http-conformance and packages/qa/dogfood were not run. By git grep, the conformance suite makes no /layers or ?layers= HTTP read. The dogfood suites read /layers only for items that exist (crm_order, showcase_account). CI runs both.
  • ADR anchor. scripts/adr-anchors/packages__rest__src__meta-item-read-gate.ts.json already anchors ADR-0045 §3 on this file. The code comment carries the ADR id.

Generated by Claude Code

hotlong and others added 6 commits September 29, 2026 06:53
…npublished app's, both spellings and both transports

Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289
Co-authored-by: Claude <noreply@anthropic.com>
…the plain read's absence

createMetaLayeredAnswer now refuses an answer with no layer present as absent, before the gate, so both spellings on both transports answer an absent name what they answer an unpublished app (ADR-0045 section 3).

Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

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 397572ed5da04c14eed7b4bf934a4c353822df6c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1a837a47a89ce61422cf7e9ee94cb3e318325e40 — the merge of head 32de5e64fc63ff51fe5bf3ae28dd49c1c04b2993 into base 397572ed5da04c14eed7b4bf934a4c353822df6c, 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 1a837a47a89ce61422cf7e9ee94cb3e318325e40 && git checkout 1a837a47a89ce61422cf7e9ee94cb3e318325e40
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 397572ed5da04c14eed7b4bf934a4c353822df6c 32de5e64fc63ff51fe5bf3ae28dd49c1c04b2993 && git checkout -B drift-repro 397572ed5da04c14eed7b4bf934a4c353822df6c && git merge --no-ff 32de5e64fc63ff51fe5bf3ae28dd49c1c04b2993

node scripts/docs-audit/affected-docs.mjs --json 397572ed5da04c14eed7b4bf934a4c353822df6c

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 32de5e64fc63ff51fe5bf3ae28dd49c1c04b2993
Local-runs: none

Inputs read: card #20507 (body; comments 5879479871 triage grade, 5880083725 claim, 5880534492 os-dev-report), PR #20527 (body; the 4-file list, +117/-15; the net diff against main at 397572e), and the check-runs on the head. Binding direction: triage's grade 5879479871 under ADR-0045 §3 ("Hidden" means externally unobservable, consistently across every surface; "REST/MCP calls against a unpublished app return 404 by default").

Check-runs on the head at my read (23:28 UTC): 32 runs — 22 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in)), 7 in progress (Test Core 1/6, 2/6, 4/6, 5/6, 6/6; Type Check · workspace; Lint & Repo Gates), 0 failure. Success includes Build Core, Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, Test Core (3/6), Dogfood Regression Gate (1/3, 2/3, 3/3 and the aggregate), Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Check Changeset, Governed Surface Queue Guard, Check PR Size, Flag docs affected by code changes, and the four claim/close guards. The 7 running checks are recorded as running; no verdict is inferred for them.

① Derived judgments

The diff: one source change. createMetaLayeredAnswer in packages/rest/src/meta-item-read-gate.ts gains a step 1 — present = META_ITEM_LAYERS.filter(layer != null); when present.length === 0 it answers { kind: 'refuse', refusal: { reason: 'absent' } } before the gate. The former steps 1 and 2 (gate, mask) are the pre-fix code moved verbatim with present hoisted out of the gate block; the docblock's "with nothing behind the name the protocol answers every layer null, and so does this chain" sentence is replaced. rest-server.ts and runtime/src/domains/meta.ts are untouched, which the claim's file surface allowed only if a transport needed a line — none did (both already map absent).

  1. A member, an absent name against an unpublished app, both spellings, both transports — RIGHT: the same status and body now. RestServer: both refusals reach sendMetaReadRefusal → sendMetaItemAbsent (404, { error: { code: RESOURCE_NOT_FOUND, message: "Metadata item not found or access denied." } }; the message names no item); the flag's Deprecation and Link are written on res before serveMetaItemLayered runs, so they ride both answers. Dispatcher (answerMetaLayered): both are withHeaders(deps.error('Not found', 404), headers). The unpublished app's refusal is the gate's absent (now step 2), the absent name's is step 1; the emitter is the same object on each transport, so the two bodies cannot diverge. No Vary (the view is not translated) and no Cache-Control (owed on serve only) on either. Pinned by the new describe: 2 transports × 2 spellings, comparing status, code, body, Vary, Cache-Control, Deprecation and Link with the requested name masked — the mask is right, since Link names the caller's own request path, not the item.
  2. A published item's answer, and a builder's answer for an unpublished item — RIGHT: unchanged. Any present layer makes present non-empty, and steps 2–3 are the pre-fix steps 1–2 unchanged. launchpad for builder (studio.access) has effective non-null → the gate admits → 200, pinned on both spellings and transports by the new "whoever asks" case; the four published-app controls pin the member's served body (every layer pruned to nav_leads, nav_org_directory), and the pre-existing [#20478] layered census still drives every item cell × probe × caller through sameAnswer across transports.
  3. H0, the reader — RIGHT: the search supports "nothing depends on the 200". Re-measured on objectui at the pinned .objectui-sha dd3f7e1b (git grep over the local sibling clone's objects at that commit; no checkout). MetadataClient.layered (packages/data-objectstack/src/metadata-client.ts 1289–1304) is the only wire reader of /layers; on res.status === 404 it returns { code: null, overlay: null, overlayScope: null, effective: null }, and its own comment already reads a 404 as "an empty-but-successful layered view". 15 non-test call sites in 5 files, exactly the dev's list (ResourceEditPage 1017, 1510, 1649, 1689, 1723; StudioDesignSurface 1820, 1949, 2962, 4015; EmbeddedItemEditor 110; PermissionMatrixEditor 512, 841, 885; PackageOwdOverviewPanel 155, 249); each reads effective ?? code ?? {}. The protection verdicts: editable !== false, deletable !== false, lock && lock !== 'none', and resettable ?? isArtifactItem where isArtifactItem requires layered.code != null — false for an absent name, the same verdict the old 200 carried (resettable = artifactBacked, false with no code layer). ResourceEditPage's load effect returns early in createMode. PermissionMatrixEditor 834 states the 404 shape as its designed path. No ?layers= in objectui non-test source (CHANGELOG hits only; control: the client's own /layers URL at 1301). SDK: no layers hit in packages/client/src or packages/client-react/src (control: 26 /meta hits in packages/client/src/index.ts); the route ledger row GET /api/v1/meta/:type/:name/layers is disposition: 'server-only'. In-repo in-process readers of getMetaItemLayered (plugin-security) call the protocol, not the chain, and are untouched. QA: the dogfood suites read /layers and ?layers=true for existing items only (crm_order, showcase_account); http-conformance has no layered read.
  4. The two edited existing tests — RIGHT: intent kept, no assertion weakened. (a) Parity ?package= case: the one removed assertion (payroll?package=crm → 200, code null, effective null) pinned exactly the defect's answer. It is replaced by crm?package=elsewhere → 200, code: null, effective.label 'CRM (overlay)' — crm is _packageId: 'crm', so the scope empties its code layer while the env overlay row still answers, which is the original intent ("?package= scopes the code layer") shown with the overlay surviving — plus payroll?package=crm → 404 RESOURCE_NOT_FOUND (a scope that leaves no layer is the name's absence, never a 200 with every layer null). The invoice mask assertion is untouched. (b) Census metaProtocol double: only the fixture changes — getMetaItemLayered now answers { code: doc, overlay: null, overlayScope: null, effective: doc } where the generic { ok, rows, ... } answer carried no layer; the /layers row's SERVES_ON_ITS_OWN assertion toBe(200) is unchanged and now measures a real document through the gate and the mask rather than an all-absent pass-through, which is the row's stated intent ("serves on its own reading").

Also judged right: a ?package= scope that leaves no layer answers 404 (the code layer is scoped away under ADR-0048 and no overlay row exists, so the name resolves to nothing in that scope; the client already treats that 404 as empty). In-process, createMetaLayeredAnswer's serve for a no-layer input becomes refuse/absent; its only callers are RestServer and the dispatcher, both of which already write absent. The absence is judged before the gate exactly as the plain read's step 1 (#18066) judges it, so a name with nothing behind it cannot be converted into a 403 either. Nothing wrong found.

② Semver level

.changeset/20507-layers-absent-name-oracle.md: "@objectstack/rest": patch. Matches what the diff publishes: one source file in @objectstack/rest; @objectstack/runtime changes a test file only and takes its wire change through the shared chain, which the changeset body names on both transports. A bug fix in a released package takes patch (AGENTS.md Post-Task Checklist 3); the card is bug + security, the fix pulls a door back to the declared contract (ADR-0045 §3), removes or renames nothing an author writes, and the body carries the client note ("treat that 404 as the same answer"; Studio's client already maps it). Clause-②: no, no arm, in both the PR body and the changeset body — truthful: no accept set is widened and no public surface grows; a response is withdrawn, not an input narrowed. Check Changeset on the head: success. Level right.

③ Boundary flags

  • open_questions: none.
  • Out-of-scope finding — GetMetaItemLayeredResponseSchema.effective describe text ("null when the item resolves to nothing at all", packages/spec/src/api/protocol.zod.ts 507–511): answered. The text still describes the protocol method's in-process return, which is unchanged (plugin-security reads it in-process and still gets nulls); on the REST wire that case is now the route's 404. packages/spec/** is read-only on this card. Escalated to the spec seat as a describe-precision item; not a landing condition.
  • Deviation, os-verify-lock.sh UNLOCKED (no flock on macOS): a declared narrowing; the head's check-runs are the gate verdicts, not the dev's local runs.
  • Deviation, check:dual-build-cjs-loads NOT MEASURED: the diff changes no import, export or module format; Build Core on the head is success.
  • Deviation, first label-write exit 3 then re-run: process noise, nothing was written twice; no bearing on the diff.
  • Deviation, qa/http-conformance and qa/dogfood not run locally: neither reads the layered view for an absent name (grep above); Dogfood Regression Gate 1/3, 2/3, 3/3 and Dogfood Verify CLI are success on the head.
  • Deviation, no labels written: none were dispatched.
  • Reviewer note: Console Pin Gate on this head is skipped; the diff removes no export the pinned sibling imports, so nothing is owed there. 7 check-runs were still in progress at my read (listed above); the landing waits on them as on any head — recorded, not inferred.

Implemented-by: claude/issue-20507-layers-absent-name-oracle
Reviewed-by: local_1d2a197c-c20e-4e90-9be8-413d4d432289

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 23:38
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit b43a814 Sep 28, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20507-layers-absent-name-oracle branch September 28, 2026 23:55
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…he request path, not the route template (objectstack-ai#20508) (objectstack-ai#20528)

Fixes objectstack-ai#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 objectstack-ai#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](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_

Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
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