Measured, not inferred. Three real os serve boots under a live non-degraded isolated posture (tenancy posture 'isolated' — the Layer 0 organization wall is ACTIVE, degraded=false), driven under #15163. Full reading: objectstack#15163 comment 5537114769.
What an ex-member's API key can do on the wiring the open core actually builds
| arm |
member key |
ex-member key (stamped with org_alpha, membership ended) |
org-less key |
| SUBJECT — single-kernel provider wiring |
GET 200 total=2 · POST 201 |
GET 200 total=2 · POST 201, row lands org: org_alpha |
GET 200 total=0 (silent) · POST 403 |
KM-ONLY — kernel-manager present, kernel unbound |
byte-identical to SUBJECT |
byte-identical to SUBJECT |
byte-identical |
CONTROL — kernel bound |
GET 200 · POST 201 |
401 · 401 |
401 · 401 |
⇒ On the SUBJECT wiring an ex-member reads and writes another organization's rows. Not a silent-empty; a write that lands. The control arm proves the probe can produce a refusal where one exists, and the healthy member key returns data on all three arms, so neither direction is a broken probe.
organization_required splits: reads answer 200 + total 0 — verbatim the silent-empty failure the withdrawn B′ was filed to replace — while writes are refused by Layer 0 (403, ADR-0123 D2).
The mechanism — four facts, each re-verified by the PM on origin/main after the report
tenant-layer.ts — under isolated Layer 0 returns { organization_id: input.organizationId }. A hard equality. It never reads accessible_org_ids; that is the group union branch.
api-key.ts — tenantId is row.active_organization_id copied verbatim. The key's own stored claim, never vetted against membership.
resolve-authz-context.ts — the organization_membership_ended guard runs only if (keyPrincipal?.tenantId && input.tenancyPosture). No posture ⇒ never runs.
api-key.ts — the organization_required guard is likewise if (!tenantId && tenancyPosture).
⇒ The wall is enforced, and enforcing it is exactly what lets the ex-member through: the predicate compares against the caller's own unvetted claim, and the one fact that would expose the ended membership is not an input to the layer that could act on it.
B′'s conclusion was right and its reasoning was wrong, in the direction already measured on #13906: the row wall IS active. And the KM-ONLY arm shows the trigger is kernel being bound in computeExecCtx, not kernel-manager being present — so B′'s boot check tested the wrong predicate for its own premise. ⛔ A boot refusal remains excluded as a remedy (maintainer, 2026-09-04).
⚠️ The scope limit that decides the severity, and it is not yet measured
@objectstack/organizations is cloud-private and resolves from nowhere in this workspace. The probe declared a local stand-in registering org-scoping and nothing else — a behavioural copy of SimulatedOrgScopingPlugin, which this repo already ships for the purpose. So this reading covers the three open-core layers only. It cannot speak for any refusal the real enterprise plugin adds on top.
⇒ Step 1 of any repair is a cloud-side reading: does the real @objectstack/organizations refuse an ex-member's stamped key? If yes, the blast radius is open-core self-hosters. If no, it is every walled deployment. ⛔ Do not size this card before that answer.
The three coupled questions
- Re-open the declined option? The repair is to make the single-kernel branch derive the posture — the option the 2026-09-02 ruling explicitly did not take, to be re-judged on this measurement.
- Does
authRefusal get a consumer in the same change? Measured: ResolvedAuthzContext.authRefusal has zero consumers outside two test assertions. Both refusals surface only by leaving userId unset, so the door answers the generic anonymous 401 — byte-identical to sending no credential. Deriving the posture alone converts a silent leak into a 401 that tells the operator nothing. (authz-store-unavailable.ts already argues this from the other side: it rejected a sibling field because authRefusal is read by no door.)
- Does the admit-on-unknown-posture default survive?
api-key.ts justifies it as protecting "every org-less key on every single deployment whose transport has not been wired". The measurement's sharpest point is against that premise: the transport it calls not-yet-wired is not a residual case — it is every deployment the open core can build.
四棱(业务立场;① 权重 ≥50%)
① 项目长远合理性 —— 明确指向修。 这是 ADR-0131 整份记录的前提被现实证伪:D8 说「一道谓词,算一次」,而这里的谓词拿的是调用方自己填的那一格。归属模型可以做得再干净,只要 tenantId 的来源是未经核验的自述,墙就只是把「你说你是谁」重复了一遍。⛔ 且这不是 v18 才有的问题,它现在就在线上。
② 实际业务拉动 —— 有,且是最坏的那种形态。 不是静默空集,是写入落到别的组织。零具名用户报障,但 API key 的典型用途正是长期存活的自动化——成员离职、key 不撤,是常态而非边角。⚠️ 拉动的大小完全取决于上面那条 cloud 读数。
③ 防 AI 写元数据犯错 —— 中性偏正。 与元数据无关;但问题 2 直接关系到「失败要说人话」:一个和「没带凭据」逐字节相同的 401,会让运维和 agent 都去查错方向。
④ 创业阶段不扩散 —— 支持最小修法。 修法是让已存在的分支多推导一个已存在的值,加一个已存在字段的消费者。⛔ 不新增契约面、不新增姿态、不加启动拒绝。真正的扩散风险在反面:把它留着,就得永远记住「单内核装配上墙是假的」这条只有口口相传的知识。
PM 建议
先取 cloud 读数定性质,然后按 1+2+3 一次修完(分开修会留下「不再泄漏但说不清为什么被拒」的中间态)。17.3 尚未打标(最新仍是 17.2.0),所以这一修是有窗口的——但它是 09-02 被否选项的重新裁定,⛔ 不是我能自行派发的。
Refs: #15163(测量全文)· #13906 / PR #15020(B′ 撤销)· ADR-0105 D2/D3 · ADR-0123 D2 · ADR-0069 · ADR-0131 D8.
Measured, not inferred. Three real
os serveboots under a live non-degradedisolatedposture (tenancy posture 'isolated' — the Layer 0 organization wall is ACTIVE,degraded=false), driven under #15163. Full reading: objectstack#15163 comment 5537114769.What an ex-member's API key can do on the wiring the open core actually builds
org_alpha, membership ended)org: org_alphakernel-managerpresent,kernelunboundkernelbound⇒ On the SUBJECT wiring an ex-member reads and writes another organization's rows. Not a silent-empty; a write that lands. The control arm proves the probe can produce a refusal where one exists, and the healthy member key returns data on all three arms, so neither direction is a broken probe.
organization_requiredsplits: reads answer 200 + total 0 — verbatim the silent-empty failure the withdrawn B′ was filed to replace — while writes are refused by Layer 0 (403, ADR-0123 D2).The mechanism — four facts, each re-verified by the PM on
origin/mainafter the reporttenant-layer.ts— underisolatedLayer 0 returns{ organization_id: input.organizationId }. A hard equality. It never readsaccessible_org_ids; that is thegroupunion branch.api-key.ts—tenantIdisrow.active_organization_idcopied verbatim. The key's own stored claim, never vetted against membership.resolve-authz-context.ts— theorganization_membership_endedguard runs onlyif (keyPrincipal?.tenantId && input.tenancyPosture). No posture ⇒ never runs.api-key.ts— theorganization_requiredguard is likewiseif (!tenantId && tenancyPosture).⇒ The wall is enforced, and enforcing it is exactly what lets the ex-member through: the predicate compares against the caller's own unvetted claim, and the one fact that would expose the ended membership is not an input to the layer that could act on it.
B′'s conclusion was right and its reasoning was wrong, in the direction already measured on #13906: the row wall IS active. And the KM-ONLY arm shows the trigger is
kernelbeing bound incomputeExecCtx, notkernel-managerbeing present — so B′'s boot check tested the wrong predicate for its own premise. ⛔ A boot refusal remains excluded as a remedy (maintainer, 2026-09-04).@objectstack/organizationsis cloud-private and resolves from nowhere in this workspace. The probe declared a local stand-in registeringorg-scopingand nothing else — a behavioural copy ofSimulatedOrgScopingPlugin, which this repo already ships for the purpose. So this reading covers the three open-core layers only. It cannot speak for any refusal the real enterprise plugin adds on top.⇒ Step 1 of any repair is a cloud-side reading: does the real
@objectstack/organizationsrefuse an ex-member's stamped key? If yes, the blast radius is open-core self-hosters. If no, it is every walled deployment. ⛔ Do not size this card before that answer.The three coupled questions
authRefusalget a consumer in the same change? Measured:ResolvedAuthzContext.authRefusalhas zero consumers outside two test assertions. Both refusals surface only by leavinguserIdunset, so the door answers the generic anonymous 401 — byte-identical to sending no credential. Deriving the posture alone converts a silent leak into a 401 that tells the operator nothing. (authz-store-unavailable.tsalready argues this from the other side: it rejected a sibling field becauseauthRefusalis read by no door.)api-key.tsjustifies it as protecting "every org-less key on everysingledeployment whose transport has not been wired". The measurement's sharpest point is against that premise: the transport it calls not-yet-wired is not a residual case — it is every deployment the open core can build.四棱(业务立场;① 权重 ≥50%)
① 项目长远合理性 —— 明确指向修。 这是 ADR-0131 整份记录的前提被现实证伪:D8 说「一道谓词,算一次」,而这里的谓词拿的是调用方自己填的那一格。归属模型可以做得再干净,只要
tenantId的来源是未经核验的自述,墙就只是把「你说你是谁」重复了一遍。⛔ 且这不是 v18 才有的问题,它现在就在线上。② 实际业务拉动 —— 有,且是最坏的那种形态。 不是静默空集,是写入落到别的组织。零具名用户报障,但 API key 的典型用途正是长期存活的自动化——成员离职、key 不撤,是常态而非边角。⚠️ 拉动的大小完全取决于上面那条 cloud 读数。
③ 防 AI 写元数据犯错 —— 中性偏正。 与元数据无关;但问题 2 直接关系到「失败要说人话」:一个和「没带凭据」逐字节相同的 401,会让运维和 agent 都去查错方向。
④ 创业阶段不扩散 —— 支持最小修法。 修法是让已存在的分支多推导一个已存在的值,加一个已存在字段的消费者。⛔ 不新增契约面、不新增姿态、不加启动拒绝。真正的扩散风险在反面:把它留着,就得永远记住「单内核装配上墙是假的」这条只有口口相传的知识。
PM 建议
先取 cloud 读数定性质,然后按 1+2+3 一次修完(分开修会留下「不再泄漏但说不清为什么被拒」的中间态)。17.3 尚未打标(最新仍是 17.2.0),所以这一修是有窗口的——但它是 09-02 被否选项的重新裁定,⛔ 不是我能自行派发的。
Refs: #15163(测量全文)· #13906 / PR #15020(B′ 撤销)· ADR-0105 D2/D3 · ADR-0123 D2 · ADR-0069 · ADR-0131 D8.