fix(rest): require the resolved environment to belong to the caller at GET /ui/view/:object/:type - #13625
Conversation
…t GET /ui/view/:object/:type Maintainer ruling 2026-08-30 (option C): the seam must resolve identity AND compare the resolved environment against the one the caller is entitled to, on both naming channels (X-Environment-Id header and bound hostname), with a signalled refusal on an envRegistry.resolveById validation failure rather than a silent fallback to the default environment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
…-view-environment-ownership
- execctx-consumer-census: the UI-view route joins as BARE site 53 behind the shared anonymous floor (72->73 sites, 89->92 mentions, 52->53 bare); the seam drops its local .catch so it does not become the only site that is both locally caught AND behind the floor. - rest-exec-ctx-principal-kind: __authEnvironmentId named in the golden key set. - system-context-census: 5 doc anchors re-anchored by the gate's own --fix, pure line rot (+124 on every one). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 13 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 48728f27ceaf82b3b09d6312b807758c1331dfc9 && git checkout 48728f27ceaf82b3b09d6312b807758c1331dfc9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eaba72e48cc294038e74ece5bf7677568d55b038 2a4e9098e8248b080914610baf9909248590800e && git checkout -B drift-repro eaba72e48cc294038e74ece5bf7677568d55b038 && git merge --no-ff 2a4e9098e8248b080914610baf9909248590800e
node scripts/docs-audit/affected-docs.mjs --json eaba72e48cc294038e74ece5bf7677568d55b038
|
…-view-environment-ownership
…1 -> 64 The fourth count anchor this seam repair moves, and the one a package-scoped run cannot see: packages/qa/dogfood cites other packages' files and is not reached by `pnpm --filter @objectstack/rest ...` -- only the Dogfood Regression Gate runs it. Re-anchored, NOT relaxed: the control proves the census still reads the file it thinks it does, and a rising enforceAuth is exactly what this card causes. Measured +3 over the whole file (the derivation counts the bare term, comments included): one new call site (52 -> 53) plus two prose mentions. The sibling numbers were re-derived and did not move -- population 80, reachable 19, registrars 17, mounts 80 -- which is what says this is a guard change and not a surface change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
…-view-environment-ownership
…-view-environment-ownership # Conflicts: # content/docs/permissions/system-context.mdx
Fixes #13214
Maintainer ruling 2026-08-30T12:20:51Z (comment id
5468648876, 第 5 场总监席决裁批 #1, verbatim 「第一批其余同意」), option C: the seam must require that the resolved environment belong to the caller — identity resolution plus an ownership check — on both named-environment channels, with a signalled refusal on anenvRegistry.resolveByIdvalidation failure rather than a silent fallback to the default environment.⛔ Draft on purpose. Review and landing are the PM's; auto-merge is not armed.
What was wrong
GET /api/v1/ui/view/:object/:typewas the one metadata-touching route inRestServer's table that resolved no identity at all — straight fromresolveProtocoltogetUiView. Measured across two prior measurement-only PRs (#13244, #13258, both merged):resolveExecCtxcalled zero times, while all 52 identity-touching siblings answered 401 under an absent context;X-Environment-Idheader and bound hostname), with the foreign kernel acquired;X-Environment-Idwas not refused but fell through to the default environment and answered 200 with that environment's view, so two 200s with different bytes told a real environment id from an invented one.Why anonymous-deny alone is not the repair
The ruling names it: adding
resolveExecCtx+enforceAuthand stopping is option B, and it was measured not to fix the crossing — an authenticated caller could still name a foreign environment, because nothing downstream compared the environment that was resolved with the environment the caller is entitled to.⭐ That claim is now demonstrated rather than quoted. The ablation below removes only the ownership comparison, keeping
resolveExecCtx+enforceAuth— i.e. builds exactly option B — and 12 pins go red while every anonymous-deny pin stays green.What the seam does now
Four steps, in this order, and the order is load-bearing:
resolveRequestEnvironmentId;enforceAuth);enforceEnvironmentOwnership).Resolving identity before the environment is decided would authenticate the caller somewhere other than where the answer comes from — which is the very mismatch step 4 exists to catch.
There was no ownership predicate to reuse. Searched before writing one: no
environmentOwnership/assertEnvironmentAccess/ entitlement predicate exists in this package or its siblings, the scoped/environments/:environmentId/...mounts carry no such check either, andExecutionContextdeclares no environment field. So the fact had to come from where identity is actually established.computeExecCtxvalidates the caller against an auth service it looks up in a kernel, and it now records which environment that kernel belongs to on__authEnvironmentId. A credential is good for the environment whose auth service accepted it. The value differs from the resolved environment in exactly the branch that crosses: when the resolved environment's kernel carries noauthservice, the lookup falls back to the default environment's, and a session minted there authenticated a request naming another one.Two refusable shapes: named but not served (the caller named an environment through the header and the chain resolved a different one — which is what an unresolvable id does), and anchored elsewhere.
Deliberate, and the reason is the oracle rather than tidiness. A caller naming a real foreign environment is already refused by
enforceAuth— their session is not valid there, so no context resolves. Giving either shape above a different status (a 403, or a 404 of this seam's own) would leave "this environment id exists" distinguishable from "it does not" by status alone, rebuilding the oracle one layer up. One shape, byte for byte, for every way a caller can fail to be entitled to the environment it named.The cost is diagnosability, named rather than discovered: an operator whose environment genuinely lacks an
authservice sees the anonymous 401, not a wiring error. That is the same trade the sibling seams already make.undefinedand'platform'are not environments — a control-plane boot resolves none, so the anonymous floor is the whole gate there.Proposed
minor, with the migration note already written into the changeset. Please confirm or override; the ruling did not grade this card.Reasoning: this changes a published route's behaviour from "anonymous read" to "authenticated and ownership-checked", which plausibly breaks a caller that relied on the old behaviour (an anonymous consumer, a caller sending a contradictory
X-Environment-Id, a caller sending an unresolvable one, or a deployment whose environment kernel carries noauthservice of its own — all four are enumerated with remedies in the changeset). The nearest precedent on this lane is #13347, gradedminorwith a migration note for a purely additive published-envelope change, on the reasoning that a published shape change is minor even when additive. This one is a narrowing, sominorlooks like the floor rather than a generous read —patchseems wrong to me and I did not want to choose it quietly.Verification — at
2a4e9098e, the final commitAll exit codes captured before any pipe (
cmd > file 2>&1; EXIT=$?), every gate quoted from its own printed verdict line. Authored in sessionhttps://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk— recorded here in prose because editing a PR body rewrites the footer's session URL to the bare form.Tests.
pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2→Test Files 163 passed (163)/Tests 2740 passed (2740).Dogfood suite (
@objectstack/dogfood, 129 files, sharded 3 ways exactly as CI does) → 980 passed, 3 skipped, 0 failed: shard 1Test Files 43 passed (43)/Tests 313 passed (313), shard 243 passed (43)/288 passed | 1 skipped (289), shard 342 passed | 1 skipped (43)/379 passed | 2 skipped (381). 43+43+43 = 129 = the real file count, which is how I know--shardtook effect rather than being silently dropped. The case that was RED in CI now prints its own verdict line:✓ authz probe blind-spot census (#13260) > packages/rest/src/rest-server.ts — every positive control is still present in THAT file.Typecheck.
pnpm --filter @objectstack/rest typecheck→ exit 0;check:test-typecheck: OK — @objectstack/rest's test layer compiles under packages/rest/tsconfig.test.json; 1 file(s) / 2 error(s) / 1 pinned signature(s) held in test-typecheck-debt.json. ⛔ Not a NOT-MEASURED green:tsc -p tsconfig.test.json --listFilesputs a 660-file program on the table and all six changed files are in it (1 hit each), and the two ledgered errors are both insrc/rest.test.ts— none is mine.pnpm lint(repo-wideeslint . --no-inline-config) → exit 0, run in full. ⛔ No narrowing claimed. Run because this lane compensates by hand fordispatch-gates.mjsnot naming the lint family.Gate union, re-derived after merging
origin/mainwithnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(change set taken from the merge base by the script itself). The first derivation printed a STALE TREE banner on stderr while still exiting 0 —origin/mainhad moved 5 commits — so it was re-derived after the merge and that second answer governs. Green, each read from its own verdict line:check:authz-resolver·check:route-envelope·check-system-context-census·check:nul-bytes·check:cross-package-test-inputs·check:dispatcher-error-vocabulary·check:doc-authoring·check:logger-receiver-detach·check:objectql-double-limit·check:page-declaration-shape·check:published-files·check:slot-lookup·check:test-source-alias·check:type-source-resolution·check:where-matcher·check:changeset-gate-self-tests·check:objectui-changeset·check:pm-half-states·check-adr-0087-registration·check-changeset-no-major·check-empty-changeset·check-ci-filter-parity·check-comment-mask-adoption·check-keyed-text-bounds·check-plugin-teardown-shape·check-shard-attestation·check-undeclared-dep-imports·docs-audit/check-affected-docs·docs-audit/check-drift-comment·release-rehearsal-clone --self-test.Refusals, reported separately and NOT folded into the green list: none. No gate exited "PREREQUISITE NOT MET" in this run.
Patch round 2 — the census table conflict, resolved by REGENERATION
The PR went
mergeable_state: dirtybehind 7 commits. Exactly one file conflicted —content/docs/permissions/system-context.mdx, the census anchor table — whilepackages/rest/src/rest-server.tsauto-merged clean.⛔ Not hand-resolved, and not resolved by picking a side. Both sides had run the gate's
--fixagainst different trees, so each side's line numbers were right for its own tree and neither was right for the merged one. Resolution was: clear the marker with an arbitrary side as a placeholder, then runnode scripts/check-system-context-census.mjs --fixagainst the merged tree and let it rewrite the table.⭐ The regeneration is what proves hand-merging would have been wrong, and by more than the conflict showed. It rewrote 18 anchors, not 5:
rest-server.tsanchors resolved to4408, 5771, 6019, 6382, 6575— matching neither side (mine said4407, 5770, 6002, 6365, 6558; main said4284, 5647, 5895, 6258, 6451). Either side taken whole is internally plausible and wrong.plugin-sharing/src/sharing-service.tsshifted+29(625→654,891→920,978→1007,1568→1597,1309→1338) and four morerest-server.tscitations moved+1. Measured cause: main's 7 commits are+35 / -6insharing-service.ts. Git auto-merged those rows without a marker, so resolving only the marked conflict — by any method — still leaves the file wrong. Only regeneration reaches them.Verdict line, quoted from the gate run without
--fixat the merged head:check-system-context-census: OK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read.⛔
scripts/pm/os-regen-merge.shdoes not cover this path, confirmed independently rather than taken on report:git check-attr mergeanswersunspecifiedforcontent/docs/permissions/system-context.mdxandos-regenforcontent/docs/references/**. This file has no merge driver.Every anchored count re-derived at the merged head (main touched
rest-server.ts, so none was carried forward): census 73 sites / 92 mentions / 20 caught / 53 bare / 53 bare-guarded / 0 caught-guarded; dogfood controlenforceAuth64,routeManager.register80, registrars 17; ownership guard call sites 1. All unchanged. The gate union re-derived to the same 45 families as the previous head, with no stale-tree banner.Dogfood suite, all three shards re-run at this head (⛔ no narrowing this round — the previous round's declared narrowing does not carry across a 7-commit merge): shard 1
Test Files 43 passed (43)/Tests 313 passed (313); shard 243 passed (43)/288 passed | 1 skipped (289); shard 342 passed | 1 skipped (43)/379 passed | 2 skipped (381). 43+43+43 = 129 = the real file count, so--shardtook effect.Patch round — the fourth count anchor, and why a package-scoped run could not see it
CI went red on
Dogfood Regression Gate (3/3):control "enforceAuth" moved in packages/rest/src/rest-server.ts: expected 64 to be 61.⛔ Re-anchored, not relaxed.
PROBE_FILE_CENSUS's control exists to prove that census is still reading the file it thinks it is, and a risingenforceAuthis exactly what this card is supposed to cause. The recorded number is now the measured one and it is still an exact count — a range or a floor would stop the row noticing the next move, which is the only thing it is for.The move is +3 over the whole file, because the derivation counts the bare term with comments included: one new call site (
if (this.enforceAuth(req, res, context)) return;, 52 → 53) plus two prose mentions in the new doc-comments. The three sibling numbers were re-derived and did not move —population80,reachable19,private register*Endpoints(17,this.routeManager.register(80 — which is what says this is a guard change and not a surface change, soblindSpotstays 61.Why it was missed, which is the transferable part:
packages/qa/dogfoodholds anchors citing other packages' files and is not reached bypnpm --filter @objectstack/rest .... A package-scoped suite structurally cannot see it; only the Dogfood Regression Gate runs it. Any change torest-server.tsowes the dogfood suite a local run.A second miss, found by re-deriving rather than by CI: my first-round gate union was derived before the
system-context.mdxrepair was applied, so it never listed the 11 documentation families that file pulls in. Re-derived after the merge, they are now run and green:check-doc-frontmatter·check-doc-route-spelling·check-docs-section-name·check-section-landing-index·check:doc-anchors·check:docs-audit-scope·check:docs-redirects·check:docs-single-h1·check:published-readme-links·check:react-page-adapter-contract·check:role-word, pluscheck-cross-package-test-inputs. That is the derivation rule earning its keep: the gate list must be re-derived against the diff you actually have, not the one you started with.Merge collision, resolved and verified. PR #13614 landed mid-round and edits
execctx-consumer-census.test.ts— the same file I re-anchored. The merge auto-resolved; its§9and my numbers coexist, and the census was re-derived on top of it at the final head rather than carried forward: 73 sites / 92 mentions / 20 caught / 53 bare / 53 bare-guarded / 0 caught-guarded, all unchanged.One declared narrowing. Dogfood shards 2 and 3 were measured at the immediately preceding tree, not re-run at
898b1d705. That is a measurement rather than an omission: the delta between the two trees is exactly six files (git diff --name-only), zero of which are referenced anywhere underpackages/qa/dogfood, and the same grep findsrest-server.tsin 5 dogfood files, so the zero is a reading and not a broken instrument. Shard 1 and the previously-red file were both re-run at the final head. The lock had two waiters, one at 750s, and re-running the other two shards would have starved them for ~8 minutes to re-measure a tree delta that provably does not reach them.⭐ Ablation — direction predicted before running, and the first attempt was a no-op
Prediction, recorded before the run: removing only the ownership comparison builds option B exactly, so the result should be a split, not a uniform flip — red on every leg whose caller is authenticated but anchored elsewhere or names an environment the chain did not serve; green on every anonymous leg and on the whole identity suite, because
enforceAuthstill fires there. Predicted magnitude 10–14 red, 0 inui-view-route-identity.measurement.test.ts.Result: 12 failed / 41 passed, 11 in the tenancy suite and 1 in the ownership suite, 0 in the identity suite. Every red is an authenticated-outsider, ghost-id, oracle-closed, precedence or registrar-source leg. The prediction held on direction, on split and on magnitude.
⛔ The first ablation attempt measured nothing and this records it rather than quietly re-running. It reported 53/53 green — which my own pre-registered third branch ("if nothing flips, the pins are not reading the guard") flagged. Cause: the mutation lived in a script with
trap restore EXIT, and the vitest run was chained after that script with&&, so the trap restored the file before vitest ever loaded it. The run was re-done with the suite invoked inside the trap's scope.Mutation proven on disk, never from an editor exit code: anchor asserted unique before mutating (1), anchor after 0, injected text after 1, blob moved
19e33da0→29921cee; the script aborts if any of those fails. The tree was re-hashed after the run and still read29921cee, so the reds were taken on the mutated tree. No rebuild needed, and here is why rather than an assumption: the mutated subject isrest-server.ts, imported in-package as./rest-server.jsand resolved by vitest to the TS source, not through a dependency'sexportstodist/— the 12 reds with no rebuild are themselves the proof the mutation reached the code under test.Restore leg given equal treatment, proven by observation and not by an exit code:
git checkout HEAD -- "$REPO_ROOT/packages/rest/src/rest-server.ts"(absolute path, HEAD-pinned to bypass the index) → restored blob19e33da0equals the HEAD blob, injected text count 0, anchor count 1,git diff HEADempty,git statusclean.The two existing measurement suites: inverted, ⛔ not deleted and ⛔ not weakened
Both were on main pinning the leak as present. Each reading that said "B's view crosses to a caller with no claim on B" now says "it is refused", against the same instrument, the same fixtures and the same controls, with the pre-repair reading kept in the comment beside its inverse — an assertion with no history cannot tell a later reader whether the defect ever existed.
One method change is called out in the file rather than hidden: the identity suite's §3 used to compare an absent caller against an entitled one through the seam. The seam no longer serves the absent one, so that comparison is unavailable and has ⛔ not been faked by relaxing an assertion. The producer question is unchanged and still answered — by calling the producer directly, plus a new reading the old method could not take: when the seam refuses, the producer is never reached (recorder sees 0 calls, with the same recorder reached once by an entitled caller as the control).
ui-view-environment-ownership.test.tsis new and it is the load-bearing half. Both existing suites stubresolveExecCtxwholesale, so__authEnvironmentIdis synthetic there — they are complete readings about the gate and no reading at all about the fact. IfcomputeExecCtxstamped the resolved environment unconditionally (the tempting shortcut), the gate would compare a value to itself, refuse nobody, and every assertion in those two files would stay green. The new file drives the unstubbed method and pins that the value is truthful, including the cross-environment fallback branch where it deliberately does not match.I predicted the foreign kernel would still be ACQUIRED before the refusal on the tenancy instrument (matching the sibling
/data/:objectordering the ruling recorded as input knowledge). It measured empty. The prediction is right about production and wrong about that instrument:instrument()replacesresolveExecCtx, so the onlygetOrCreatecaller left isresolveProtocol, which the repair moved to after the refusal. Unstubbed,computeExecCtxdoes acquire the named environment's kernel, and that is now pinned in the new file (authLookups[0]is the foreign environment,acquiredcontains it). ⛔ The zero must not be read as "the ordering property has been repaired" — it has not been, and repairing it is a separate card.Fences
packages/core/src/security/auth-gate.ts— not edited, and not in the diff.auth-gate.ts,packages/runtime/src/http-dispatcher.ts, a new adapter underpackages/adapters/**); this diff touches none. The synthetic-caller trigger from option B ("re-open if a second transport adapter or a synthetic-request caller appears") is also not met: the new call site is A1,packages/restenforceAuth, whosepathExemptprecondition from fix(rest): exempt a request from the ADR-0069 gate only when it carries a real path (#7432) #7836 makes a populated non-empty string a precondition of reachingisAuthGateAllowlistedat all, and every synthetic request in the tests populatespath. Reported, ⛔ not acted on.packages/qa/dogfood/test/authz-conformance.matrix.ts— not edited, not in the diff. No row added,discover()not widened, ratchet untouched. That gap is authz-conformancediscover()reaches 1 of 17 route registrars in rest-server.ts — the ratchet's route-completeness guarantee is false for 66 of 85 mounted routes #13260 / Repair the authz-conformance ratchet's route population — decide where it comes from before widening any regex (80 entry points measured invisible) #13411 territory and still unruled.#13259is not addressed here. It already landed on main independently (thehiddenpriority-pass filter), and the tenancy suite re-measures the repaired producer on the served path rather than assuming it.The diff leaves
packages/rest/in exactly one file:content/docs/permissions/system-context.mdx.check-system-context-censuswent red on my first run with 10 problems, all pure line rot — my edits shifted fiverest-server.tsanchors by exactly +124 lines each. The repair is the gate's own prescribed--fix, it rewrote one table row and five line numbers and nothing else, and the gate then printedOK — 109 elevation read sites in 20 packages across 45 files, all anchored. Any edit torest-server.tsabove line 4283 causes this. Flagged for the PM rather than passed over.Residual, measured and not closed here
For an authenticated caller the two refusals are byte-identical, so the id oracle is closed for them. The remaining discriminator is narrower and worth naming: a caller naming a real foreign environment is refused by
enforceAuthon the identity step, whereas one naming an invented id is refused by the ownership comparison — today both answer the same 401 with the same body, which is why the oracle is closed. It stays closed only as long as those two answers remain identical, which is the reason the shape is asserted rather than described (see the id-oracle case in §4 of the tenancy suite). ⛔ Nothing here is left silently unrefused.Generated by Claude Code
Generated by Claude Code