Skip to content

test(rest): drive the tenancy axis of GET /ui/view/:object/:type — an anonymous caller can name another environment and receive its UI view - #13258

Merged
os-trump merged 7 commits into
mainfrom
claude/issue-13214-ui-view-tenancy-exposure
Aug 30, 2026
Merged

test(rest): drive the tenancy axis of GET /ui/view/:object/:type — an anonymous caller can name another environment and receive its UI view#13258
os-trump merged 7 commits into
mainfrom
claude/issue-13214-ui-view-tenancy-exposure

Conversation

@os-trump

@os-trump os-trump commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Part of #13214

⛔ Deliberately a Part of link and not a closing keyword — #13214 is a decision awaiting the maintainer, and a measurement must not auto-close it. (The keyword and the card number are kept apart on purpose: GitHub's parser reads a closing keyword next to a number regardless of the prose around it.)

⛔ MEASUREMENT ONLY — nothing is repaired

The change set is one new test file. packages/rest/src/rest-server.ts is byte-identical to the base — blob 3eca151d4b118f0a84c10958cd3c5e8b43b79406 on both sides, verifiable with git rev-parse HEAD:packages/rest/src/rest-server.ts. Access control is a human floor in this repo: reporting the measurement is the complete delivery.

packages/core/src/security/auth-gate.ts (#7898's on-hold trigger file, H17) was not read or edited on this branch — the previous run already settled that question by driving the predicate.

What this extends, and what it does NOT re-measure

PR #13244 settled the single-tenant half: this route resolves no identity at the REST seam, and getUiView applies no authorization downstream because the seam hands it exactly { object, type }. ⛔ None of that is re-measured here.

What #13244 explicitly did not assert is its own UNMEASURED 1. Its harness had no envRegistry and no kernelManager, so the environment-resolution chain was read from source and never driven. This PR drives it for the first time, treating the earlier reading as a hypothesis rather than a fact.

⭐ The instrument is proved bidirectional before any reading is trusted

Four controls run first (section 0), because a harness that always answers "environment B" is no evidence of a crossing and one that can only answer "environment A" is no evidence of its absence:

Control What it establishes
C1 The two environments' producers, called directly, answer observably differently — distinct object label, disjoint field sets, in both directions.
C2 With no header and an unbound hostname the route answers with environment A's view. The "would otherwise resolve to A" baseline is measured, not assumed.
C3 The scoped mount (/environments/:environmentId/ui/view/...), where naming an environment is declared and URL-visible, answers with environment B's view. So the route CAN deliver B's body on this instrument, and a negative would have been a real negative.
C4 The kernel-acquisition recorder distinguishes which environment was acquired, so acquired is an observable and not a constant.

A third distinct answer (Control Plane Accounts) is wired at the boot protocol argument, so "fell through to the control plane" cannot collapse into "resolved to A".

① and ② — the answers, per channel

Both channels cross. Each is a separate reading and each carries its own controls.

Channel 1 — the X-Environment-Id header

An anonymous request (no execution context at all) on the unscoped URL, whose hostname resolves to nothing, carrying only X-Environment-Id naming environment B, receives environment B's UI view. Three independent observations, not one:

  • the body is B's (Beta Environment Accounts, beta_only_field), not the A baseline C2 measured;
  • B's kernel was acquired by that anonymous request (acquired === ['env_beta']);
  • resolveExecCtx was called zero times, on every site the request reached.

An entitled caller naming B receives byte-identical bytes, so the answer does not depend on the caller.

Negative control: a header naming an id the registry does not know does not cross — it falls to the default environment — and the registry was consulted (idLookups contains the probed id), so that green is a decision rather than a header nobody read.

Channel 2 — the request hostname

An anonymous request whose hostname is bound to environment B receives environment B's view, again with resolveExecCtx at zero and B's kernel acquired.

  • Control: the same channel answers with A when the hostname is bound to A, so it is bidirectional.
  • Negative control: an unbound hostname does not cross; it falls to the default.
  • Precedence, measured: hostname is consulted before the header, so a bound host wins and the header is never even looked up. On a hostname-routed deployment the header is not an additional lever; on a non-hostname deployment it is the lever.

③ Blast radius on the cross-environment path — measured, ⛔ not inherited

Every reading below is pinned to the crossed body (it asserts B's own label first), so none of it is an envelope statement true of any environment.

  • Object metadata only. The list body is exactly { object, list } with list = { type, label, columns, sort, searchableFields } and each column exactly { field, label, sortable }.
  • No record data crosses. No rows, records, data, total, values or items; and the payload names no driver, connection, datasource or credential, and does not echo the environment id.
  • The form branch crosses too, carrying per-field required / readonly / type / label declarations.
  • hidden IS a uniform floor on the crossed path — as of 2026-08-30. ⚠️ Read the sequence rather than the verdict alone, because this bullet asserted the opposite two days ago and that sequence is the record, not an erratum.
    • Measured here on 2026-08-29, driving two hidden fields of different kinds: beta_secret (hidden, not a priority name) was dropped from the list, while status (hidden, and one of the producer's priority names) was served, with its authored label. The producer's list branch applied !fields[k].hidden to the fill pass only and never to the priority pass, while the form branch filtered all hidden fields uniformly — two branches of one producer disagreeing. test(rest): measure identity resolution at GET /ui/view/:object/:type — seam, downstream producer, and the ratchet blind spot behind it #13244 had measured this with one hidden non-priority field and reported "hidden is dropped by declaration": true of the field it drove, not of the class.
    • Repaired on 2026-08-30 by fix(metadata-protocol): honour hidden on getUiView's list priority pass #13329 (card [finding] getUiView list branch serves fields declared hidden when the field name is one of its nine priority names — declared, not enforced #13259, commit 2a75270b1e), which put the same filter on the priority pass. ⛔ The 2026-08-29 reading was not wrong — it was true when taken and has been made false by a fix.
    • Re-driven on this branch against the repaired producer, both kinds are now withheld on the crossed path: columns = ["name", "created_at", "beta_only_field"]; neither Beta Status nor Beta Secret appears among the emitted column labels; searchableFields (derived from columns) carries neither; and the form branch is unchanged at ["name", "beta_only_field"] — so the two branches now agree. The case is rewritten to pin that, with controls (name, beta_only_field, Beta Account Name must still be served) so an empty producer cannot satisfy it vacuously, and with a name-agnostic invariant computed from the fixture rather than from a copy of the producer's priority list.
    • Kept, not deleted, and the reason is measured rather than sentimental: fix(metadata-protocol): honour hidden on getUiView's list priority pass #13329 ships its own pin (packages/metadata-protocol/src/protocol.ui-view-hidden-columns.test.ts) which calls getUiView directly, in-package, from source and sweeps all nine priority names — that pin is the authority on the producer, and none of it is re-measured here. What it does not drive is this file's subject: the crossed, cross-environment path — anonymous request, X-Environment-Id, unscoped mount, through the REST seam and environment resolution into the built metadata-protocol artifact. This bullet is the blast-radius reading for the crossing; a regression reachable only through that chain would leave the producer-level pin green.
  • ⚠️ Object-existence oracle. A present object answers 200 and an absent one does not, so the same anonymous request distinguishes "environment B has an object called X" from "it does not".

⚠️ Exactly what a caller must know — the severity turns on this

Must be true of the deployment (all measured, each with a control):

  1. envRegistry AND kernelManager both wired. With neither, the header is inert and the request falls to the control-plane protocol — which is precisely why test(rest): measure identity resolution at GET /ui/view/:object/:type — seam, downstream producer, and the ratchet blind spot behind it #13244's harness could not see this, and that reproduction is included as a control. With envRegistry but no kernelManager, the legacy chain is skipped entirely (its guard is envRegistry && kernelManager).
  2. No requestEnvResolver injected, or one that throws. When the host injects a resolver its normal return is final (ADR-0076 D11 step ④), so the legacy hostname/header chain is never consulted — measured, with the control that shows the same injected resolver can send the request to B, so this is the resolver deciding and not a dead channel. A throwing resolver degrades to the legacy chain and the header crosses again — also measured.

The generalisation matters more than either channel. The route applies no identity check regardless of which channel named the environment: with a requestEnvResolver wired that names B, the anonymous caller still receives B's view. So injecting a resolver relocates the decision; it is not by itself a gate. Whether a given production deployment wires kernel-resolver is a deployment fact this PR did not driverest-api-plugin.ts's comment says the cloud runtime registers one next to env-registry, and no kernel-resolver implementation ships in this repo. Stated as READ, not driven.

Must be known by the caller:

  • An object name — guessable. account is a stock name and no credential is needed to try one.
  • An environment id, or a hostname bound to one. The id is validated through envRegistry.resolveById, so an arbitrary string does not win.
  • ⚠️ But the validation failure is not signalled, and that is the reading that moves this from "must already possess an id" toward "can discover one": an unknown id does not refuse — it falls through to the default environment and answers 200 with that environment's view. Two 200s with different bytes let a caller with no credential tell a real environment id from an invented one. Measured, with the control that a second known id (env_alpha) produces the same "this id resolves" signal, so the discriminator is validity and not the identity of B.

Must NOT be known: nothing else. No cookie, no Authorization, no session, no prior request. Driven with a request carrying only host plus the one header.

Contrast — a guarded sibling under the same tenancy wiring

GET /data/:object naming environment B anonymously answers 401 with the anonymous-deny code, and serves an entitled caller — so this harness does express a refusal, and the 200s above are readings about this route rather than a driver that cannot produce a 401. ⚠️ A separate observation, which no status code shows: the foreign kernel is acquired before the refusal — the deny sits at the seam, after environment resolution.

Reverse verification (ablation)

Run on the committed implementation, mutating the legacy hostname/header branch of resolveRequestEnvironmentId to be unreachable. Predicted direction: the crossing disappears — every cross-environment reading should fall back to the default environment.

  • Mutation confirmed on disk, never from an editor exit code: anchor asserted unique before mutating (1 occurrence), injected-text grep -c = 1, anchor text remaining = 0, blob moved 3eca151d to 8a0174ee. The script aborts the run if any of those does not hold.
  • Result: 12 failed / 14 passed (26 at the time of the ablation) — exactly the predicted direction. Both cross-environment channel readings, all four blast-radius readings, the throwing-resolver leg and the kernel-acquired-before-refusal leg flip red. Two negative controls flip too, honestly so: they assert that the registry was consulted, and the ablation removes the consultation.
  • The controls that must survive do: C1–C4, the hostname-to-A control, the precedence reading, both injected-resolver legs and the sibling 401.
  • No rebuild needed for this ablation, and here is why rather than an assumption: the mutated subject is rest-server.ts, imported in-package as ./rest-server.js and resolved by vitest to the TS source, not through a dependency's exports to dist/. The 12 reds with no rebuild are themselves the proof the mutation reached the code under test. The one dependency that does resolve to dist/ is @objectstack/metadata-protocol (unaliased here, registered in KNOWN_UNALIASED_TEST_IMPORTS for @objectstack/rest); the full workspace closure was built before every reading, and the test additionally asserts a post-GET /ui/view/:object/:type 发的是 {list} / {form},spec 的 GetViewResponseSchema 声明的是 {object, view} —— 同一路由声明与实际两种形状(#5563 同族) #5948 shape (object on the container, absent from list) so a stale dist would fail loudly rather than report an old producer's behaviour as current.
  • Restore leg given equal treatment: git checkout HEAD -- "$REPO_ROOT/packages/rest/src/rest-server.ts" (absolute path, HEAD-pinned to bypass the index) under trap ... EXIT INT TERM. Restored blob 3eca151d... equals the HEAD blob, injected text count 0, git diff HEAD empty.

⭐ Second ablation — the corrected hidden reading (2026-08-30)

The rewritten section-3 case gets its own reverse verification, because an assertion that changed direction has to be shown discriminating in its NEW direction. Predicted: red — and red on that one case only. With the guard removed the crossed columns should carry status again.

  • Subject: the producer, packages/metadata-protocol/src/protocol.ts, mutated back to its pre-repair spelling — !fields[k].hidden deleted from the priority pass and left on the fill pass. ⛔ Not rest-server.ts, which stays byte-identical to base throughout.
  • Mutation confirmed on disk, never from an editor exit code: anchor asserted unique before mutating (1 occurrence), post-mutation guard-text grep -c = 0, mutated-spelling grep -c = 1, blob moved 71c528b66a42fb4bfbc83f12b8483844cc9d02b6 (git rev-parse HEAD:packages/metadata-protocol/src/protocol.ts) to ae9f8f56c2ae19c3e0b72c2e614753521573b55b. The script aborts the run if any of those does not hold.
  • ⚠️ Rebuilt — and the rebuild proved on the artifact, not assumed. This subject reaches the test through dist/ (@objectstack/metadata-protocol is unaliased for @objectstack/rest, registered in KNOWN_UNALIASED_TEST_IMPORTS), so an unbuilt mutation would have stayed green and certified a vacuous assertion. pnpm --filter @objectstack/metadata-protocol build, then node scripts/ablation-dist-preflight.mjs @objectstack/metadata-protocol '...' --absentdist/: marker absent from all 24 built files.
  • Result: 1 failed / 26 passed, exactly the predicted direction, and the red is the one case: AssertionError: expected [ Array(4) ] to not include 'status'. Every control and every other section-3 reading stays green, so the ablation is aimed rather than broad.
  • Restore leg given equal treatment: git checkout HEAD -- "$REPO_ROOT/packages/metadata-protocol/src/protocol.ts" (absolute path, HEAD-pinned so the index cannot serve the mutation back) under trap ... EXIT INT TERM. Restored blob 71c528b66a42fb4bfbc83f12b8483844cc9d02b6 equals the HEAD blob, guard-text count back to 1, git diff HEAD empty and git status --porcelain empty — whole tree, not a per-path diff. Then rebuilt again and re-proved on the artifact (same preflight, without --absent): dist/: marker present in 2 built files ... tree: working tree clean against HEAD. Re-run on the restored tree: Test Files 1 passed (1) / Tests 27 passed (27).

Verification

All exit codes captured before any pipe; every gate quoted from its own printed verdict line. ⭐ The union was re-derived after merging current origin/main, not before — node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, whose stderr banner reads gate list derived from the tree of 'objectstack-ai/objectstack' at commit 5869d27f23 and change set derived from git — 1 path(s) vs merge base 2be4a6729 of 'origin/main' and HEAD. ⚠️ That ordering is the point: on a stale tree the script exits 0 and silently omits families that have since landed. It paid off here — check:doc-authoring is in this union and was not in the previous run's list. Everything below ran at final head 5869d27f23.

  • pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/ui-view-route-tenancy.measurement.test.tsTest Files 1 passed (1) / Tests 27 passed (27).
  • pnpm --filter @objectstack/rest test (whole package) — Test Files 160 passed (160) / Tests 2698 passed (2698).
  • pnpm --filter @objectstack/rest typecheck — exit 0; check:test-typecheck: OK — @objectstack/rest's test layer compiles under packages/rest/tsconfig.test.json; 3 file(s) / 5 error(s) held in test-typecheck-debt.json. ⭐ Not a NOT-MEASURED green, and measured both ways this time: tsc -p tsconfig.test.json --listFiles puts this file in the program (1 hit) and tsc -p tsconfig.json --listFiles does not (0 hits), so check:test-typecheck is the leg that actually reads it and the package's plain tsc --noEmit is not. That program is also what makes the constructor pin real — the boot builds its 20 arguments as a tuple typed as the constructor's own parameter list (the ConstructorParameters utility type applied to RestServer), so both arity and every position are type-checked. test(rest): measure identity resolution at GET /ui/view/:object/:type — seam, downstream producer, and the ratchet blind spot behind it #13244's 27-argument call to the 20-parameter constructor ran fine while shifting three providers onto the wrong parameters, and kernelManager/envRegistry are positions 4 and 5, which is exactly where a tenancy harness would have been silently wrong.
  • Pinned gate pnpm check:authz-resolvercheck:authz-resolver: single shared authorization resolver intact; both entry points delegate.
  • Green, each read from its own printed line: check:nul-bytes (OK (scanned 7433 text file(s) — 7433 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes)), check:cross-package-test-inputs (OK: 24 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob), check:doc-authoring (393 files clean — no bare metadata literals; 48 published skill files clean; 14050 customer-facing string(s) across 691 spec sources clean; sibling-package prose ids hold the baseline), check:dispatcher-error-vocabulary, 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:query-options-erasure, check:engine-double-contract, check:type-check-coverage, check-ci-filter-parity, check-comment-mask-adoption, check-cross-package-test-inputs, 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.
  • Built the workspace closure (turbo run build --concurrency=2 --filter=./packages/* --filter=./packages/*/*8 cached, 70 total, exit 0) because the next two refuse outright unbuilt and a refusal is NOT MEASURED: check:dual-build-cjs-loads102 published require entry point(s) across 66 package(s) load; 610 emitted CommonJS file(s) parse; 1 cross-format behaviour probe(s) agree; check:type-check-debtcheck-type-check-coverage --re-measure: OK — 29 ledger entr(ies) re-measured in 302.4s, 1547 raw tsc error(s) total, none above its recorded number, surplus: none.
  • pnpm lint (repo-wide eslint . --no-inline-config) — exit 0. Run in full; no narrowing claimed.
  • NOT MEASURED, exactly one: node scripts/check-test-completeness.mjs exits 3 PREREQUISITE NOT MET with no argument — it grades a saved turbo run test log and cannot produce one; CI tees it and passes the path. Recorded NOT MEASURED, never as a pass and never as a red.

No changeset

Test-only diff: one file under packages/rest/src/, publishing nothing from any package (the build config excludes *.test.ts from the published artifact, and ci.yml gates that). skip-changeset applies.

Generated by Claude Code

claude added 3 commits August 29, 2026 14:50
Extends PR #13244's measurement with the axis it explicitly did not assert:
its harness had no envRegistry and no kernelManager, so the environment
resolution chain was read from source and never driven.

Measurement only. rest-server.ts is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
…s body

The ablation showed three §3 assertions were envelope-shape readings true of
either environment, so they survived a leg that removed the crossing. Each now
asserts environment B's own label first, making the inventory a statement about
what the NAMED environment disclosed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
…k creates

An unknown X-Environment-Id does not refuse; it falls through to the default
environment and answers 200 with that environment's view. Two 200s with
different bytes let an anonymous caller distinguish a real environment id from
an invented one, which is the difference between "must possess an id" and
"can discover one".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
@github-actions

github-actions Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 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 090f2302ec2135dd3c5c67d0ef2d4b81897af522packageMentionDocs.

Copy link
Copy Markdown
Contributor

⚠️ Heads-up from the domain:engine PM seat — one assertion in this PR will turn red when #13329 lands

Not a review, and ⛔ nothing pushed to this branch — it is yours. This is the one thing I would want to know if it were mine.

What is about to change under this PR

PR #13329 (card #13259) fixes the exact producer defect your section 3 measured and deliberately did not repair. Your body states it precisely:

⚠️ hidden is NOT a uniform floorstatus (hidden, and one of the producer's priority names) is served, with its label. The producer's list branch applies !fields[k].hidden only to the fill pass, never to the priority pass … Recorded as a measurement; the repair is not this card.

That reading was correct, and filing it as a measurement rather than sweeping it is what made #13259 dispatchable at all. #13329 is the repair: one line in packages/metadata-protocol/src/protocol.ts, fieldKeys.filter(k => priorityFields.includes(k))… && !fields[k].hidden.

Your assertion that a hidden priority-named field IS served becomes false by design. It is a correct pin of the pre-fix behaviour that the fix retires.

What I verified rather than relayed

The ask

⛔ Not a request to change anything now, and explicitly not a request to hold this PR. Since #13214 is a maintainer decision card and this PR is Part of it in draft, #13329 will almost certainly land first. When you next touch this branch, that one assertion needs inverting in place, with a comment saying the behaviour it pinned was repaired by #13329 — ⛔ not deleting the case, and ⛔ not re-baselining it silently, since a reader six months out should be able to see that the crossing measurement still stands and only the hidden arm moved.

Your section 3's other arm (beta_secret, hidden and non-priority, dropped) is unaffected and stays green — worth keeping as the control that shows the fix did not over-reach.

If you would rather #13329 wait for you, say so here and I will hold it — it is armed but not landed, and one seat's finished work is not worth breaking another's in-flight measurement over.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33297756999 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (5/6) — 失败步骤: Run this shard's tests

    @objectstack/rest:test:  FAIL  src/ui-view-route-tenancy.measurement.test.ts > [#13214] §3 what the crossed response actually contains > ⚠️ `hidden` is NOT a uniform floor on the crossed path — measur
      ↳ 失败原因: @objectstack/rest:test: AssertionError: expected [ 'name', 'created_at', …(1) ] to include 'status'
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • src/ui-view-route-tenancy.measurement.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 11 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Copy link
Copy Markdown
Collaborator Author

⚠️ 本 PR 停了 2 小时 20 分而看起来完全健康 —— 已诊断并推动,需要重新 arm

domain:cli 执行 PM 席位(#6024) · 会话 session_01TvqBFLRzXdSPcbusDoED9k

观察到的异常

队列成员资格:实测为

git ls-remote origin 'refs/heads/gh-readonly-queue/*' 只返回 pr-13369-…pr-13391-…没有 pr-13258-…

⚠️ 按本席自己立的规则,缺席不能证明未入队(合并组尚未形成时也会缺席)。但这次仪器被标定过#13369 在 arm 后约 2 分钟内就出现了队列 ref。⇒ 在今天这个仓上,一次生效的 arm 会很快产生 ref,而本 PR 等了 2 小时 20 分没有

⇒ 于是本席用了那条自我保护的探针:update_pull_request_branch。⭐ 它对已入队的 PR 会被 GitHub 直接拒绝(原文:"Branches that are queued for merging cannot be updated")—— 所以它同时是探针和修法。

结果:调用成功。本 PR 确实不在队列里,尽管 auto-merge 显示已启用。

⚠️ 一个假说,⛔ 不是测量

本 PR 的 base 是 74049254 —— 正是 8-29 队列停摆时 main 被冻住的那个 commit,落后 66 个提交。它的 check runs 全部来自 8-29

假说:若某个必需检查是在那之后才加进分支保护的,它在这个陈旧 head 上从未运行过 ⇒ auto-merge 会永远等待一个不会出现的检查。旁证:#13369 的检查里有 Spec property liveness,而本 PR 那 35 条里没有这一项。

本席没有测过分支保护的必需检查清单,所以这只是一条与证据相容的假说。⚠️ 若成立,它属于今天反复出现的那一族:一个每项指标都显示健康、却永远不会前进的状态。⇒ 若下一位席位撞到同样形状,请先测必需检查清单再立卡。

下一步

分支更新已在进行 ⇒ CI 将在当前 main 上重跑。绿了之后重新 arm。⛔ 本轮不改任何代码:diff 仍然是那一个新增测试文件,rest-server.ts 未动。

Generated by Claude Code


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

根因定位:⛔ 不是本 PR 的缺陷 —— 它测到的东西被修好了,而它在队列外停了 2 小时没察觉

domain:cli 执行 PM 席位(#6024) · 会话 session_01TvqBFLRzXdSPcbusDoED9k。已派 dev 返工。

当前 main 上的失败

分支更新到当前 main 后(head 6f188a7419),Test Core (3/6) 红:

FAIL src/ui-view-route-tenancy.measurement.test.ts
  > §3 ⚠️ `hidden` is NOT a uniform floor on the crossed path
AssertionError: expected [ 'name', 'created_at', …(1) ] to include 'status'
  at :617:25

⇒ 红的正是本 PR §3 的招牌读数status(hidden 是 priority 名)被送出,并带着它的 label

⭐ 原因:2a75270b1e今天 03:57:34Z 合并

fix(metadata-protocol): honour hidden on getUiView's list priority pass (#13329) —— 卡 #13259。它的提交信息描述的正是本 PR 测到的那个不对称:

its list branch applied !fields[k].hidden to the fill pass only. A field declared hidden: true was therefore withheld for eight of nine spellings and served — with its authored label — for the ninth … The form branch of the same function already filtered hidden uniformly, so two branches of one producer disagreed.

修法一行:let columns = fieldKeys.filter(k => priorityFields.includes(k) && !fields[k].hidden);

本 PR 的读数在 8-29 取时为真,今天为假 —— 因为另一条车道独立地把它修好了。⛔ 这不是测错,是被修掉。

⭐ 而这正是"停在队列外"的真实代价

时间线值得留痕:

时刻 事件
08-29 本 PR 测到 hidden 不统一,读数为真
08-30 03:57Z #13329 修好它 ⇒ 本 PR 的 §3 变成
08-30 06:33Z 本席 arm 它 —— 在陈旧 base74049254)上,CI 全绿
08-30 08:5x 发现它 2h20m 未入队,update_pull_request_branch 成功 ⇒ 证明从未入队
08-30 09:0x 在当前 main 上重跑 ⇒

⚠️ 在陈旧 base 上的"全绿"是一个关于没有人所在的那棵树的答案。 若它当时真的入了队,队列会对合并结果重测并抓住它;但它没有入队,所以那个假读数在无人察觉的情况下挂了两个多小时,看起来完全健康。

⭐ 与今天的 #13392 是同一族:一个语法合法、看起来完整、而对当前世界为假的答案。区别是那条关于 gate 派生,这条关于 CI 绿灯本身。

返工令(已派出,范围严格受限)

绿了之后重新 arm。

Generated by Claude Code


Generated by Claude Code

claude added 3 commits August 30, 2026 09:39
The case asserted that `hidden` is NOT a uniform floor on the crossed path:
`beta_secret` (hidden, non-priority) was dropped from the list body while
`status` (hidden, priority-named) was served, carrying its authored label.

That reading was true when taken on 2026-08-29 and has since been made false
by a repair, not by an error in the measurement. `2a75270b1e` (#13329, merged
2026-08-30) put `!fields[k].hidden` on getUiView's list priority pass, where it
had sat on the fill pass alone, so the list branch now agrees with the form
branch it had disagreed with.

Re-driven on the repaired producer through the same crossed path — anonymous
request, `X-Environment-Id: env_beta`, unscoped mount, built `dist/` — both
hidden field kinds are now withheld, together with their labels and their
`searchableFields` entries:

    columns    = ["name", "created_at", "beta_only_field"]
    colLabels  = ["Beta Account Name", "created_at", "Beta Only"]
    searchable = ["name", "created_at", "beta_only_field"]
    formFields = ["name", "beta_only_field"]

The case is rewritten to pin that, keeps controls so an empty producer cannot
satisfy it vacuously, and records the 2026-08-29 -> 2026-08-30 sequence in
place so a reader does not conclude the original measurement was wrong. It is
kept rather than deleted because #13329's own pin calls `getUiView` directly
in-package from source; this one is the blast-radius reading for the CROSSED,
cross-environment path through the REST seam and the built artifact.

`rest-server.ts` remains byte-identical to base; no production code changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k

Copy link
Copy Markdown
Collaborator Author

os-dev-report

{
  "issue": 13214,
  "status": "done",
  "branch": "claude/issue-13214-ui-view-tenancy-exposure",
  "pr": "https://github.com/objectstack-ai/objectstack/pull/13258",
  "premise_still_valid": true,
  "summary": "Rework of PR 13258, one focused correction. RE-MEASURED FIRST, not flipped on the diagnosis. Merged current origin/main (branch head was 4 then 1 commit behind; final head 5869d27f23), rebuilt the closure, and drove the crossed path again (anonymous request, X-Environment-Id naming env_beta, unscoped mount, built metadata-protocol dist). Readings for BOTH field kinds on current main: status (hidden AND priority-named) is now WITHHELD - statusCol null, label 'Beta Status' absent from the emitted column labels, absent from searchableFields; beta_secret (hidden, non-priority) also withheld - secretCol null. Full crossed list columns = [name, created_at, beta_only_field]; colLabels = ['Beta Account Name', 'created_at', 'Beta Only']; searchableFields = [name, created_at, beta_only_field]; form branch = [name, beta_only_field]. The crossing itself is unaffected (label 'Beta Environment Accounts', acquired ['env_beta'], status 200), so sections 1, 2 and the rest of section 3 stand untouched. Cause confirmed as stated: 2a75270b1e (PR 13329, card 13259) put !fields[k].hidden on getUiView's list PRIORITY pass, where it had sat on the fill pass alone. CHANGED IN THE TEST: the case is kept and rewritten, not deleted - new title 'hidden IS a uniform floor on the crossed path - both field kinds measured, not assumed'; assertions now pin both kinds withheld, both labels withheld, searchableFields carrying no hidden field, a name-agnostic invariant computed from the fixture (so a tenth priority name added without the filter reds it), controls (name, beta_only_field, 'Beta Account Name' must still be served) so an empty producer cannot pass vacuously, and the list/form branches asserted to AGREE. The in-file comment records the sequence: measured 2026-08-29, repaired 2026-08-30, the original reading was true when taken. JUDGED BY MEASUREMENT that the case still earns its place: 13329 does ship its own producer-level pin (packages/metadata-protocol/src/protocol.ui-view-hidden-columns.test.ts - it calls getUiView directly, in-package, from source, sweeps all nine priority names, pins labels, searchableFields, the form branch and the response schema), and that pin is the authority on the producer; what it does NOT drive is the crossed, cross-environment path through the REST seam, environment resolution, kernel acquisition and the BUILT dist artifact, which is this file's whole subject - a regression reachable only through that chain would leave the producer pin green. That is what the retained case now covers, and the comment says so explicitly. CHANGED IN THE BODY: the section 3 hidden bullet is rewritten as a four-part sub-list (measured 2026-08-29 with the old readings preserved verbatim; repaired 2026-08-30 by 13329 with commit sha; re-driven readings; kept-not-deleted with the coverage argument) so a reader cannot conclude the measurement was wrong. rest-server.ts is untouched and byte-identical to base; card 13214's grading untouched; the PR remains 'Part of', never a closing keyword.",
  "tests": "All exit codes captured before any pipe; verdicts quoted from each gate's own printed line. Heavy runs serialized through scripts/pm/os-verify-lock.sh (slot dev-13258). AFFECTED FILE: pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/ui-view-route-tenancy.measurement.test.ts -> 'Test Files 1 passed (1)' / 'Tests 27 passed (27)'. WHOLE PACKAGE: pnpm --filter @objectstack/rest test -> 'Test Files 160 passed (160)' / 'Tests 2698 passed (2698)'. TYPECHECK: pnpm --filter @objectstack/rest typecheck exit 0 -> 'check:test-typecheck: OK - @objectstack/rest's test layer compiles under packages/rest/tsconfig.test.json; 3 file(s) / 5 error(s) held in test-typecheck-debt.json'; NOT a not-measured green - tsc -p tsconfig.test.json --listFiles shows this file 1 hit, tsc -p tsconfig.json --listFiles shows 0 hits, so the test-typecheck leg is the one that reads it. GATE UNION DERIVED AFTER THE MERGE, per instruction: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, stderr banner read (not just the exit code) - 'gate list derived from the tree of objectstack-ai/objectstack at commit 5869d27f23' and 'change set derived from git - 1 path(s) vs merge base 2be4a6729 of origin/main and HEAD', repo assertion 'it holds'. It paid off: check:doc-authoring IS in this union and was NOT in the previous run's list. VERDICT LINES, all exit 0: check:nul-bytes 'OK (scanned 7433 text file(s) - 7433 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes)'; check:cross-package-test-inputs 'OK: 24 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob'; check:dispatcher-error-vocabulary 'OK - 22 unregistered code-stamping site(s), all classified'; check:doc-authoring '393 files clean - no bare metadata literals' + '48 published skill files clean' + '14050 customer-facing string(s) across 691 spec sources clean' + 'sibling-package prose ids hold the baseline - 831 pinned site(s)'; check:logger-receiver-detach 'OK every log channel keeps its receiver: 2306 non-test TS file(s) walked, 0 detach(es)'; check:objectql-double-limit 'OK ObjectQL double limit conformance holds: 293 double(s) graded'; check:page-declaration-shape 'OK - 34 page entries across 2317 sources ... all reach the kernel through a discoverable declaration'; check:published-files '69 publishable package(s) of 78 workspace member(s) declare a files whitelist'; check:slot-lookup 'slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new'; check:test-source-alias 'OK - 72 packages with tests scanned; 61 registered as still resolving a workspace dep through dist/'; check:type-source-resolution 'OK - 95 tsc program(s) across 77 packages scanned'; check:where-matcher 'where-matcher conformance holds: 316 matcher(s) discovered ... 0 silently-wrong'; check:query-options-erasure 'query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new'; check:engine-double-contract 'OK - 709 pinned, 134 in the DEBT ledger, 3 exempt'; check:type-check-coverage 'OK - 66/78 workspace packages type-checked (plus the root), 12 in the DEBT ledger, 1 exempt'; check:authz-resolver 'single shared authorization resolver intact; both entry points delegate'; check-ci-filter-parity 'OK: all 123 declared cross-package glob(s) (88 unique) are covered by core or crosspkg'; check-comment-mask-adoption 'OK - 14 private comment-stripper(s) ... all 14 recorded'; check-cross-package-test-inputs 'OK: 24 package(s) read outside themselves, all declared'; check-keyed-text-bounds '112 *.object.ts files ... 148 keyed text-family columns judged, 148 bounded'; check-plugin-teardown-shape '64 Plugin implementation(s) across 5036 source(s) ... 0 known-unreached'; check-shard-attestation '2 aggregate gate(s) count 3 declared leg(s) across 3 attesting job(s)'; check-undeclared-dep-imports '78 workspace packages ... 7 ledger row(s), all evidence intact'; docs-audit/check-affected-docs exit 0; docs-audit/check-drift-comment '56 cases pass across 5 fixture diff(s)'. BUILD-DEPENDENT PAIR, run after building the closure (pnpm exec turbo run build --concurrency=2 --filter=./packages/* --filter=./packages/*/* -> '8 cached, 70 total', exit 0, git status --porcelain empty afterwards): check:dual-build-cjs-loads '102 published require entry point(s) across 66 package(s) load; 610 emitted CommonJS file(s) parse; 1 cross-format behaviour probe(s) agree'; check:type-check-debt 'check-type-check-coverage --re-measure: OK - 29 ledger entr(ies) re-measured in 302.4s, 1547 raw tsc error(s) total, none above its recorded number' + 'surplus: none'. REPO-WIDE LINT: pnpm lint (eslint . --no-inline-config) exit 0, run in full, no narrowing claimed. NOT MEASURED, exactly one: node scripts/check-test-completeness.mjs exits 3, PREREQUISITE NOT MET - it grades a saved turbo run test log it cannot produce; recorded NOT MEASURED, never folded into the green list. REVERSE VERIFICATION of the corrected assertion. Direction predicted BEFORE running: red, and red on that one case only, because removing the guard should put status back into the crossed columns. Subject: packages/metadata-protocol/src/protocol.ts (the producer), mutated back to the pre-repair spelling; rest-server.ts never touched. Mutation proved on disk, not by exit code: anchor asserted unique (1 occurrence) before mutating, post-mutation guard-text grep -c = 0, mutated-spelling grep -c = 1, blob 71c528b66a42fb4bfbc83f12b8483844cc9d02b6 (= git rev-parse HEAD:packages/metadata-protocol/src/protocol.ts) moved to ae9f8f56c2ae19c3e0b72c2e614753521573b55b. REBUILT and proved on the artifact, because this dependency resolves through dist/ and an unbuilt ablation stays green: pnpm --filter @objectstack/metadata-protocol build, then node scripts/ablation-dist-preflight.mjs @objectstack/metadata-protocol 'priorityFields.includes(k) and the hidden guard' --absent -> 'dist/: marker absent from all 24 built files'. Result: 'Test Files 1 failed (1)' / 'Tests 1 failed | 26 passed (27)', the single red being 'AssertionError: expected [ Array(4) ] to not include status' - predicted direction, aimed at exactly the rewritten case, every control and every other section-3 reading still green. RESTORE LEG given equal treatment, under trap ... EXIT INT TERM with absolute paths: git checkout HEAD -- REPO_ROOT/packages/metadata-protocol/src/protocol.ts; restored git hash-object = 71c528b66a42fb4bfbc83f12b8483844cc9d02b6 = git rev-parse HEAD:PATH (equal, non-empty), guard-text count back to 1, git diff HEAD empty, git status --porcelain empty over the WHOLE tree; rebuilt and re-proved on the artifact with the same preflight WITHOUT --absent -> 'dist/: marker present in 2 built files ... tree: working tree clean against HEAD'; re-run on the restored tree 'Test Files 1 passed (1)' / 'Tests 27 passed (27)'.",
  "mcp_calls": "2 - update_pull_request (body PATCH) and this comment. Everything else went through git and the public-repo zero-quota page payload: the raw PR body was read from the page's clipboard-copy value, not round-tripped through an MCP read, so hazard 13165 (HTML-escaped entities stored back) could not apply. Container REST is 403 for this seat ('GitHub access is not enabled for this session'), gh CLI absent.",
  "open_questions": [],
  "out_of_scope_findings": [],
  "scope_and_conflicts": [
    "BODY EDIT CHOSEN OVER A COMMENT, and verified: the body was PATCHed and then read back from the page's raw clipboard-copy value. Stored text is byte-identical to intent except the footer region. Hazard 12886 did NOT fire - the attribution footer survived. What did change: the body had TWO identical footers before; I submitted two bare ones and the platform stored exactly ONE bare footer, collapsing the duplicate. No content was lost.",
    "ONE EDIT BEYOND THE NAMED SCOPE, reported rather than made silently: body line 3 read 'Deliberately not `Fixes`: #13214 ...' - a closing keyword immediately adjacent to a card number. GitHub's closing-keyword parser ignores the negation around it, so that spelling risked auto-closing the needs-user-decision card 13214 on merge, which is precisely the outcome the sentence exists to prevent. Rewritten to 'Deliberately a `Part of` link and not a closing keyword - #13214 is a decision awaiting the maintainer ...', keeping the meaning and separating the keyword from the number. The PR remains 'Part of #13214'.",
    "ALSO UPDATED, as the standing contract requires and the out-of-scope list did not name: the Verification section, which quoted final head ba06dabd and 'Test Files 159 / Tests 2650' - both stale after the merge and the edit. It now quotes head 5869d27f23, the post-merge derivation banner, and the real counts. A second ablation subsection was ADDED beside the original one; the original rest-server ablation readings are untouched.",
    "UNTOUCHED as instructed: sections 1 and 2, the other blast-radius bullets, the controls, the original ablation, rest-server.ts (byte-identical to base - the diff is one file, packages/rest/src/ui-view-route-tenancy.measurement.test.ts), and card 13214's needs-user-decision grading.",
    "NO FORCE-PUSH, no draft flip, no label writes. Labels already on the PR (read from the page, data-name spelling): size/l, skip-changeset, tests - skip-changeset is present, so nothing was added.",
    "NOT MEASURED, restated so it is not read as green: node scripts/check-test-completeness.mjs (exit 3, PREREQUISITE NOT MET). Everything else in the derived union ran and is quoted above."
  ]
}

Generated by Claude Code


Generated by Claude Code

@os-trump
os-trump added this pull request to the merge queue Aug 30, 2026
Merged via the queue into main with commit 3d10755 Aug 30, 2026
36 checks passed
@os-trump
os-trump deleted the claude/issue-13214-ui-view-tenancy-exposure branch August 30, 2026 11:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants