Repository navigation
fix(rest): the layered view answers an absent name with the plain read's 404, as it answers an unpublished app (#20507) - #20527
Conversation
…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>
… gate serves it to Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…ain read's 404 Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…yers-absent-name-oracle
📓 Docs Drift Check1 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
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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 |
Contract reviewServed-tier: 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 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 judgmentsThe diff: one source change.
Also judged right: a ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…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>
Fixes #20507
Clause-②: no
What this changes
The layered view,
GET /meta/:type/:name/layersand the deprecated?layers=trueflag, answered a name with nothing behind it200withcode,overlayandeffectiveallnull. It answered a member's read of an unpublished app404 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.createMetaLayeredAnswerinpackages/rest/src/meta-item-read-gate.tsis the one chain both transports call after the store read (RestServer, and the runtime dispatcher's/metadomain). It now answers a layered read with no layer present asabsent, before the per-caller gate runs. That is the same place the plain read judges absence (createMetaItemAnswerstep 1). Both transports already writeabsentin their own absence envelope, the one they use for an unpublished app. So there is one change, and no transport edit:rest-server.tsandruntime/src/domains/meta.tsare untouched.The four measurements the dispatch asked for
H0, the reader: nothing depends on the 200. Measured at objectui's pinned
.objectui-shadd3f7e1be3561d63267d7162f3fc0ac52e72834d, bygit grepover the tree at that commit.MetadataClient.layered(packages/data-objectstack/src/metadata-client.ts, lines 1289 to 1304). It reads/layers. On404it already returns{ code: null, overlay: null, overlayScope: null, effective: null }, the same four null layers the old200carried.?layers=spelling in objectui's non-test source. As a control, the same pattern hits the client's own/layersURL at line 1301.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.effective ?? codeand takes the draft throughgetDraft. It reads the protection verdicts defensively:editable !== false,deletable !== false,lock && lock !== 'none', andresettable ?? isArtifactItem. So the four-null result from a404renders exactly as the old200did.PermissionMatrixEditor.tsxline 834 already relies on it in words: a record the server does not hold "answers the 404 shape".packages/client/src,packages/client-react/src) has zero layered reads. As a control,packages/client/src/index.tshas 26/metahits. The route ledger records this route asserver-only, consumed by objectui over plain HTTP.H1, re-measured on
mainbefore the fix (fb194c70e5). The new pins ran red against the unfixed chain. As a member, on both spellings and both transports,no_such_appanswered200with every layernull(pluslock,editable,deletableandresettable).launchpad, the unpublished app, answered404 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
createMetaLayeredAnswerwith every layernull. The protocol documents thateffectiveis 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.RestServerwrites it withsendMetaItemAbsent:404{ error: { code: "RESOURCE_NOT_FOUND", message } }. The dispatcher writes it asdeps.error('Not found', 404). These are the bytes each transport already sends for an unpublished app.studio.access) now receives404 RESOURCE_NOT_FOUNDfor 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.launchpad(200).H3, ablation. I deleted the new branch through
scripts/ablation-replace.mjsin WRAP mode. The tool recorded: anchor count 1 to 0, blob662d87187124toaf0967c88051. Under the mutation I ran the full@objectstack/runtimeand@objectstack/restsuites.@objectstack/restresolves from source in both suites: the runtime vitest config aliases it to../rest/src/index.ts. Sodistis not on the path.?package=emptied-scope case. The four published-app controls stayed green.662d87187124) andgit diff HEADis empty.git status --porcelainwas empty afterwards.Tests
The new pins are in
packages/runtime/src/domains/meta-list-projection-parity.test.ts. That file drivesRestServerand the dispatcher over the same fixtures:/layersand?layers=trueon each transport, an absent name and an unpublished app answer the same status, code, body,Vary,Cache-ControlandDeprecation. The flag'sLinkalso matches once the requested name is masked, because it names the caller's own path.404for an absent name, and a builder is still served the unpublished app.Two existing fixtures pinned the old answer, and I triaged them:
?package=case asserted200with all-null layers forpayroll?package=crm, which is exactly the answer this change removes. Its purpose, showing that?package=scopes the code layer, is kept bycrm?package=elsewhere:200,code: null, and the overlay still answers.payroll?package=crmis now an absence pin.packages/rest/src/execctx-consumer-census.test.ts) answeredgetMetaItemLayeredwith a generic object that carried no layer. Its/layersrow read200only 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/mainmerged):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 printcheck:test-typecheck: OK.pnpm --filter '@objectstack/runtime...' buildafter the merge.Gates
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, derived at32de5e64fc: 61 families.pnpm check:dual-build-cjs-loadsexited 3, PREREQUISITE NOT MET: 36 packages have nodist/. That is NOT MEASURED, and a whole-repopnpm buildwas not run on this shared host. As a narrowed probe of the one package whosedistthis diff changes,require('packages/rest/dist/index.cjs')loads and exportscreateMetaLayeredAnsweras a function. The diff changes no import, export or module format.--ranreconciliation: "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 lintat32de5e64fc: 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/restrun:Declared narrowing — verification ran UNLOCKED.
scripts/pm/os-verify-lock.shcould not take the shared verify lock on this host: no usable
flock. The sharedverify lock is declared Linux-only (
flockis util-linux, and a stock macOS doesnot 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.
Acceptance notes
GetMetaItemLayeredResponseSchema.effectiveinpackages/spec/src/api/protocol.zod.tssays its value isnull"when the item resolves to nothing at all". Through the REST route, that case now answers404instead 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.packages/qa/http-conformanceandpackages/qa/dogfoodwere not run. Bygit grep, the conformance suite makes no/layersor?layers=HTTP read. The dogfood suites read/layersonly for items that exist (crm_order,showcase_account). CI runs both.scripts/adr-anchors/packages__rest__src__meta-item-read-gate.ts.jsonalready anchors ADR-0045 §3 on this file. The code comment carries the ADR id.Generated by Claude Code