refactor(core): one shared admission tenancy-posture classification, six seams folded onto it - #17101
Conversation
…s onto one helper
The classification (branded "never registered" stays quiet, every other
rejection becomes AuthzStoreUnavailableError('tenancy', err)) was hand-written
at six admission seams. Any one of them degrading into a silent `catch`
re-opens #13906. It is now `classifyAdmissionTenancyPosture` in
@objectstack/core, with its own two-direction pins.
The RESOLUTION deliberately did not move: rest-server's kernel-vs-provider
branch, each seam's accessor-presence guard, and each seam's own reason why a
missing async accessor stays quiet are per-seam facts a shared owner would have
had to erase or carry a flag for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…mask `pnpm check:comment-mask-adoption` reds on a private regex comment stripper; the shared `scripts/js-comment-mask.mjs` answers that question once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…stripping comments `check:cross-package-test-inputs` reds on the shared comment mask's specifier: importing it makes this test's real inputs wider than its package, which needs a glob widening plus a `vitest.repo-tests.json` entry. The reading does not need comment stripping at all — the module doc is what names the forbidden symbols, and it sits ABOVE the declaration. Slicing from the declaration to end of file needs no stripper, keeps the test inside its package, and the preconditions of the slice (found, starts at the declaration, comment-free) are now themselves asserted instead of assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
📓 Docs Drift CheckThis PR changes 7 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 43 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 9a97af493baef53e63757a2402ba4f501d1be108 && git checkout 9a97af493baef53e63757a2402ba4f501d1be108
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin aa5152d1ad4194293c4550a844da7a83475697d6 bdd62f15f9cb95b161fe5b07fe4b6d60381172c3 && git checkout -B drift-repro aa5152d1ad4194293c4550a844da7a83475697d6 && git merge --no-ff bdd62f15f9cb95b161fe5b07fe4b6d60381172c3
node scripts/docs-audit/affected-docs.mjs --json aa5152d1ad4194293c4550a844da7a83475697d6
|
… and a vanished path is not a crash
`Test Core (1/6)` failed on this PR's head with
`ENOENT ... open 'packages/spec/tsup.config.bundled_8xzodswt4ct.mjs'` raised by
`compliance-families-retirement.test.ts`'s `readFileSync`. The cause is the
walk's POPULATION, not this PR's diff:
- `turbo.json` gives `test:repo` `dependsOn: ["^build"]` — UPSTREAM builds
only, never its own package's — so `@objectstack/spec#build` runs
concurrently with the walk;
- tsup bundles `tsup.config.ts` to `tsup.config.bundled_<random>.mjs` beside
it, loads it and deletes it. `.gitignore` already declares that class
(`*.bundled_*.mjs`), but the walk is a filesystem walk, not a git walk, so
it enumerated the artifact anyway;
- `readdirSync` then `readFileSync` is not atomic, so the file is enumerated,
deleted, and the read raises ENOENT.
Reproduced deterministically on this tree: planting
`packages/spec/tsup.config.bundled_REPROBE.mjs` containing a retired name makes
the leg report it as an offender (exit 1) before this change, and exit 0 after
— proof the artifact really was in the radius, under a filename that changes
every run.
Two halves, and ⛔ neither skips or re-scopes a real source:
- the transient bundle is excluded by name pattern. It is a COPY of
`tsup.config.ts`, which the walk still reads, so this removes no coverage;
- a path that vanishes between enumeration and read is recorded and skipped —
a file that no longer exists cannot be a reference that SURVIVES in the
tree, which is exactly what the pin asserts. ⛔ Every non-ENOENT read fault
is still re-raised.
Both are pinned: a narrowness control (the pattern must not reach
`tsup.config.ts`, `index.mjs` or any authored `.mjs`) and a vanished-path
control with a positive read and an EISDIR leg. Ablating the pattern to
`/\.mjs$/` reddens the narrowness control, so it can fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…ld-admission-tenancy-posture-classification
Contract review at
|
| # | seam | file (head) | H | NR | RF | NA | ST | pinned by |
|---|---|---|---|---|---|---|---|---|
| 1a | REST data door, kernel wiring | packages/rest/src/rest-server.ts:2805-2808 |
posture → posture | undefined → undefined |
AuthzStoreUnavailableError('tenancy') → same |
guard kernel && typeof kernel.getServiceAsync === 'function' unchanged (2805) |
caught (old try) → caught (thunk inside helper try) |
execctx-authz-input-seam-reachability.test.ts:165 (re-aimed source pins :188-199 incl. no-catch narrowness control), :536, :573, :615, :636 |
| 1b | REST data door, provider wiring | rest-server.ts:2809-2828 |
same | same | same | else if (this.tenancyServiceProvider) unchanged (2809) |
same | same file :724 (NR quiet), :734 (RF 503) |
| 2 | Marketplace install-local | packages/cloud-connection/src/marketplace-install-local-plugin.ts:1700-1710 |
same | same | same | guard at 1706 unchanged | same | marketplace-install-local-tenancy-admission.test.ts:338, :351, :363 (RF 503), :391 (NR quiet) — blob identical main↔head |
| 3 | Share-link door | packages/plugins/plugin-sharing/src/sharing-plugin.ts:558-568 |
same | same | same | guard at 564 unchanged | same | share-link-tenancy-posture-admission.test.ts:404, :448, :526 (§5 RF 503), :607 (group) — blob identical |
| 4 | Datasource admin routes | packages/services/service-datasource/src/admin-routes.ts:439-447 |
same | same | same | guard at 443 unchanged | same | __tests__/admin-routes-tenancy-posture-admission.test.ts:509 (NR), :525 (§5b RF), :569 (group), :607 (single) — blob identical |
| 5 | Settings routes | packages/services/service-settings/src/settings-service-plugin.ts:377-387 |
same | same | same | guard at 383 unchanged | same | settings-admission-tenancy-posture.test.ts:511 (NR), :521-534, :596-627 (RF 503) — blob identical |
| 6 | File download door | packages/services/service-storage/src/storage-service-plugin.ts:1078-1085 |
same | same | same | typeof registry.getServiceAsync !== 'function' unchanged (1081) — the degrade-to-ungated reason kept verbatim in its docblock |
same | file-read-tenancy-posture-admission.test.ts:550 (NR), :566-621 (RF), :639 (group), :672 (single) — blob identical |
| helper | classification only | packages/core/src/security/admission-tenancy-posture.ts:108-119 |
delegates to effectiveTenancyPosture |
isServiceNotRegisteredError → undefined |
else throw new AuthzStoreUnavailableError('tenancy', err) |
n/a (§3 pin forbids getServiceAsync/getKernel/PluginContext/getService( in the impl slice) |
thunk invoked inside try (pinned :185-206) |
admission-tenancy-posture.test.ts §1 (real ObjectKernel, both arms with brand-predicate anti-vacuity controls, ADR-0112 code/status/object/cause asserted :131-141), §2, §3 |
Security direction: no seam moves in either direction. Every seam's guard line is byte-identical; the removed per-seam try/catch and the helper body are the same three decisions. ADR-0096 D1 / ADR-0101 D2 fail-closed posture unchanged. Card says four seams, PR folds six: the extra two are the cloud-connection copy triage found and rest-server.ts's second wiring — both in scope per the dispatch order ("FIVE sites, not four", "re-derive by symbol").
Accept set / Clause-② / changeset
- Added to the published
@objectstack/coresurface (security/index.ts:116-117):classifyAdmissionTenancyPosture(function) andTenancyServiceResolver(type). Nothing removed or renamed.@objectstack/speccontracts untouched (only a spec test file changed). Clause-②: yeson the card claim matches: a new export on a published index is the mechanical-flooryes.check-widening-tells.mjs --declaration no --diff→ exit 0, no tell fires (T3 only readspackages/spec/api-surface/*).check-clause2-carriers.mjs --pair 17101→ exit 0, "both carriers agree".- Changeset
.changeset/fold-admission-tenancy-posture-classification.md:@objectstack/core: minor, six consumerspatch. Level correct.check-changeset-no-major.mjs→ exit 0;check-adr-0087-registration.mjs→ exit 0, non-breaking, no marker due. All seven touched packagesprivate: false;content/docs/releases/untouched. No model identifier in PR body/changeset.
Pins
- Helper misclassification fails a test: §1 :91-145 drive both arms at a real
ObjectKernel; a swallowingcatchfails :124-141, a throw-at-everything helper fails :106, and the brand-predicate controls tell the two apart. - No per-seam pin deleted: all five seam test files have identical blobs on main and head; the rest reachability file is re-aimed with the superseded regexes quoted in place and a new no-
catchnarrowness control. No.skip/.only/.todoadded. The dev's ablation (21 cases red across all six seams, both REST wirings) is the dev's reading on803fad5d— consistent with the pin structure but not re-measured by the reviewer.
Docs / body accuracy
Docblocks updated at all six seams and in security/index.ts; the per-seam "why the quiet answer" reasons are preserved. No content/docs page names the folded symbol; #16018 owns the docs half. PR body accurate except F1 (missing file) and F2 (head drift). Fixes #16013 is correct closure.
Discharged
| card acceptance item | status | evidence |
|---|---|---|
| Fold the classification only; resolution stays per seam | discharged | six seams' guards byte-identical; helper takes a thunk, §3 pin forbids accessor/kernel/context symbols in the impl |
Re-derive census by symbol, include the fifth (cloud-connection) copy |
discharged | head git grep shows all five copies + both REST wirings call classifyAdmissionTenancyPosture |
Helper home beside effectiveTenancyPosture in @objectstack/core |
discharged | security/index.ts:116-117, adjacent to :102 |
| Pin the classification itself; per-seam branded/unbranded classify the same | discharged | core §1; five seam suites unchanged and still driving both arms |
| "Missing async accessor stays quiet" preserved per seam with its own reason | discharged | each guard unchanged; docblocks keep each seam's reason |
| Ablation: swallow the classification, suite reddens; name any uncovered seam | discharged (dev-measured on 803fad5d) |
21 red across all six seams; not re-run by the reviewer |
Clause-② yes, carrier on PR at creation, comparison read-back |
discharged | labels + check-clause2-carriers --pair 17101 exit 0 |
| PR body declares the full cross-lane file surface | partial | omits the spec test file (F1) |
| Do not add a sync fallback (#15997 ruling) | discharged | no getService( in helper impl (pinned); no seam gained a sync path |
{"pr":17101,"head":"5a9c4fd55fdf6844438dbce3222af7f0b422af18","verdict":"PASS WITH FINDINGS","blocking":[],"clause2":"yes","semver_ok":true,"governed":false,"ci":"30 success / 3 skipped / 1 in_progress (Lint & Repo Gates) / 0 failure at 2026-09-09T09:2xZ"}维护者速读
本 PR 把六个准入座(REST 数据门的 kernel/provider 两条接线、cloud-connection 安装门、share-link、datasource admin、settings、storage 下载门)各自手写的 tenancy 读取 try/catch 分类折到 @objectstack/core 新导出的 classifyAdmissionTenancyPosture 上,解析(怎么拿到服务、缺异步访问器为何保持安静)逐座原样保留,每座的守卫行逐字节未动。逐输入类比对(healthy 三姿态/degraded、从未注册、注册但失败、无访问器、同步抛错)没有任何准入判定移动,fail-closed 方向不变;新增两个导出已在 changeset(core minor)与卡/PR 双载体 Clause-②: yes 申报,check-clause2-carriers --pair 17101 与 no-major/ADR-0087 门禁均 exit 0。无阻断项;四条非阻断发现里最重的是 F1:最后一个提交顺手修了 packages/spec 的 tsup 临时 bundle 测试抖动,证据在 commit 里但 PR 正文的文件清单没有列它,属卡外范围未申报。head 上 CI 30 绿/3 跳过/1 进行中(Lint & Repo Gates),无红。engine 席下一步:让 dev 要么把该抖动修复拆成独立 PR、要么在 PR 正文补列该文件及复现证据,并追加一行 dev-report 把读数钉到 5a9c4fd5;本 PR 带 protocol:system,采纳后清双载体、卡转 pm:awaiting-maintainer,PR 保持 draft 等维护者亲合。
Generated by Claude Code
|
Addendum — verdict re-pinned to the current head
Findings — unchanged, one re-pinned. F1, F3, F4 exactly as posted. F2 (non-blocking, re-pinned): the Per-seam table and Discharged table: every file:line holds on CI on {"pr":17101,"head":"bdd62f15f9cb95b161fe5b07fe4b6d60381172c3","verdict":"PASS WITH FINDINGS","blocking":[],"clause2":"yes","semver_ok":true,"governed":false,"ci":"12 success / 3 skipped / 16 in_progress / 0 failure at 09:3xZ"}Generated by Claude Code |
Contract review (clause ②) — PASS WITH FINDINGS, all non-blocking · head
|
| # | Change (measured) | Judgment |
|---|---|---|
| 1 | New public export classifyAdmissionTenancyPosture(resolveTenancyService: TenancyServiceResolver): Promise<TenancyPosture | undefined> reaches the published . entry: /home/user/objectstack/packages/core/src/security/index.ts:116-119 → packages/core/src/index.ts:44 (export * from './security/index.js') → package.json exports["."] → dist/index.js. No API baseline/report file exists in the tree to update (grep for api-report|public-api|exports-baseline|\.api\.md → 0; control fired). |
RIGHT — declared in changeset and on both carriers. |
| 2 | New public type TenancyServiceResolver on the same entry. Occurs at exactly 4 lines tree-wide (decl admission-tenancy-posture.ts:89, use :109, index :118, changeset) — no collision. |
RIGHT, but the PR body's Clause-② line says "a new symbol" (singular). Changeset names both. → F1, non-blocking. |
| 3 | No published type narrowed. Core index diff is +11/−0; the six consumer diffs touch only imports, comments, and bodies of private/module-local functions. | RIGHT — no BREAKING banner, no adr-0087: owed. |
| 4 | Helper's accept set (new surface, not a widening of an existing refusal): thunk returning Promise<TenancyPostureSource|undefined|null> | TenancyPostureSource | undefined | null. That is byte-for-byte effectiveTenancyPosture's parameter domain (/home/user/objectstack/packages/core/src/security/api-key.ts:230-232, if (!tenancy) return undefined). Each removed copy passed the resolved value straight into that same function. |
RIGHT — accepts nothing the copies did not. |
| 5 | Helper's refuse set: every throw/rejection with isServiceNotRegisteredError(err) === false → new AuthzStoreUnavailableError('tenancy', err) (admission-tenancy-posture.ts:112-117). Code mint lines AuthzStoreUnavailableError('tenancy', err) (non-test, comment lines excluded): base 11 → head 5 + 1 helper; the 6 removed are exactly the PR's six seams; the 5 untouched are mcp/plugin.ts:96, runtime/domains/activation-gate.ts:220, runtime/domains/keys.ts:144, runtime/security/resolve-execution-context.ts:211. |
RIGHT — identical discriminator, identical brand, identical cause. |
| 6 | Per-seam behaviour, six seams: each keeps its own guard verbatim and hands the exact former expression as the thunk: rest kernel () => kernel.getServiceAsync('tenancy') as any, rest provider () => this.tenancyServiceProvider!(environmentId) as any, four kernel.getServiceAsync!<TenancyPostureSource>('tenancy'), storage registry.getServiceAsync!<TenancyPostureSource>('tenancy'). new AuthzStoreUnavailableError( count at head: 0 in the five copy files; 2 in rest-server.ts (the 'objectql' / 'auth_gate' mints, never this card's — its import is therefore still live). No seam's quiet-undefined became a throw or vice versa; no catch now swallows anything it used to re-raise. |
RIGHT — no admission decision moves anywhere. |
| 7 | Sync-throw parity: thunk is invoked inside the helper's try (effectiveTenancyPosture(await resolveTenancyService())), so a synchronous throw from an accessor classifies exactly as the old try { await kernel.getServiceAsync(...) } did. Pinned §2. |
RIGHT. |
| 8 | Ask-once: helper calls the thunk once, no retry; pinned §2 ("asked EXACTLY ONCE"). Load-bearing for rest's kernel-vs-provider constraint. | RIGHT. |
| 9 | Guard→thunk TOCTOU: the thunk executes synchronously at call time (before the helper's first await), so the ! assertions on getServiceAsync! / tenancyServiceProvider! are backed by the guard that just ran. |
RIGHT. |
| 10 | Binding constraint — "classification, not resolution": helper names no accessor/kernel/context (§3 slices by symbol; anchor export async function classifyAdmissionTenancyPosture occurs once, line 108, and the symbol name is absent from the module doc so indexOf cannot land in a comment); Function.length === 1; 'tenancy' fixed. rest-server.ts keeps if (kernel && typeof kernel.getServiceAsync === 'function') … else if (this.tenancyServiceProvider) untouched; four seams keep ctx.getKernel(); storage keeps its StorageGateRegistry read; each seam's own "quiet on missing accessor" reason remains at the seam. |
HONOURED at all six. |
| 11 | Brand mechanism: isAuthzStoreUnavailableError is a property-brand test (authz-store-unavailable.ts:175-181), not instanceof, so minting inside core rather than at the seam is invisible to every downstream catch net, including the three packages that resolve core through dist/. |
RIGHT. |
| 12 | Rest §0 pins re-aimed (/home/user/objectstack/packages/rest/src/execctx-authz-input-seam-reachability.test.ts): superseded regexes quoted in place; new delegation pins match head source; narrowness control measured on the tree — anchors let tenancyPosture; and const authz = await resolveAuthzContext( each occur exactly once, slice = 1625 chars, catch hits = 0. |
RIGHT. |
| 13 | Core pins (admission-tenancy-posture.test.ts): 10 pins; both rejections driven off a real ObjectKernel, each paired with the brand predicate's own reading (anti-vacuity). One pin, "SCOPED without a scope id", has a tautological arm if (raw === undefined) expect(raw).toBeUndefined(). On this tree plugin-loader.ts:258 throws Scope ID required…, so the meaningful else arm is what runs today. |
RIGHT with one soft pin → F3, non-blocking. |
| 14 | Out-of-card change: /home/user/objectstack/packages/spec/src/system/compliance-families-retirement.test.ts — test-only; excludes /\.bundled_[^./]+\.mjs$/ from a filesystem walk; tolerates ENOENT only (re-raises everything else, EISDIR pinned); visited > 1000 anti-vacuity retained (line 493). No published surface. |
Not a ② concern → F4, non-blocking. |
② Semver — changeset vs diff
| Item | Measured | Judgment |
|---|---|---|
.changeset/fold-admission-tenancy-posture-classification.md |
@objectstack/core: minor; rest, cloud-connection, plugin-sharing, service-datasource, service-settings, service-storage: patch. No major. |
Core: two new exports on a published index ⇒ minor — RIGHT. Six consumers: src/** moved, no public-surface change, not comment/test-only ⇒ patch — RIGHT. |
BREAKING / adr-0087: |
Nothing narrowed, renamed, retired or re-typed (item 3). | Correctly absent. |
@objectstack/spec |
Only a .test.ts moved; absent from changeset. |
RIGHT (test-only). |
Gate, run here with the real PR payload (--event built from GET /pulls/17101, labels + body) |
check-changeset-no-major.mjs --base a27544b4 --head bdd62f15 --event … → exit 0, "declares clause-② yes, and it grades a package whose packages/**/src/** it moves at minor or above — discharged"; declaration line read from the body (backtick prefix accepted); carrier needs:contract-review present. |
Consistent. |
| Dual carrier | scripts/pm/check-clause2-carriers.mjs --pair 17101 → exit 0, "readable in the fixed spelling and both carriers agree". |
Consistent. |
| Fixed group | All seven packages present in .changeset/config.json fixed (1 each), so the group resolves to minor regardless. |
Per-package levels are still the honest reading. |
③ Boundary flags — each claim measured on the tree
| Flag | True on tree? | Leaving it out of this PR correct? |
|---|---|---|
/home/user/objectstack/packages/runtime/src/security/resolve-execution-context.ts holds the identical classification under a different local name |
TRUE — lines 206-214: let tenancyPosture; try { effectiveTenancyPosture(await opts.getService('tenancy')) } catch (err) { if (!isServiceNotRegisteredError(err)) throw new AuthzStoreUnavailableError('tenancy', err); tenancyPosture = undefined; }, feeding resolveAuthzContext at 216. Untouched by the diff (stat empty for packages/runtime). |
Correct for this PR — the claim comment scoped "FIVE sites + rest" and the unblock comment explicitly delegated the four-vs-twelve decision to the implementer to state; it is stated. But the card's own amendment lists this site under "(a) Mint-and-throw — the extractable one", and it is "noted, not filed" → F2. |
/home/user/objectstack/packages/mcp/src/plugin.ts (resolveStdioTenancyPosture) — identical kernel branch + deliberate sync fallback |
TRUE — 92-99 is the identical classification; 100-104 is try { …ctx.getService… } catch { return undefined; } with its own justification at 66-71. Untouched. |
Correct — kernel branch is foldable in the same follow-up; the fallback's bare catch is that seam's documented decision and must not be folded. |
runtime/src/domains/keys.ts:144 and activation-gate.ts:220 mint the brand but must NOT be folded |
TRUE — both catch (err) { throw new AuthzStoreUnavailableError('tenancy', err); } with no re-test; both reach the service via deps.resolveServiceOrLoud(context, 'tenancy', context.environmentId); http-dispatcher.ts:2322-2325 returns undefined for every non-resolved outcome and classifyService (2411-2413) answers {outcome:'never-registered'} on the brand and re-throws everything else, so a branded rejection never reaches those catches. keys.ts:132-137 records the reason verbatim. |
Correct — folding would be a functional no-op that re-adds the discriminator upstream already applied, against the file's documented design; they also do not feed resolveAuthzContext. |
(checked, not in the dev's list) rest/src/rest-api-plugin.ts:335/395 quiet-return sites |
TRUE, untouched. | Correct — the amendment's disposition (b), a different shape. |
Verdict: PASS WITH FINDINGS
All findings non-blocking:
- F1 (non-blocking) — PR body's
Clause-②: yesline says "a new symbol"; the diff exports two (classifyAdmissionTenancyPosture,TenancyServiceResolver). The changeset and the index comment already name both; one word in the body. - F2 (non-blocking) — Two hand-written copies of the exact classification remain (
packages/runtime/src/security/resolve-execution-context.ts:206-214,packages/mcp/src/plugin.ts:92-99) and are "noted, not filed". The card's amendment names both under the extractable family; the card's argument (one tested helper vs N copies that must each stay correct forever) is undelivered at those two seams until a follow-up card exists. File it; do not fold here. - F3 (non-blocking) —
packages/core/src/security/admission-tenancy-posture.test.ts, pin "SCOPED without a scope id": theraw === undefinedarm is tautological. Today theelsearm runs (plugin-loader.ts:258throws), but the pin cannot redden if that changes. Assert the reading instead of branching on it. - F4 (non-blocking) —
packages/spec/src/system/compliance-families-retirement.test.tsis outside the card. It is test-only, declared in the PR body's PATCH ROUND, and correctly absent from the changeset; cleaner as its own PR, not required for ②.
No accept set widened, no admission decision moved at any of the six seams, no published type narrowed, the two new exports are declared on both carriers and levelled minor in the changeset, and the card's binding constraint (classification only, resolution stays per seam) is honoured on the tree at bdd62f15f9cb95b161fe5b07fe4b6d60381172c3.
Seat disposition
ACCEPT. No blocking finding ⇒ landing proceeds. F2 is filed as its own card by this seat (the reviewer's own instruction: "File it; do not fold here."); F1/F3/F4 are recorded and not chased — ⛔ a body re-edit lands a second attribution footer, and F3/F4 are test-hygiene items that do not gate this PR.
Path surface read with get_files, ⛔ not from the report: 12 files, 0 governed-surface hits (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md); control fired against a synthetic governed path. ⇒ ordinary landing route, ⛔ not the governed four-step.
PM dispatch seat · domain:engine · session session_01XTBcV7zZHmokdyQgXjbyEU · R1 · 2026-09-09T10:3xZ
Generated by Claude Code
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 34339196643 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
⛔ Ejected from the merge queue at 10:32Z — standing down, and ⛔ NOT re-queueing. The failure is not this PR's.
What happenedQueue build 34339196643 went red and ejected this PR. The failing check, and why it is not this PR's
Which assertion failed — located by content, ⛔ not inferred from the test name
⛔ Why no re-queue was spent, even though one was availableTwo rules point opposite ways here and the seat acted on the stricter one:
⇒ Conflict, so: treat it as real, do not burn a full queue rebuild on an unknown signature, and leave the one legitimate re-run unspent. ⛔ It is also a new signature — the only open
DispositionFiled as #17121 with the full signature, both hypotheses, and the order in which a round should separate them. ⛔ No ⛔ This seat has not claimed #17121: This PR is parked, not abandoned: ready, green on its own head, mergeable clean, auto-merge deliberately off. It re-enters the queue the moment #17121 is resolved — or immediately, if a second PR hits the same signature and the workflow's own aggregation reclassifies it as a flake. PM dispatch seat · Generated by Claude Code |
Fixes #16013
Clause-②: yes— a new symbol is exported from@objectstack/core's published index, so the mechanical floor fires.needs:contract-reviewis hung on this PR at creation; the review is the director seat's.Six admission seams each hand-wrote the same try/catch on the
tenancyread that feedsresolveAuthzContext. This folds the classification only onto one tested helper. ⛔ The resolution stayed at every seam, deliberately — see "What did NOT move" below.Nothing is broken today. Every copy was correct, so this removes a standing hazard rather than fixing a defect: a quiet
catchat any one of the six re-opens #13906, where a failed posture read is answered as "this check does not apply" and an ex-member's org-stamped API key is admitted.The census, re-derived BY SYMBOL on this tree
Measured on
edf59e3599a3324598ae6cabbc72be322fe0d287(the branch point,origin/mainat 2026-09-09T06:33Z),git grep -n 'resolveAdmissionTenancyPosture'— ⛔ not inherited from the card, whose line numbers had drifted:Five copies plus
rest-server.ts's two branches = six seams. The fifth copy (cloud-connection) is the one the card's own family of four missed and triage found; folding four and leaving it hand-copied would have defeated the card's argument.The helper's home, also re-derived by symbol:
effectiveTenancyPostureis declared atpackages/core/src/security/api-key.ts:230and re-exported frompackages/core/src/security/index.ts:102on this tree. The card said:93, the dispatch measured:101; both had drifted, which is why it was located by symbol.What moved: the classification, and only that
packages/core/src/security/admission-tenancy-posture.tsType annotations elided below so no angle-bracket token reaches this body; the file carries the full typed signature (
resolveTenancyService: TenancyServiceResolver, resolving to the effectiveTenancyPostureor toundefined):It takes a THUNK, and that is a measurement rather than a taste. The REJECTION is the input being classified, so the resolution has to happen inside the helper's own
try. A helper taking an already-resolved service would force every caller to keep acatchof its own — which is exactly the thing this deletes. A thunk that throws synchronously classifies identically, because it is invoked inside thattry; the seams reach the service through accessors that can throw before returning a promise, so that is load-bearing and pinned.'tenancy'is fixed, not a parameter. This function returnseffectiveTenancyPosture's value, so it is the tenancy service or it is nothing; a name parameter would only let a caller mint the outage brand under a name the read did not use. The generalised mint ('objectql','auth_gate',resolve-authz-context.ts's parameterisedobject) is a different extraction and ⛔ not this one.The trap, recorded because the card's own amendment predicted someone would hit it:
rethrowAuthzStoreUnavailablealready exists inpackages/core/src/security/authz-store-unavailable.tsand is not this. It is the mirror half — it re-raises a brand a net already holds. This one runs the other direction: it MINTS the brand from a raw, unbranded registry rejection.What did NOT move, and why it must not
⛔ The resolution. A helper that also owned how the service is reached would be wrong for at least one seam or grow a flag per seam — the copies again, with an extra step.
rest-server.tsbranches on kernel-vs-provider: two wirings, and asking twice would let a provider bound to the LOCAL kernel answer for a request that resolved to another environment. Both branches now call the same classification; the branch itself is untouched.ctx.getKernel()and guard on the async accessor's presence themselves;service-storagereads an already-normalisedStorageGateRegistryslice (finding(service-storage): hosted tenant kernels cannot mount storage's REST routes — registerStorageRoutes needs buildAuthSessionResolver / buildFileReadAuthorizer / findFileHolder, all package-internal; export them so cloud can bridge storage the way it bridges settings #15169).rest-server.tsobjectQLProvidersplits on it — aKernelBase-shaped host has nogetServiceAsync, so the dereference would be an unbranded, therefore LOUD,TypeErrorplugin-sharingKernelBase/LiteKernelhost shape, stated as this seam's own and cross-referenced to its two siblingsservice-datasourceobjectqlservice-settingsresolveTenancyPosture()being the REQUESTED posture (ADR-0093 D4/D5)service-storagebuildFileReadAuthorizeralready returnsundefined(downloads ungated) with noauthservice or engine, so raising aTypeErrorwould take the download surface offline on exactly the hosts whose gate is optional by designcloud-connection⇒ measured: five of the six arguments share the
KernelBase/LiteKernelshape andservice-storage's does not — it rests on a contract the other five do not have. That asymmetry is exactly why the resolution stayed local, and it is stated here rather than assumed.The ablation — the control CAN fail, proved on disk and in
distThe mutation is the degradation this card exists to prevent: the shared classification made to swallow, so every rejection becomes the absent posture.
Proved on disk before anything was read: mint lines in the source 1 -> 0, planted marker 0 -> 1.
Proved in
distbefore any colour was read — load-bearing, becausepackages/rest,packages/plugins/plugin-sharingandpackages/cloud-connectionresolve@objectstack/corethrough itsexportstodist/(onlyservice-datasource,service-settingsandservice-storagealias it tosrc, so half these suites would have stayed green against the un-rebuilt artifact):Every one of the six seams reddened
packages/core/src/security/admission-tenancy-posture.test.tspackages/plugins/plugin-sharing/src/share-link-tenancy-posture-admission.test.tspackages/services/service-datasource/src/__tests__/admin-routes-tenancy-posture-admission.test.tspackages/services/service-settings/src/settings-admission-tenancy-posture.test.tspackages/services/service-storage/src/file-read-tenancy-posture-admission.test.tspackages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.tspackages/rest/src/execctx-authz-input-seam-reachability.test.tspackages/services/service-settings/src/settings-routes.authz-outage-relay.test.tspackages/services/service-storage/src/storage-routes.authz-outage-relay.test.ts21 cases reddened, and they are the right ones — every failure names the loud arm.
restreddened on BOTH of its wirings (tenancy REGISTERED AND FAILING (factory throws) -> 503 outageon the kernel branch,REGISTERED AND FAILING on the provider path is a 503 outageon the provider branch), which is what makes the two-branch fold covered rather than assumed. ⭐ No seam had to be named as uncovered.Accounting for every case that stayed green
129 cases inside the reddened files stayed green, plus 39 in the two controls. Each is accounted for, and none of them is a coverage hole:
catchanswersundefined— which is exactly what the branded "never registered" path answers anyway. So every "tenancyNEVER REGISTERED ⇒ the quiet answer" pin passes under the mutation and is right to: this mutation destroys only the LOUD arm. ⇒ ⛔ a suite holding only the quiet direction would have proved nothing here, which is why the loud direction is what every seam was checked for.tenancycases never enter thecatch— the CONTROL legs, the ex-member and org-less refusals under a live posture, and the effective-vs-requested-posture legs.rest's §0 source-text pins stayed green, correctly — they readrest-server.ts, which the mutation never touched. They pin the delegation and the absence of a localcatch; the classification's colour is pinned in core, and that leg went red.object, which this classification does not mint. They were driven precisely so that a mutation reaching further than the tenancy classification would show up; it did not.Restored and re-proved, ⛔ never with a bare
git checkout --:The mutation script carried a
trapon EXIT INT TERM calling its own restore function with absolute paths throughout, and the restore leg is proved by content (blob hash + empty diff + clean tree), ⛔ never by an exit code — an exit-0 cleanup that did nothing looks identical from the status alone. Two earlier attempts of this ablation are recorded in the report rather than hidden: one never acquired the shared verify lock (exit 99— NOT MEASURED, nothing mutated), and one aborted at the dist preflight before running a single test, which is the preflight doing its job.Verification
Build first, always. A fresh worktree's
distis the classic false red, and this diff's whole point is a cross-package export. Onepnpm build --concurrency=2run covering@objectstack/corewith its dependents, plus each consumer package's own dependency closure, reached exit 0 before a single verdict was read.Package suites — all seven touched packages, green:
@objectstack/core@objectstack/rest@objectstack/plugin-sharing@objectstack/service-datasource@objectstack/service-settings@objectstack/service-storage@objectstack/cloud-connectionTypecheck: six of the seven, exit 0 (⚠️
tsc --noEmitplus each package'scheck:test-typecheck).@objectstack/cloud-connectiondeclares notypecheckscript — it is a measured DEBT entry (13 errors) inscripts/check-type-check-coverage.mjs, so that package is NOT MEASURED for types by the ratchet's own design, not by omission here.check:type-check-coverageandcheck:type-check-debtboth ran and are green, so the entry did not move.Gate families: derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackagainst the merge base, re-derived after the changeset landed (8 families only apply once a changeset exists), and reconciled. Each exit status was captured by redirect-then-$?, ⛔ never across a pipe. 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN, 0 non-zero —node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RAN_LIST_FILEreports✓ 63 derived famil(ies) accounted for.exit 3 PREREQUISITE NOT METon the first sweep —check:dual-build-cjs-loads,check:i18nandcheck:type-check-debtall read built output, and this worktree had only the affected closure built. That is NOT MEASURED and not a pass, so a repo-widepnpm build --concurrency=2(exit 0) ran and all three were re-measured green. ⛔ None of the three was recorded as a pass on its exit-3 run.origin/main(c43bac7048) and says none of the commits this tree can see touched what the answer derives from; it does not warn STALE TREE, so no merge was taken. The merge queue rebuilds the PR as merged onto currentmainand re-runs the required workflows there — that is the race-free re-verification, and it is where the two upstream commits are answered for.check:comment-mask-adoptionflagged a private regex comment-stripper in the new core test. Routing it through the sharedscripts/js-comment-mask.mjsfixed that gate —check:cross-package-test-inputs, because importing that module makes the test's real inputs wider than its package (it wanted a glob widening plus avitest.repo-tests.jsonentry). ⇒ the reading did not need comment stripping at all: the module doc is what names the forbidden symbols, and it sits ABOVE the declaration. §3 now slices the implementation by symbol from its declaration to end of file — no stripper, no escape — and the slice's own preconditions (found, starts at the declaration, comment-free) are asserted rather than assumed.Repo-wide
pnpm lint(eslint . --no-inline-config): exit 0 on the final tree — the whole population, so no narrowing argument is needed.⛔ Not claimed: the dispatch-gates run reports 45 artifact-roster families whose
silentverdict is a fact about a LIST rather than about these paths, 10 declared-wide populations, and 5 families that take a value from the workflow — none of those is cleared by anything above, and CI runs them.The changeset, decided on a measurement
.changeset/fold-admission-tenancy-posture-classification.md—@objectstack/coreat minor, the other six at patch.The level axis is EXISTENTIAL, not universal:
check-changeset-no-major.mjsrequires a PR declaring clause ② to grade at least one package whosepackages/**/src/**it moves at minor or above, and its remedy text says to "raise the one that actually grew".@objectstack/coreis the package that grew — a new exported symbol on a published index, which theWHICH LEVELprose inpr-automation.ymlgrades minor on the act, not on the commit type. The other six received a call-site substitution with no public-surface change, so they are graded on what actually happened to them. ⛔majoris refused during the launch window; nothing here is breaking, so no BREAKING banner and no ADR-0087 disposition is due (nothing authorable is renamed, retired or re-typed).The gate reads content via
git show REV:PATH, so the changeset was committed before it was measured.Full file surface (cross-lane, declared)
The helper's home decides the lane (
core-> engine); the call sites span services and cli. One cross-package PR:One pin file was RE-AIMED rather than deleted
packages/rest/src/execctx-authz-input-seam-reachability.test.ts§0 assertedrest-server.ts's SOURCE TEXT — three regexes matching the two hand-written copies this PR removes. Left alone they would go red; weakened to "something mentions tenancy" they would keep passing precisely because nothing is produced any more. Following that file's own standing instruction, each superseded regex is quoted in place and the pin is re-aimed at what it is actually about: both wirings reach the shared classification, and — the new narrowness control — the seam region betweenlet tenancyPosture;andresolveAuthzContext(contains nocatchat all. A localcatchreappearing there is the exact degradation the card exists to prevent and would be invisible to a delegation-only pin.⛔ Out of scope, deliberately
Acceptance notes — noted, ⛔ not filed
Two more copies of this exact classification exist under DIFFERENT local names, so a symbol-name census cannot see them. Measured on
edf59e3599,git grep -n "AuthzStoreUnavailableError('tenancy', err)"returns 11 lines in 9 files (negative controlAuthzStoreUnavailableErrorZZZ-> exit 1, 0 lines). Six are this PR's. Of the remaining five:packages/runtime/src/security/resolve-execution-context.ts:206-214— the identicallet tenancyPosture; try { … } catch { if (!isServiceNotRegisteredError(err)) throw … }, inline, reaching the service throughopts.getService('tenancy'). Same shape, foldable, out of this card's declared surface.packages/mcp/src/plugin.ts:90-105—resolveStdioTenancyPosture, whose kernel branch is the identical classification, plus a deliberate synchronous fallback whose barecatch { return undefined; }is that seam's own decision. Kernel branch foldable; the fallback is not.packages/runtime/src/domains/keys.ts:144andpackages/runtime/src/domains/activation-gate.ts:220— ⛔ NOT copies and must not be folded onto this helper. They mint the same brand, but the branded/unbranded split already happened one layer earlier inHttpDispatcher.resolveServiceOrLoud, so theircatchdeliberately carries noisServiceNotRegisteredErrorre-test ("a second copy of the classification is a second thing to drift",keys.ts:136). Folding them would re-add the re-test they deliberately dropped.Reported to the PM with this measurement rather than filed blind; nothing here is a defect today.
PATCH ROUND —
Test Core (1/6)was RED, and my local bar could not have caught itThe red, read from the job log and not guessed from the name (run 34327949045, job 102389587699):
The gap in my bar, stated plainly — it is a gap, ⛔ not a flake
packages/specwas not in the seven packages I ran, and itstest:repois not one of the derived gate families. Worse, the same hole covers two packages I did report on: all six ofcore,objectql,rest,runtime,specandtypessplit their suite into two vitest projects, and"test": "vitest run --project local"— sopnpm testnever runs therepoproject at all.test:repois a separate turbo task that CI schedules insideTest Core, invisible to bothpnpm testanddispatch-gates.⇒ the repo-wide suites are exactly
packages/{core,objectql,rest,runtime,spec,types}test:repo(each with its ownvitest.repo-tests.jsoninclude). All six now run here, every round.The cause — the walk's POPULATION, not this PR's diff
turbo.jsongivestest:repodependsOn: ["^build"]— upstream builds only, never its own package's — so@objectstack/spec#buildruns concurrently with the walk. The job log shows exactly that interleaving.tsup.config.tstotsup.config.bundled_RANDOM.mjsbeside it, loads it, and deletes it..gitignorealready declares that class (*.bundled_*.mjs) — but the walk is a filesystem walk, not a git walk, so it enumerated the artifact anyway.readdirSyncthenreadFileSyncis not atomic ⇒ enumerated, deleted,ENOENT, whole leg errors.Reproduced deterministically on this tree — ⛔ not inferred: planting
packages/spec/tsup.config.bundled_REPROBE.mjscontaining a retired name makes the leg report it as an offender —— which proves the artifact really was inside the pin's radius, under a filename that changes every run. (Had
tsup.config.tsever named a retired symbol, the same walk would have reported a phantom offender at a random path. Non-deterministic in both directions.)The fix — two halves, ⛔ neither skips, disables, quarantines nor re-scopes a test
tsup.config.ts, which the walk still reads, so this removes no coverage.catchwould turn an unreadable tree into a silent green).Both are pinned so they can fail: a narrowness control (the pattern must not reach
tsup.config.ts,index.mjs,js-comment-mask.mjsorbundled_thing.mjs) and a vanished-path control with a positive read and an EISDIR leg proving a non-ENOENT fault still throws. Ablation: widening the pattern to match every.mjsreddens the narrowness control (1 failed / 9 passed); restored byte-exactly, verified bygit hash-objectequality.turbo run build test test:repo --filter @objectstack/spec --force— the CI shape, build alongside the walk — is 3/3 tasks successful.Re-verified after the fix, on a tree merged with
origin/mainThe re-derivation warned STALE TREE, so
origin/mainwas merged throughscripts/pm/os-regen-merge.sh(merge committed before any regeneration), the repo rebuilt, andpnpm --filter @objectstack/spec check:generatedreports all 15 generated artifacts up to date — no regeneration was owed.specnow included)test:repo)pnpm lintpackages/specgates are the honest cost of the widened surface, and all twenty ran green.packages/runtime'stest:repowas RED once on the way here withFailed to resolve entry for package "@objectstack/objectql"— that is missingdist, i.e. NOT MEASURED, ⛔ not a red; it is green after the rebuild. Recorded because it is the same class of mistake the shard failure above punished.origin/mainmoved again during this pass (5 more commits). Re-deriving against the newestorigin/mainyields a byte-identical 83-family list, so the residual staleness adds no family this pass did not run; the merge queue's rebuilt generation is where those commits are answered for.File surface added by this round
Generated by Claude Code