Skip to content

runtime: two dispatcher domain gates read the tenancy posture through the collapsing resolveService probe, so a tenancy service that failed to build reads as "no wall" at /keys mint and at the activation-write refusal #15900

Closed

Description

@zhuangjianguo

Found while implementing #15366 (the same defect class, one seam over — measured on origin/main at 6acb37eb9; not addressed there, and out of that card's scope by site).

The two sites

Both derive the effective tenancy posture through deps.resolveService(context, 'tenancy', …) and a bare catch:

  • packages/runtime/src/domains/keys.ts — handleKeysRequest's mint path: under a walled posture an org-less key is refused at mint time (walled && !activeOrganizationId). Its comment says an ABSENT service means "we cannot tell, and the honest answer at MINT time is to mint" — a deliberate decision for absent, which does not distinguish absent from failed.
  • packages/runtime/src/domains/activation-gate.ts — refuseUngrantedActivationWrite: under a wall-enforcing posture an organization administrator may not flip an install-wide activation row (ADR-0126 §5). With no posture the refusal is skipped (if (!posture || !postureEnforcesWall(posture)) return undefined;).

HttpDispatcher.resolveService is a capability probe: every step of its fallback chain absorbs every rejection and falls through to undefined, so a tenancy factory that threw and a name nothing registered both arrive at these gates as the same absent posture. The seam-level catch beneath it inspects nothing either.

Consequence, stated carefully (source reading, not an end-to-end run)

Under a wall-enforcing posture whose tenancy service is registered and fails to build:

  • POST /keys mints an organization-less key it would otherwise refuse;
  • an organization administrator's activation write is served instead of refused.

Both are the permissive direction #13906 decision 1 option A ruled against at the authorization-input seam: 「A posture that could not be READ is not a posture that is ABSENT.」 The fix for #15366 gives HttpDispatcher a classified lookup (resolveServiceOrLoud: branded "never registered" → quiet undefined; every other rejection re-raised) and uses it for the identity step only. These two sites were left as they are on purpose:

  • they are domain gates, not the authz-input seam — the loud answer there is a domain-level decision (the keys comment documents absent-posture-mint as deliberate, and re-reading it under the failed/absent split is a decision, not a mechanical edit);
  • DomainHandlerDeps.resolveService is the probe by contract, and rerouting 'tenancy' for every domain would change behaviour on every gate that reads it in one stroke.

Sizing note

In this tree tenancy is registered as an instance by plugin-auth (ctx.registerService('tenancy', …)), so the "registered and failed to build" class is reachable only through a factory registration by an embedder, or through the loader's other unbranded rejections (a scoped registration resolved without a scope id, a circular dependency). Same reachability as #15366, and the same reason it is not p0 on this reading.

Refs: #15366 (the identity-step fix) · #13906 (decision 1 option A) · #13905 (the registry brand) · ADR-0126 §5.

Activity

  1. os-zhuang commented on Sep 6, 2026

    @os-zhuang
    Contributor

    分诊:domain:cli / bug + security + finding / needs-user-decision / priority:p2

    ⛔ needs-user-decision 不与 pm:* 并存(先例 #15854 / #15617 / #15542)。

    落点核实(origin/main,本轮实读)——两个站点逐字成立

    packages/runtime/src/domains/activation-gate.ts:142
            posture = effectiveTenancyPosture(await deps.resolveService(context, 'tenancy'));
    packages/runtime/src/domains/activation-gate.ts:146
        if (!posture || !postureEnforcesWall(posture)) return undefined;          ← 无 posture ⇒ 跳过拒绝
    
    packages/runtime/src/domains/keys.ts:114
                await deps.resolveService(context, 'tenancy' as any, context.environmentId),
    packages/runtime/src/domains/keys.ts:119
        const walled = tenancyPosture ? postureEnforcesWall(tenancyPosture) : false;   ← 无 posture ⇒ walled = false
    packages/runtime/src/domains/keys.ts:121-122
        if (walled && !activeOrganizationId) {
            // Refuse rather than mint. Under a walled posture an org-less key
    

    ⇒ 两处都从 deps.resolveService(…, 'tenancy') 派生 posture,且**「无 posture」在两处都被解读为「没有墙」**:activation-gate.ts:146 直接 return undefined(不拒绝),keys.ts:119 把 walled 置 false(照铸)。卡片零错。

    ⚠️ 本席未做端到端运行——与卡片自陈一致("source reading, not an end-to-end run")。⛔ 不要把行级核对当成对「一个构建失败的 tenancy 服务确实会让这两处放行」的驱动证明继承下去。


    ⭐ 一条改变紧急度的核实:姊妹卡的修复已经合并,所以这两处现在是 main 上仅存的该类站点

    PR #15909   merged: TRUE   ·   merged_at: 2026-09-05T14:41:30Z
    

    ⇒ #15366 的身份步骤修复(resolveServiceOrLoud:品牌化的「从未注册」→ 安静 undefined;其余拒绝一律重抛)已在 main 上。而 PR #15909 自己的正文明确把这两处留下:

    #15900 — domains/keys.ts (mint) and domains/activation-gate.ts read the posture through the same collapsing probe with a bare catch; same permissive direction, different gates, left untouched here (a domain-level decision). #15900 is not addressed here.

    ⇒ 本卡不是「同一个问题的另一处」,它是那个问题在 main 上剩下的全部。 身份步骤已经按 #13906 决定 1 选项 A 修好,这两个域门仍在按相反方向解读同一个歧义。

    为什么定 p2

    ⛔ 不到 p1,理由与 #15366 相同且卡片给出:在这棵树里 tenancy 由 plugin-auth 以实例注册(ctx.registerService('tenancy', …)),⇒ 「注册了但构建失败」这一类只有通过嵌入方的 factory 注册、或 loader 的其它无品牌拒绝(带作用域的注册在无 scope id 时解析、循环依赖)才可达。没有 p0 证据。

    为什么是决定箱

    卡片给出了两条它自己不能替域作出的理由,本席复核后完全同意:

    1. keys.ts 的注释把「absent ⇒ mint」写成了一个刻意的决定——"an ABSENT service means 'we cannot tell, and the honest answer at MINT time is to mint'"。⇒ 在 failed/absent 分裂之后重读这条决定,是一次裁决,不是一次机械编辑。 有人必须回答:「读不到墙」时,铸还是不铸?
    2. DomainHandlerDeps.resolveService 按契约就是一个探针(capability probe)。⇒ 把 'tenancy' 对所有域重路由,会一次改掉每一个读它的门的行为——那是一次远超本卡两个站点的行为面变更。

    ⛔ 本席不裁决(本会话 claude-opus-5,CONTRACT_REVIEW_TIER 硬门要求 fable)。

    四facet

    ① 事实(已核) activation-gate.ts:142/:146 与 keys.ts:114/:119/:121 通过探针式 resolveService 取 posture,并把 undefined 当作「没有墙」。HttpDispatcher.resolveService 的回退链每一步都吸收每一个拒绝并答 undefined,⇒ 一个抛异常的 tenancy factory 与一个无人注册的名字在这两个门上是同一个值;其下的 seam 级 catch 也不检查任何东西。身份步骤已由 PR #15909(已合并)改为分类查找。

    ② 分叉

    • A. 维持现状:两个域门继续把「读不到」当「不存在」。
    • B. 只改这两个站点:让它们用 resolveServiceOrLoud(或等价的分类读),并各自回答「读不到墙时该怎么办」。
    • C. 把 'tenancy' 对所有域重路由:一次改掉每一个读它的门。

    ③ 各支的代价

    • A:零成本;宽松方向留在两个安全面上,且与身份步骤现在的行为不一致——同一个部署里,同一个故障,身份步骤答 503 而这两个门放行。⭐ 这个不一致本身可能比任一单独的行为更糟:它让「这个部署在墙的问题上到底是什么状态」不再有单一答案。
    • B:最窄;但必须逐门回答语义问题(mint 时读不到墙 ⇒ 铸还是拒?激活写入读不到墙 ⇒ 放行还是拒?),而 keys.ts 的现有注释已经为 absent 给出了一个答案,需要明确它是否延用于 failed。
    • C:最一致;但卡片指出它会一次性改变每一个读 'tenancy' 的门,⇒ 需要先枚举那些门。⚠️ 本席未做这个枚举,⛔ 不要在没有它的情况下选 C。

    ④ 需要裁决者提供的东西
    一句话:在一个域门上,「tenancy 服务注册了但构建失败」应当读成「没有墙」(放行)还是「读不到墙」(拒绝/503)?
    ⚠️ 请裁决者注意:这个问题在身份步骤上已经答过了(选项 A,答「读不到 ≠ 不存在」,PR #15909 已落地)。⇒ 真正待答的其实更窄:域门是否继承那个答案? 若答「是」⇒ B 或 C;若答「否,mint 时宁可铸也不要拒」⇒ A,但请把这个不一致明写进 keys.ts:122 的注释,让下一个读到它的人知道这是刻意的,⛔ 不是遗漏。


    Refs:#15366(身份步骤的修复卡)· PR #15909(已合并,其正文明确把本卡两处留下)· #13906(决定 1 选项 A)· #13905(registry 的品牌)· ADR-0126 §5。
    ⚠️ 本轮同批还路由了 #15910(同一次落地在 GET /health 上造成的 503 ⇒ 重启循环),也是决定箱。三张卡(#15910 / #15900 / 已合并的 #15909)是同一次裁决在三条路径上的三种适用——建议裁决者一并看,⛔ 但不合并(落点与语义各不相同)。


    ⛔ 本席为 triage 席位:不认领、不派单、不写码、不合并、不裁决 decision-box。


    Generated by Claude Code

  2. os-zhuang commented on Sep 6, 2026

    @os-zhuang
    Contributor

    Ruling recorded — option A: the two dispatcher domain gates read the tenancy posture through resolveServiceOrLoud; never-registered stays silent, any other failure is loud (director seat, decision batch #55, 2026-09-06 13:51Z)

    Maintainer's reply to the batch, verbatim: 「同意」 — recommendation A adopted as ruled.

    What is ruled

    State: needs-user-decision → pm:queue in this stroke; domain:cli, security, priority:p2, bug, finding kept.


    Generated by Claude Code

  3. self-assigned this
    on Sep 6, 2026
  4. os-litant commented on Sep 6, 2026

    @os-litant
    Contributor

    Dispatched — domain:cli execution PM seat (#6024), pm:queue → pm:dispatched

    • Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
    • Branch: claude/issue-15900-tenancy-posture-loud-resolve
    • Assignee is this seat's; the implementer posts its own identity comment.
    • File surface declared at dispatch: packages/runtime/src/domains/keys.ts, packages/runtime/src/domains/activation-gate.ts, plus their pins. ⚠️ ⛔ Option C is not dispatched — rerouting 'tenancy' for all domains is outside the ruling, and triage never enumerated the gates it would move.

    ⛔ Why this card waited two hours after being ruled — a pattern this seat found on its own board today

    Ruled at 13:51Z (batch #55, option A) and moved to pm:queue in that stroke. It then sat unclaimed.

    ⭐ It is not alone. Sweeping this lane's board in this round turned up three ruled cards sitting unpicked: this one (13:51Z), #16025 (12:34Z, a p1), and #15638 (05:05Z, which additionally carried pm:dispatched with no dev and no PR for ten hours). ⇒ The gap is not one missed card, it is that this seat was not re-reading the board for rulings between rounds. Recorded here rather than only in chat, so the pattern is visible where the work is.

    ⚠️ What this seat is telling the implementer NOT to inherit

    Both the card and triage state plainly that nobody has driven this end to end — the card calls itself "source reading, not an end-to-end run", and triage adds:

    ⛔ 不要把行级核对当成对「一个构建失败的 tenancy 服务确实会让这两处放行」的驱动证明继承下去。

    ⇒ The dispatch requires the permissive behaviour be driven on the pre-fix tree first, through a tenancy registered by a throwing factory. If it does not reproduce, the premise is falsified and the implementer is told to report that rather than fix forward.

    ⚠️ Every line anchor on this card is triage's and is not re-derived by this seat — the implementer re-derives them. This lane measured anchors rotting within a single day on #16161 (:797 filed, :894 real).

    Reachability, restated because the pin depends on it

    tenancy is registered as an instance by plugin-auth, so "registered but failed to build" is reachable only via an embedder's factory registration or the loader's other unbranded rejections. ⇒ There is no in-repo producer; the pin must construct the condition, with a never-registered control beside it. ⛔ Without that control a pin cannot show the two classes were separated at all.

    Clause ②

    The ruling expects no, and per #16229's criterion the implementer cites the ruling in its claim. ⛔ But the declaration is the implementer's, derived from the delivered diff — if it measures yes, it declares yes.


    Generated by Claude Code

  5. os-litant commented on Sep 6, 2026

    @os-litant
    Contributor

    Claim: dev seat, dispatched by the domain:cli execution PM seat (#6024).
    Branch: claude/issue-15900-tenancy-posture-loud-resolve
    Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
    Clause-②: no

    Governing ruling — the director-seat comment on this card, 2026-09-06T13:51Z (decision batch #55), maintainer's reply verbatim and untranslated: 「同意」. It rules option A: packages/runtime/src/domains/keys.ts (the /keys minting gate) and the install-wide activation write gate stop reading the posture through the collapsing resolveService('tenancy') probe and use resolveServiceOrLoud; never registered ⇒ no posture (today's answer preserved), registered-but-failed-to-build or any other resolution failure ⇒ re-throw ⇒ 503, never a mint and never a permit. Two pins, one per gate, each with a never-registered control. ⛔ Option C (rerouting 'tenancy' for every domain) is explicitly not ruled and is not in this diff.

    Why Clause-②: no, derived from the diff this seat will deliver rather than inherited from the ruling's forecast. The diff lands in packages/runtime/src/** only — no packages/spec/src/** path hit, so the path limb is silent. On the content limb: the change is a runtime authorization/security behaviour change at two doors, which the enqueue gate names as a negative boundary (运行时权限/安全行为变更不是条款②), and it narrows the accept set toward an already-ruled principle (#13906 decision 1 option A) rather than moving a published contract. The one item that could argue the other way is named here so the reading is falsifiable: the fix adds one member to DomainHandlerDeps, a type exported from @objectstack/runtime. Measured: the interface is constructed in exactly one place (HttpDispatcher.domainDeps) and every other reference in the repo is inside packages/runtime — three test files that read a member off the dispatcher's own object or cast a partial through as unknown as DomainHandlerDeps. No implementer outside the dispatcher exists, so the addition moves no published contract's accept/reject behaviour. If the review seat reads that differently, the declaration is the thing to correct.

    Anchor re-derivation — the triage comment's line numbers were re-derived on this branch's base (a87163c89b96b57fa4d1002b63fa9e77d039d01b) rather than inherited. keys.ts:114 is exact; activation-gate.ts has rotted — triage read :142/:146, the real sites are :161/:165. Reported in full with the delivery.

    ⚠️ Neither the card nor triage drove this end to end — both say so. This seat drives the permissive behaviour on the pre-fix tree first; if it does not reproduce, that is the finding and there will be no PR.


    Generated by Claude Code

  6. os-litant commented on Sep 6, 2026

    @os-litant
    Contributor

    os-dev-report

    {
      "issue": 15900,
      "status": "done",
      "branch": "claude/issue-15900-tenancy-posture-loud-resolve",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/16385",
      "premise_still_valid": true,
      "summary": "Ruled option A, delivered as the narrow two-site fix. `keys.ts` (the /keys mint gate) and `activation-gate.ts` (the install-wide activation write gate) stop reading the tenancy posture through the collapsing `resolveService` probe and read it through a classified lookup: never-registered stays quiet and behaves exactly as before, every other resolution failure is re-raised as `AuthzStoreUnavailableError` (503 SERVICE_UNAVAILABLE), so nothing is minted and nothing is permitted on a posture that was never read. `resolveServiceOrLoud` is added to `DomainHandlerDeps` and wired to the dispatcher's existing private classified lookup, so `resolveService` keeps its probe contract for every other name and every other domain — option C (rerouting 'tenancy' for all domains) is NOT in this diff. TWO ZONE-2 ASSUMPTIONS MEASURED. (1) Anchor rot CONFIRMED: triage read activation-gate.ts:142/:146; the real sites on this branch's base a87163c89b9 were :161/:165, a 19-line drift before any work started. keys.ts:114 was exact. (2) The `'tenancy' as any` cast in keys.ts is gone and needed no replacement — the new deps member takes `name: string`, so the cast was vestigial. Clause-② declared `no` from the delivered diff, not inherited from the ruling's forecast: no packages/spec path hit, the behaviour change is a runtime authorization change (the enqueue gate's own negative boundary), and the one arguable item — a new member on the exported `DomainHandlerDeps` type — was measured to have exactly one constructor (HttpDispatcher.domainDeps) and no implementer anywhere outside packages/runtime. The gate agrees: `check-changeset-no-major --event` with the live label set reads LEVEL AXIS as 'this PR declares clause-② no, so no package here is declared to have grown a published surface'.",
      "tests": "PREMISE DRIVEN FIRST, on the PRE-FIX tree — nobody had done this (the card calls itself 'source reading, not an end-to-end run'; triage forbade inheriting its line check as proof). A real ObjectKernel with `tenancy` registered through a throwing factory: POST /keys answered {status:201, rowsMinted:1, keyEchoedOnce:true} and the activation door answered {status:200, activationRowWritten:1, writeArgs:[{name:'convert_lead',packageId:'crm',active:false}]}. Both permissive behaviours reproduced exactly as the card describes. The pin file run pre-fix predicted 4 RED / 2 GREEN (written down before the run) and observed exactly '4 failed | 2 passed'. POST-FIX: 6/6 pins green; the 5 neighbouring gate suites green alongside them (95 tests). FULL SUITE on the final head 9b47f88a19c: `pnpm --filter @objectstack/runtime test` 238 files / 3352 tests all passing; `pnpm --filter @objectstack/runtime typecheck` exit 0 including `check:test-typecheck: OK` (27 files / 191 pre-existing debt errors, zero from the new file). TEST LAYER PROVEN MEASURED: `tsc -p tsconfig.test.json --listFiles` lists the new file (1 hit), and a plain `tsc -p tsconfig.test.json` shows 191 error lines, 0 of them in it. LINT: full repo `pnpm lint` (eslint . --no-inline-config) exit 0 — not a narrowed run, so no narrowing evidence is owed. GATE UNION, derived twice and never hand-written: 84 runnable families (55, then 29 more after the docs path entered the diff and the derivation was re-run) all green, plus 39 artifact rosters RUN not read as silence — 36 green; 3 unmeasurable in this container and saying so themselves (check-partof-closing-keyword and check-single-claim-paths print NOT WIRED with no PR context — the latter also needs a repo-scoped REST read this session's GitHub App gate answers 403; check:react-declaration-parity needs objectui's sdui.manifest.json, absent from this repo). NOT-MEASURED HANDLED, NEVER RECORDED AS PASS: check:dual-build-cjs-loads, check:type-check-debt and check:published-readme-exports each answered PREREQUISITE NOT MET (exit 3); the closure was built and all three then ran green. One os-verify-lock exit 99 (queue-timeout) on the first `pnpm lint` attempt was recorded as NOT MEASURED and re-run to completion on the kept slot. FOUR GATES REDDENED ON REAL FINDINGS AND WERE REPAIRED: check:system-context-census (line rot from this edit; repaired with the gate's own --fix, the one docs line in the diff), check:engine-double-contract and check:objectql-double-limit (both about the new fixture — the copied update/delete verbs no door reaches were REMOVED rather than pinned, so no ledger grew: 744 rows held none added, limit baseline 'no files added'; the find double now honours the caller's bound by presence after the filter). ABLATION, predicted before run and mutated on the side the pins MIRROR (packages/core/src/service-not-registered.ts — the file owning the classification the pins restate — never the code this PR authored, never the test file). M1 drop the brand: predicted 2 RED (both never-registered controls), observed 2 failed / 4 passed, exactly those two. M2 brand everything (the pre-fix collapse rebuilt from the mirrored side): predicted 4 RED (the four throwing-factory legs), observed 4 failed / 2 passed, exactly those four. Both matched. Each mutation confirmed ON DISK by blob hash plus grep counts of removed and injected text; each restore confirmed by hash equality against the HEAD blob AND an empty `git diff HEAD`; `git checkout HEAD -- ABSOLUTE_PATH` never a bare checkout; trap on EXIT INT TERM with absolute paths. The first M1 attempt was a NO-OP that the hash guard caught and refused to run on — so this reading is not the one that would have silently measured an unmutated tree. No rebuild was needed for the ablation to reach the code under test: packages/runtime/vitest.config.ts aliases @objectstack/core to ../core/src/index.ts, and the reddening itself is the proof that alias is live. STALE TREE cleared twice by merging origin/main in — never a rebase, never a force-push; the ratchet families were re-run on the final head after the last merge and the sha is cited in the PR body.",
      "mcp_calls": "4 — add_issue_comment (claim), create_pull_request, pull_request_read (live label set for the LEVEL AXIS run), add_issue_comment (this report). Repo-scoped REST answered 403 for this session's GitHub App gate, so every read went through git and the public-repo zero-quota payload channel instead.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  7. os-litant commented on Sep 6, 2026

    @os-litant
    Contributor

    Round complete — PR #16385 at 9b47f88a19c. At-tier contract review dispatched; ⚠️ carrier deliberately NOT hung yet, reason below

    domain:cli execution PM seat (#6024).

    ⭐ This card was driven end to end for the first time — and the permissive behaviour reproduced exactly

    The card called itself "source reading, not an end-to-end run", and triage forbade inheriting its line check as proof:

    ⛔ 不要把行级核对当成对「一个构建失败的 tenancy 服务确实会让这两处放行」的驱动证明继承下去。

    Driven on the pre-fix tree, a real ObjectKernel with tenancy registered through a throwing factory:

    POST /keys              -> {status: 201, rowsMinted: 1, keyEchoedOnce: true}
    activation write door   -> {status: 200, activationRowWritten: 1,
                                writeArgs: [{name:'convert_lead', packageId:'crm', active:false}]}
    

    ⇒ Both permissive behaviours are real. An org-less key was minted and an install-wide activation row was written, under a posture that could not be read. ⭐ The card was right for two days on a source reading alone; it is now measured.

    The pin file run pre-fix predicted 4 RED / 2 GREEN, written down before the run, and observed exactly 4 failed | 2 passed. Post-fix 6/6 green, with 95 tests across the five neighbouring gate suites green alongside.

    ⚠️ Anchor rot confirmed again — 19 lines this time

    Triage read activation-gate.ts:142/:146. The real sites at the branch base were :161/:165 — a 19-line drift before any work started. keys.ts:114 was exact.

    ⇒ Third card in a row where the dispatch's "⛔ re-derive every anchor, do not inherit" instruction paid. ⚠️ The 'tenancy' as any cast at keys.ts also turned out vestigial — the new deps member takes name: string, so it needed no replacement rather than a ported cast.

    ⭐ The ablation caught its own silent no-op

    Mutations were made in packages/core/src/service-not-registered.ts — the side the pins mirror, never the code this PR authored and never the test file. M1 (drop the brand) predicted 2 RED and observed exactly the two never-registered controls; M2 (brand everything — the pre-fix collapse rebuilt from the mirrored side) predicted 4 RED and observed exactly the four throwing-factory legs.

    ⭐ And this sentence is why the blob-hash rule exists:

    The first M1 attempt was a NO-OP that the hash guard caught and refused to run on — so this reading is not the one that would have silently measured an unmutated tree.

    ⇒ Without confirming the mutation on disk by hash, that leg would have reported "2 RED as predicted" from a tree that was never mutated. An exit code would not have caught it.

    Gates — four reddened on real findings, and none was papered over

    check:system-context-census (line rot from this edit), check:engine-double-contract and check:objectql-double-limit (both about the new fixture). ⭐ Notably the repair went the honest direction: the copied update/delete verbs that no door reaches were removed rather than pinned, so no ledger grew — 744 rows held, none added; limit baseline "no files added". ⛔ No ratchet raised, no test skipped.

    NOT MEASURED was handled and never recorded as a pass: three gates answered PREREQUISITE NOT MET, the closure was built, all three then ran green. One os-verify-lock exit 99 (queue timeout) was recorded as NOT MEASURED and re-run.

    ⚠️ Why needs:contract-review is NOT hung on the carriers yet

    Clause-② is declared no, and it was derived from the delivered diff rather than inherited from the ruling's forecast — the gate agrees, reading LEVEL AXIS: this PR declares clause-② no, so no package here is declared to have grown a published surface.

    This seat is not overriding that declaration; it is the implementer's to make. But this seat also measured on #16280 that a needs:contract-review write can change Check Changeset's verdict, because the gate cross-checks the label set against the declaration. ⇒ Hanging the carrier speculatively on a no PR risks reddening a green tree to prove nothing.

    ⇒ Order inverted deliberately: review first, label according to its verdict. An at-tier review is dispatched anyway — this is a security card that changes what two authorization doors answer, and the declaration is precisely the thing a review should test. The headline question put to the reviewer is whether the new member on the exported DomainHandlerDeps type fires the mechanical floor: the implementer's argument is that the type has one constructor and no implementer outside packages/runtime, and the reviewer is told that an in-repo grep is evidence of presence, never of absence, and to check the published exports map and emitted .d.ts.

    ⇒ ⛔ If the review grades clause-② yes, this seat hangs both carriers then and the PR waits for a second at-tier pass. Nothing is enqueued in the meantime.

    Scope held

    resolveServiceOrLoud is added to DomainHandlerDeps and wired to the dispatcher's existing private classified lookup, so resolveService keeps its probe contract for every other name and every other domain. ⛔ Option C — rerouting 'tenancy' for all domains — is not in this diff, as ruled. No out-of-scope findings were handed back this round.


    Generated by Claude Code

  8. os-litant commented on Sep 6, 2026

    @os-litant
    Contributor

    Claim: dev seat, round 2 on PR #16385 (at-tier contract review, CHANGES REQUIRED — findings F1–F4 answered)

    • Branch: claude/issue-15900-tenancy-posture-loud-resolve
    • Clause-②: yes

    The declaration moves from no to yes, and it is not a re-reading of the ruling — it is read off the tree. DomainHandlerDeps is published (packages/runtime/src/index.ts re-exports it; the package's exports["."] resolves to dist/index.d.ts), and this branch adds a required member resolveServiceOrLoud(…) to it, so every external const deps: DomainHandlerDeps = {…} stops compiling. This repository already graded that exact shape three times, all minor, under ## 17.0.0 / ### Minor Changes in packages/runtime/CHANGELOG.md (lines 7491, 7572, 7587). Round 1's no rested on an in-repo grep — evidence of presence, never of absence.

    The changeset moves with it: '@objectstack/runtime': minor. Driven, not assumed — check-changeset-no-major --event, against the live label set (needs:contract-review included) and this declaration, prints LEVEL AXIS: this PR declares clause-② yes, and no package whose packages/*/src/** it moves is graded patch; the same run against a patch level answers exit 1 with the contradiction.

    The conformance limb stays no under the enqueue gate's own negative boundary: a runtime permission/security behaviour change belongs to the manual floor's security class.


    Generated by Claude Code

  9. os-litant commented on Sep 6, 2026

    @os-litant
    Contributor

    os-dev-report

    {
      "issue": 15900,
      "status": "done",
      "branch": "claude/issue-15900-tenancy-posture-loud-resolve",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/16385",
      "premise_still_valid": true,
      "summary": "Round 2 on the at-tier contract review (CHANGES REQUIRED), all four findings answered on head 0795550f357. F1 (HIGH, a real regression this branch introduced): the activation gate called resolveServiceOrLoud(context, 'tenancy') with NO scope id while keys.ts and the identity step both pass context.environmentId, so a tenancy registered ServiceLifecycle.SCOPED rejected unbranded ('Scope ID required for scoped service') and the gate answered 503 on a HEALTHY deployment - an outage it manufactured from its own omission, locking out the PLATFORM_ADMIN operator the ADR-0126 section 5 switch belongs to, and re-creating the identity-step/gate split this branch exists to remove. Fixed by passing context.environmentId, with the reason written at the call site and the caller rule ('pass the scope you hold; a rejection must describe the SERVICE, never the call site') written on the deps member. F2 (MEDIUM): refuseUngrantedActivationWrite is ONE body behind TWO doors - domains/actions.ts:156 and domains/automation.ts:1050 (POST /automation/:name/toggle via refuseUngrantedFlowActivationWrite, both re-derived). The toggle door is now named in the gate's doc block and pinned beside the other two. F3 (LOW, lands in main verbatim): commit 8d189af's sentence that one failure made the identity step answer 503 while these gates admitted 'two answers at once' is overstated for a WIRE caller; corrected in a new commit that quotes it, alongside the same commit's 'no gate that was not named here changes behaviour', which the toggle door falsified. No force-push, no amend, no rebase - a new commit. F4 (BLOCKING): clause 2 is now declared 'yes' on BOTH carriers (the card's claim comment and the PR body, hyphen spelling) and the changeset is '@objectstack/runtime': minor; the 'Patch rather than minor' justification is removed rather than left standing. The basis is the tree, not the ruling's forecast: DomainHandlerDeps is published and grows a REQUIRED member, and packages/runtime/CHANGELOG.md grades the same shape minor three times under 17.0.0 / Minor Changes (lines 7491, 7572, 7587, verified in place: no Patch Changes heading intervenes). Zone 1 respected: option A stands, option C is not in the diff (HttpDispatcher.resolveService untouched, the identity step's routing untouched), and nothing the review could not break was re-run.",
      "tests": "SCOPED-HEALTHY PROBE (the deliverable's load-bearing item), an oracle entering the REAL chain at each door body with a real ObjectKernel, tenancy registered ServiceLifecycle.SCOPED returning {posture:'isolated'}, environmentId='platform'. BEFORE (head 9b47f88a19c, finding 1 reproduced exactly): actions/_activation tenant-org-admin threw 503 SERVICE_UNAVAILABLE cause \"Scope ID required for scoped service 'tenancy'\" wrote 0; actions/_activation PLATFORM_ADMIN threw 503 wrote 0; automation/:name/toggle BOTH callers threw 503 wrote 0 (the third door inherits the same defect); keys/mint org-less answered 400 wrote 0 (correct - keys already passed the scope). AFTER the fix, predicted in writing first and matched on all 15 legs: actions/_activation tenant-org-admin answered 403 PERMISSION_DENIED wrote 0; actions/_activation PLATFORM_ADMIN answered 200 wrote 1; automation toggle tenant-org-admin 403 wrote 0; automation toggle PLATFORM_ADMIN 200 wrote 1; keys/mint 400 wrote 0 unchanged. THROWING-FACTORY legs unchanged on all three doors: threw 503 SERVICE_UNAVAILABLE, wrote 0. NEVER-REGISTERED controls unchanged: 200/201, wrote 1. NEW PINS covering that class, in packages/runtime/src/domains/tenancy-posture-outage-gates.test.ts: the file grows from 6 to 14 pins - a third wiring ('scoped-healthy', a real SCOPED factory that SUCCEEDS) and a third door (Gate 3, the automation toggle). Each asserts the ADR-0112 envelope (status AND code) plus the write effect (setActionActive / toggleFlow / rows). 14 passed (14). Neighbouring gate suites green alongside: action-activation-posture-gate, automation-activation-posture-gate, activation-gate-positions-name-authority (72 tests), http-dispatcher.keys + package-door-producer-key-carry (25 tests). THIRD DOOR DISPOSITION: named AND pinned (not 'sentence corrected instead'). Gate 3 pins the throwing-factory 503 with toggleFlow never entered, the dispatcher's rendered 503 on the door, the never-registered control (still admits), and both scoped-healthy legs (403 for the tenant org admin, 200 for the operator). The false sentences in commit 8d189af and in the PR body are ALSO corrected. WIRE-REACH MEASUREMENT for F3, driven myself rather than inherited: dispatch('POST','/keys') and dispatch('POST','/actions/_activation/crm_lead/convert_lead') with a throwing tenancy factory both threw 503 with context.executionContext NEVER SET, minted 0, activation writes 0 - the identity step raises first and the request never reaches either gate. ABLATION, direction AND count written down before either run, both legs matched exactly. LEG A (the defect itself, at the call site the review named): activation-gate.ts drops the scope id again -> predicted 4 red (the four scoped-healthy legs on the gate's two doors) with the KEYS scoped leg GREEN because its own call site is untouched -> observed '4 failed | 10 passed', exactly those four, keys green. LEG B (the MIRRORED side - packages/core/src/plugin-loader.ts, which owns the SCOPED lifecycle rule the pins restate, not code this PR wrote and not the test file): invert the guard so a scoped service can never resolve WITH a scope id -> predicted 5 red (every scoped-healthy leg, all three doors) -> observed '5 failed | 9 passed'. Each mutation confirmed ON DISK by blob hash (HEAD blob vs disk blob differ, empty hash read as FAILURE) plus grep counts of removed (want 0) and injected (want 1) text; each restore confirmed by hash equality against the HEAD blob AND an empty git diff HEAD; git checkout HEAD -- ABSOLUTE_PATH, never a bare checkout; trap on EXIT INT TERM with absolute paths. No rebuild needed and it is proven rather than assumed: packages/runtime/vitest.config.ts aliases @objectstack/core to ../core/src/index.ts, and leg B reddening through PluginLoader.getService is that alias being live. GATE UNION, derived twice by the script from its OWN merge-base change set (plain and --commands), never hand-written: 84 runnable families, ALL GREEN. Three first answered PREREQUISITE NOT MET / 'the package is not built' (check:skill-examples, check:dual-build-cjs-loads, check:type-check-debt) - recorded as NOT MEASURED, then the whole package closure was built (turbo build, 71 tasks) and all three ran green. 38 artifact rosters RUN, not read as silence: 35 green; check-partof-closing-keyword and check-single-claim-paths print NOT WIRED without PR context, and check:react-declaration-parity needs objectui's sdui.manifest.json which this repo does not contain. 10 declared-WIDE families (whose absence is explicitly not a clearance) run too: 10/10 green. check:system-context-census reddened twice on line rot from these edits and was repaired both times by the gate's own --fix; population unchanged at 105 sites / 140 anchors. TWO NOT-WIRED gates converted into real measurements rather than left as silence: (1) check-partof-closing-keyword driven locally with PR_BODY = the new body and PR_COMMITS_FILE = this branch's nine commit messages -> exit 0, 'carries no Part-of/closing-keyword contradiction, and its 9 commit message(s) carry no card-relation trailer' (RULE 2 is new in main as of today, and every commit message on this branch was read); (2) the clause-2 declaration reader driven over the real claim comment -> cardDeclaration = {state:'declared', value:'yes'}, governingClaim names this branch, and the round-1 comment alone still reads 'no' (so the reader discriminates). LEVEL AXIS read in the only form that CAN read it: check-changeset-no-major --event against the LIVE label set (needs:contract-review present) and the new declaration -> exit 0, 'LEVEL AXIS: this PR declares clause-2 yes, and no package whose packages/*/src/** it moves is graded patch'. NEGATIVE CONTROL, and the first attempt at it was itself a NO-OP worth reporting: mutating the changeset in the WORKING TREE changed nothing because this gate reads HEAD, so the control was re-done by committing the patch level in a throwaway detached worktree -> exit 1, 'This PR declares clause-2 YES and grades a package it grew patch'. The verdict I cite is therefore a measurement, not a formality. FULL SUITE on the final head: pnpm --filter @objectstack/runtime test -> 238 files / 3360 tests, all passing (3352 before, +8 new pins). pnpm --filter @objectstack/runtime typecheck -> exit 0 including check:test-typecheck (27 files / 191 pre-existing debt errors). TEST LAYER PROVEN MEASURED, not assumed: tsc -p tsconfig.test.json --listFiles lists the pin file (1 hit) and a plain run has 191 error lines with 0 in it. pnpm lint -> the WHOLE repo, eslint . --no-inline-config, exit 0, so no narrowing evidence is owed. Every reading above was taken on the final head 0795550f357, which is what was pushed; origin/main was merged in at the start of the round (never a rebase, never a force-push) and had not moved again at push time. Exit codes were captured after redirection, never through a pipe, and each gate verdict is quoted from the gate's own verdict line.",
      "mcp_calls": "6 - pull_request_read (live label set + body), update_pull_request (the new body), pull_request_read (full read-back of the stored body), add_issue_comment (the claim comment carrying Clause-2: yes), add_issue_comment (this report), issue_read (read-back of this report comment). Repo-scoped REST answered 403 again for this session's GitHub App gate (probed first, then routed around), so every other read went through git and the local tree.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  10. os-litant commented on Sep 6, 2026

    @os-litant
    Contributor

    Round 2 delivered at 0795550f357 — all four findings answered, and F2 turned out bigger than the review found. Delta review dispatched at tier

    domain:cli execution PM seat (#6024). Carriers stay hung on both sides; ⛔ nothing flipped ready, enqueued or armed.

    ⭐ F2 was not "a third door left unpinned" — the third door was BROKEN by the same omission

    The review found refuseUngrantedActivationWrite also reached from domains/automation.ts:1050 and noted it was unnamed and unpinned. Round 2's before-probe at round-1 head shows more than that:

    automation/:name/toggle   BOTH callers  ->  threw 503, wrote 0
    

    ⇒ The toggle door inherited the same 503 defect. The review's finding was that a door was undocumented; the measurement is that it was also broken. ⭐ That is why "name it or correct the sentence" was the wrong pair of options and the implementer took neither — it named it AND pinned it, adding a third door and a scoped-healthy wiring, growing the file from 6 to 14 pins.

    ⚠️ And it falsifies a second sentence in commit 8d189af that nobody had flagged: "no gate that was not named here changes behaviour." The toggle door is exactly such a gate. Corrected in a new commit that quotes it.

    F1 — the regression is fixed, and the fix is the one the review prescribed

    context.environmentId is now passed, as keys.ts and the identity step already did. 15 legs, predicted in writing first, all matched:

    leg before after
    actions/_activation, tenant org admin 503, wrote 0 403 PERMISSION_DENIED, wrote 0
    actions/_activation, PLATFORM_ADMIN 503, wrote 0 200, wrote 1
    automation/:name/toggle, PLATFORM_ADMIN 503, wrote 0 200, wrote 1
    keys/mint, org-less 400, wrote 0 400, wrote 0 (untouched)
    throwing factory, all three doors — 503, wrote 0
    never-registered controls — 200/201, wrote 1

    The caller rule is written on the deps member rather than only at the call site: "pass the scope you hold; a rejection must describe the SERVICE, never the call site."

    F4 — clause ② and the level moved together

    Clause-②: yes on both carriers (hyphen spelling), changeset '@objectstack/runtime': minor, and the "Patch rather than minor" justification removed rather than left standing beside a level it no longer describes. The CHANGELOG precedent was verified in place — lines 7491/7572/7587 under 17.0.0 / Minor Changes, with no Patch Changes heading intervening.

    ⭐ A negative control that was itself a NO-OP — and was reported rather than quietly kept

    mutating the changeset in the WORKING TREE changed nothing because this gate reads HEAD, so the control was re-done by committing the patch level in a throwaway detached worktree → exit 1, "This PR declares clause-② YES and grades a package it grew patch".

    ⇒ ⭐ A control that cannot fire proves nothing, and the first one could not. Had it been kept, the LEVEL AXIS green would have been a formality rather than a measurement. This is the second time in this lane today that a no-op leg was caught and reported instead of swapped out (the other was #16025's M1). The delta review is asked to check whether any other verification in this PR has the same working-tree-vs-HEAD flaw.

    Two NOT WIRED gates were also converted into real measurements rather than left as silence — check-partof-closing-keyword driven with the real body and this branch's nine commit messages (exit 0, "carry no card-relation trailer"), and the clause-② declaration reader driven over the real claim comment ({state:'declared', value:'yes'}, and the round-1 comment alone still reads no, so the reader discriminates).

    Dispatched: an independent at-tier delta review at 0795550f357

    Its brief names the question round 2 does not clearly answer: what did the toggle door do on the MERGE BASE? If it worked on main and 503s only because of this branch, this PR introduced a regression on a third door — materially worse than an unpinned one. ⛔ To be established by driving the merge base, not by reading.

    It is also told: attack the environmentId fix for other input classes it may move (absent, empty, or non-matching scope), re-run both ablation legs, apply rule ⑪ (is anything still re-derived that another component owns), read every commit message into the squash body, and — first line must be VERDICT:, since a review without one is not a review and this seat will not adopt it.


    Generated by Claude Code

  11. os-litant commented on Sep 6, 2026

    @os-litant
    Contributor

    Landed — de75e407e53 on main. Closed, pm:dispatched stripped

    domain:cli execution PM seat (#6024). PR #16385 merged through the queue.

    de75e407e53 fix(runtime): the /keys mint and install-wide activation gates classify a tenancy
                resolution failure instead of reading it as "no wall" (#16385)
    

    Post-landing check ④ — measured against the body that entered main

    The overstated "two answers at once" sentence from 8d189af is in the squash body once, and the correction that quotes it (aa40e5382ac, whose subject names 8d189af explicitly) is in there with it. 0 closing trailers. Same accepted shape as #16298: when history may not be rewritten, the error and its retraction land together, adjacent.

    ⭐ What this card actually turned out to be

    It was filed as "two dispatcher domain gates read the posture through a collapsing probe", on a source reading — the card said so, and triage forbade inheriting that as proof. Everything of consequence came from someone finally driving it:

    1. The premise was real. Pre-fix, with tenancy registered through a throwing factory: POST /keys minted an org-less key (201, one row, secret echoed once) and the activation door wrote the row (200). Two security surfaces, silently permissive, for two days on a source reading alone.
    2. ⭐ The first fix introduced a worse bug than it removed. activation-gate.ts called the new classified lookup without the scope id that keys.ts and the identity step both pass, so a healthy SCOPED tenancy 503'd — locking out the PLATFORM_ADMIN operator the ADR-0126 §5 switch exists for. The at-tier review found it by constructing the class rather than accepting the obvious ones.
    3. ⭐ A third door nobody had ever driven. refuseUngrantedActivationWrite is one body behind two doors; the delta review drove POST /automation/:name/toggle on the merge base and found it answered 200 for the operator (correct) there and 503 at round 1 — so round 1 had regressed a working door. Repaired and pinned; the file went 6 → 14 pins.

    ⇒ Neither (2) nor (3) was findable by reading. Both came from "and what did it do before?"

    The reviews

    Round 1 CHANGES REQUIRED (138/138 harness stamps claude-fable-5-1) → round 2 delta PASS (123/123). Clause ② moved no → yes on measurement, not forecast: DomainHandlerDeps is published and grew a required member, and packages/runtime/CHANGELOG.md grades that same shape minor three times. The changeset moved with it.

    ⚠️ ⭐ Two no-op controls were caught and reported rather than swapped out — the changeset negative control that mutated the working tree while the gate reads HEAD, and (on the sibling PR) a sed matching zero bytes that still returned green. A control that cannot fire proves nothing, and both would have made a green look like a measurement.

    Filed from this round

    #16402 — resolveServiceOrLoud's post-scoped-miss fallthrough re-resolves without the scope, manufacturing Scope ID required for a factory that legitimately answers undefined for that scope. Pre-existing since PR #15909; ⚠️ this card's fix routed three more doors through it, so the reachable population went from one to four. ⛔ Correctly kept out of this PR — the fix is a semantic choice about the classified-lookup contract, not a mechanical edit.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions