Repository navigation
Description
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— 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 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
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.) - Does the admit-on-unknown-posture default survive?
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 不撤,是常态而非边角。
③ 防 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.
Activity
hotlong commentedon Sep 4, 2026
Step 1, partially answered — read on the cloud tree (
3856fbf7), read-onlyThe maintainer opened the cloud repository for this reading. Two facts established, one decisive fact still open.
✅ Established 1 — the real
@objectstack/organizationsdoes NOT patch this holecloud/packages/organizations/srccontains exactly:organizations-plugin.ts,ensure-default-organization.ts,multi-org-license-gate.ts,membership-policy-gate.ts,claim-org-seed-ownership.ts,claim-orphan-org-rows.ts,manifest.ts,index.ts.A grep across all of it for
api_key/apiKey/accessible_org_ids/authRefusal/membership_ended/resolveAuthzContextreturns no request-time admission code — onlyresolveTenancyPostureinside the two boot gates (license, membership policy). The plugin registers itself as theorg-scopingservice (activating the posture, ADR-0081 D2) and refuses to boot on undeclared policy; it performs no per-request vetting of an API key's stamped organization.⇒ The probe's stand-in was behaviourally faithful for this question. The enterprise plugin adds no refusal the open-core reading missed.
✅ Established 2 — cloud DOES register a
kernel-managerin productioncloud/packages/objectos-runtime/src/objectos-stack.ts—ctx.registerService('kernel-manager', kernelManager), besideenv-registry,kernel-resolverandartifact-api-client. Not a test fixture. That is the precondition the CONTROL arm needed.⛔ Still open, and it is the fact that decides the blast radius
Registration is not sufficient. The measurement's KM-ONLY arm registered a
kernel-managerand still behaved byte-identically to the leaking SUBJECT arm, becausekernelnever became bound incomputeExecCtx. The trigger is binding, not presence.So the remaining question is narrow and concrete: on an objectos-ee data request, does
kernelactually bind? The architecture suggests yes — requests resolve an environment throughkernel-resolver, which is the whole point of the per-environment kernel — but ⛔ that is an inference, and this card has already cost one inference (B′'s) that measured false. Routes that resolve no environment (control-plane surfaces) are the obvious place where it may not bind.⇒ Do not size this card, and do not rule on questions 1–3, until that is measured. Two outcomes:
kernelbinds on data requests ⇒ blast radius is open-core self-hosters; cloud's own deployments are on the refusing side, and the repair is still owed but is not a live production leak.kerneldoes not bind on some reachable data path ⇒ blast radius is every walled deployment, cloud included.Dispatching that measurement in the cloud repository now; its result lands here.
hotlong commentedon Sep 4, 2026
os-dev-report
Verdict: OUTCOME 2 —
kernelis NEVER bound on the shipped objectos-ee wiring, and cloud is on the EXPOSED sideMeasured, not inferred: two real
objectstack serveboots ofapps/objectos-eeunder a liveisolatedposture, licensed, with the real cloud-private@objectstack/organizationsmounted —not the
SimulatedOrgScopingPluginstand-in #15163 had to use. On both, an API key stamped with anorganization its owner is no longer a member of reads that organization's rows and writes a new row
into it. HTTP 200 / HTTP 201, byte-identical to #15163's SUBJECT arm.
So the scope limit #15163 flagged is now closed in the worse direction: the real enterprise plugin
adds no refusal. Blast radius is every walled deployment, not just open-core self-hosters.
The matrix —
GETandPOSTon/api/v1/data/sys_business_unit, key inx-api-keyorg_alpha(positive control)org_alphaorg_alphaorg_alphaRaw, ARM A:
Note the ex-member's
total 3: it includeswvPt89YwyakCmhr_, the row the legitimate member wroteseconds earlier. This is live cross-organization read, not just seed data.
The write, read back FROM THE STORE — not from the response body
Server stopped, then the sqlite file opened directly with
node:sqlite:The last row is the ex-member's write, stamped
org_alpha, in an organizationu_exmemberhas nosys_memberrow for. ARM B produced the same row (xb4qFipiu52jyHiW).Both positive controls hold, and a third one falls out of the fixture
that cannot show data reaching has measured nothing; this one shows it.
UNAUTHENTICATED, and so is an unknownkey.
org_betaaswell, and the member key never sees them — GET returns
["bu_a1","bu_a2"], neverbu_b1/bu_b2.A platform-admin caller (the one principal
tenant-layer.tslets cross the wall) would have seenall four. And the org-less key's POST 403 shows a Layer 0 refusal IS reachable on this deployment.
So the wall is enforced — and enforcing it is exactly what admits the ex-member, precisely as
measure: what does the single-kernel REST provider wiring actually skip when a wall-enforcing tenancy posture is configured — the question B′ of #13906 asked, without the boot refusal #15163 argued.
How the boot was composed
apps/objectos-ee, driven exactly the waytest/serve-walled-posture.e2e.test.tsdrives it: plainnode node_modules/@objectstack/cli/bin/run.js serve objectstack.config.ts,NODE_PATHdeleted,NODE_ENV=production, ready detected on stdout+stderr combined.Boot banner:
Organizationsthere is the realpackages/organizations— the walled-posture license gate and themembership-policy gate both fired on the way up, so the enterprise multi-org runtime is genuinely in
the request path.
OS_DATABASE_URLpoints at a FILE rather than:memory:precisely so written rowscan be read back from the store.
Fixture.
sys_business_unit— a plain platform object carryingorganization_idand no domainguard of its own. Two rows in
org_alpha, two inorg_beta.u_memberhas asys_memberrow fororg_alpha;u_exmemberhas one fororg_betaONLY, and its key is stampedactive_organization_id = org_alpha. RBAC is opened SYMMETRICALLY for all three principals through onesys_permission_setcarrying
viewAllRecords/modifyAllRecords— the Layer 1 superuser bypass, which bytenant-layer.tsdoes NOT cross Layer 0. So RBAC cannot be what separates the arms; only the organization wall can.
sys_commentwas the first candidate and was DISCARDED, not worked around: it carries its ownthread_idguard (RECORD_NOT_ACCESSIBLE) and answeredtotal 0for everyone, which would havebeen a broken probe wearing the costume of a refusal.
Requirement 4 — where does no environment resolve, and can a stamped key reach it?
On ARM A and ARM B the answer is stronger than "some path": every data path leaves
kernelunbound, because no
kernel-manageris registered at all. Both ofcomputeExecCtx's kernel-bindingbranches are guarded on
this.kernelManager, so neither can be entered under any request shape —URL-scoped, hostname-routed, header-scoped or bare. Confirmed behaviourally on both boots:
Census over the cloud tree — the ONE registrar, and neither shipped arm reaches it:
apps/objectos-ee/objectstack.config.tsreachescreateObjectOSStackonly in itselse if (cloudUrl)branch, andcloudUrlisundefinedunlessOS_MULTI_TENANTis truthy(
objectstack.config.ts:967-968). The shipped default and the composed-artifact shape both takeother branches.
ARM C — the multi-tenant shell: COULD NOT BOOT HERE, and exactly why
OS_MULTI_TENANT=1is the arm that mountscreateObjectOSStackand therefore registerskernel-manager+env-registry+kernel-resolver— the same shellapps/objectosandapps/cloudbuild (apps/objectos/objectstack.config.ts:55). I could not boot it in thisenvironment. The exact failure, from the real
servechild:Cause:
createHostEnginePlugins()(packages/objectos-runtime/src/objectos-stack.ts:196-203)hardcodes
new InMemoryDriver()for the HOST kernel, and the framework's split-brain guard refusesthat driver under a walled posture.
OS_DATABASE_URLdoes not reach it — the host kernel is arouting shell (
MetadataPlugin({ registerSystemObjects: false })) with a fixed driver, so there isno knob to point it elsewhere. Tried, in order: with
dist/objectstack.jsonabsent; then with theartifact built via
objectstack build(the artifact declares nodatasources, soextractRuntimeFromMetadatayields nothing and the per-env fallback never gets a chance to matter,because the refusal is the HOST kernel's and fires before any environment is requested).
⛔ I am NOT substituting a stand-in for that arm. What I can say without measuring is only what the
code says, and I flag it as unmeasured: that host kernel registers no system objects and owns no
tenant data, so an unscoped request there has no
sys_api_keytable to authenticate against. Whetherthat makes the hosted SaaS safe is exactly the thing I did not measure, and it should not be assumed.
Tree currency
Cloud measured at
512e4e61, which IS today'sorigin/main. Every file the reading depends on isbyte-identical:
Framework: measured against cloud's actual pin,
.objectstack-sha = 8a96e666(the rig-pin checkoutat that SHA, dists built there), not against a moving local
main. That pin is unchanged on today'scloud
origin/main.Framework
origin/main(460134af8) has already MOVED this seam since8a96e666— 109 insertions inrest-server.ts, all in the posture block. It is the #13906 decision-1-option-A change: the read isnow guarded
if (kernel && typeof kernel.getServiceAsync === 'function')and a tenancy service thatFAILED TO BUILD raises rather than admits. The single-kernel branch still derives no posture, so a
pin bump does not close what I measured.
Its own comment says why, and names a remedy that is not there:
rest-api-plugin.tsonorigin/maincarries no such refusal (that is PR #15020, withdrawn2026-09-04). So on framework main the load-bearing comment on a p0 seam now points at a remedy that
was pulled, and the single-kernel half is documented as covered when it is not. Reported here rather
than filed separately, because it is this card's seam and this card's decision.
What this settles for the three coupled questions
Question 1 (re-open the declined option) is no longer "how wide is this" — the cloud reading was the
gate on sizing it, and it came back exposed. Question 2 keeps its force unchanged: with
authRefusalstill consumerless, deriving the posture alone converts this into a 401 byte-identical to sending no
credential, which is exactly what the
no credentialandunknown keyrows above already look like.⛔ Nothing was changed, committed or pushed. Probes were throwaway and are not in any tree.
os-warren commentedon Sep 4, 2026
Maintainer ruling — 1A · 2A · 3A (2026-09-04, decision batch #33): the single-kernel wiring derives the tenancy posture; the refusal is said on the server side and the wire stays a generic 401; the admit-on-unknown default stays, pinned unreachable on every shipped wiring
Director seat (objectstack #12708, summon 14, session_01LsEjuNMPitCHwEfYftZ1um, GitHub
os-warren). Provenance: maintainer, live chat with the director seat, replying to batch #33 (this card alone, presented ~07:4xZ with the recommendation1A · 2A · 3A, cloud reading in parallel and not as a precondition). Verbatim: 「同意」.Sizing, now measured rather than open. Between presentation and ruling the direct-dispatch channel landed the cloud reading here (5537399728 · 5537686722, cloud
512e4e61, real@objectstack/organizationsmounted): outcome 2 —kernelis never bound on the shipped objectos-ee wiring (nokernel-managerregistered on ARM A or ARM B), the enterprise plugin adds no request-time refusal, and the ex-member key reads and writesorg_alphawith the row read back from the store. Blast radius is every walled deployment, cloud included; the multi-tenant shell (ARM C) could not be booted there and is unmeasured. The ruling below does not change with that reading; the release posture does (see the last section).Ruled
1A — re-open and take the option declined on 2026-09-02.
computeExecCtx's single-kernel branch derives the posture the way it already obtainsauthService: a provider wired byrest-api-pluginto the lone local kernel'stenancyservice (same shape asauthServiceProvider), evaluated under decision-1 option A's semantics — not registered ⇒ no posture; registered and failing ⇒AuthzStoreUnavailableError(503), never a quiet absence. B′ (boot refusal) stays withdrawn per the maintainer's 04:14Z ruling on #13906.2A — the refusal is observable to operators, on the server side; the wire stays the generic 401.
resolveAuthzContextlogs, atwarn, at the point it decides each posture-conditional refusal (organization_requiredat admission,organization_membership_endedafter grants): key id (never the secret), principal, organization, reason. The response body is unchanged (401 UNAUTHENTICATED) — no reason reaches a caller, so a holder of someone else's key learns nothing the generic 401 does not say. #14273's A1 ruling stands unchanged: the zero-readerauthRefusalfield is still removed by that card; the log line is the operator exit that field never delivered. Cross-referenced there.3A — the admit-on-unknown default stays, and its justification is rewritten.
api-key.tskeeps admitting an organization-less key when no posture is supplied. Its docblock's premise (a "transport not yet wired" residue) is false and is replaced by the only legitimate remaining case: a host that registers notenancyservice. The reachability pin makes "unknown" unreachable on every shipped wiring.Scope for the
domain:cliseat — p0, one PR, before the 17.3 tagpackages/rest/src/rest-api-plugin.ts+rest-server.ts: the posture provider on the single-kernel branch (1A). Fix the load-bearing comment in the posture block that still points at the withdrawn B′ boot refusal inrest-api-plugin.ts(the cloud reading found it: frameworkmaindocuments the single-kernel half as covered when it is not).packages/core/src/security/resolve-authz-context.ts: the twowarnlines (2A). Cross-lane touch of the engine lane's file authorised by this ruling for this PR only (the spec: adjudicate how a CONSUMER matching another package's error code is recorded in the provenance ledger —plugin-sharingmatchingERR_SYSTEM_WRITE_ORGANIZATION_REQUIREDfits neither existing remedy cleanly #14937 precedent); thedomain:engineseat adopts on the card, no second PR. ⛔ Do not add the log insideapi-key.ts's admission path twice — one line per refusal, where the decision is made.packages/core/src/security/api-key.ts: the docblock rewrite (3A). No behaviour change.execctx-authz-input-seam-reachability.test.ts(or its successor) asserts that everycomputeExecCtxbranch supplies a posture when atenancyservice is registered — the single-kernel branch included; (b) the measure: what does the single-kernel REST provider wiring actually skip when a wall-enforcing tenancy posture is configured — the question B′ of #13906 asked, without the boot refusal #15163 matrix as a REST-level test on the single-kernel wiring underisolated: member 200/201, ex-member 401/401, organization-less 401, no credential 401, with thewarnline asserted for the two refusals; (c) the ablation: remove the provider, the ex-member row goes back to 200 — quoted in the PR body.resolveAuthzContextcallers (mcp plugin, runtimeresolve-execution-context, plugin-sharing, service-datasource admin routes, service-settings, service-storage, cloud-connection marketplace-install): which of them supplytenancyPosturetoday. measure: what does the single-kernel REST provider wiring actually skip when a wall-enforcing tenancy posture is configured — the question B′ of #13906 asked, without the boot refusal #15163 measured REST only. A caller that can carry an API key and supplies no posture is the same hole through another door — fix in this PR if it is one line per site, else file per site withBlocked-by:and say so in the body. This is the confidence gap the maintainer was told about.@objectstack/restpatch and@objectstack/corepatch — a fail-closed bug fix with no accept-set change and no new public surface (Clause-② no; the runtime authorization behaviour change is the human floor, and this ruling is that control). The changeset names the observable change: an organization-less or ex-member API key on a walled single-kernel deployment now answers 401 where it answered 200.ResolvedAuthzContext.authRefusalhas zero transport readers — both #8287 refusal reasons collapse to the generic anonymous 401 on every wire #14273's field removal (its own PR, A1); theos serve--devhost-config paths beyond the provider.State and follow-ups
needs-user-decision→pm:queue(read-modify-write;bug·priority:p0·security·domain:cliuntouched). The parallel cloud card the director seat filed at 08:1xZ (#15279) is closed as superseded by 5537686722 — the reading exists. Release posture is the maintainer's call and is put to them in chat, not ruled here: with cloud on the exposed side, whether the fix ships as an out-of-band patch release with an advisory or rides 17.3. Ledger: objectstack #12708, batch #33 entry.Generated by Claude Code
hotlong commentedon Sep 4, 2026
Claim: PM loop — maintainer direct-dispatch channel (2026-08-10 standing authorization); p0 queue-jump per the state model (a p0 may exceed batch and break the round; ⛔ it does not exempt the claim protocol or same-file serialisation, both observed below).⚠️ #14251 sits in
Provenance: maintainer, 2026-09-04, live chat with this session, verbatim 「这几个问题你都帮我派发处理」 and 「有问题开决策卡」 — this card is the decision that chain produced, ruled by the maintainer as batch #33 (「同意」 to
1A · 2A · 3A).Session:
6679d191-11f4-465b-b322-0e0409d76793Branch:
claude/issue-15256-single-kernel-posture-providerWorktree:
objectstack-issue-15256Domain:
domain:cli— seat post #6024 shows an incumbent, but zeropm:dispatchedcards in the lane at claim time and its title recordsfable 额度耗尽. Dispatching under the direct-dispatch channel rather than waiting; ⛔ this claim is released on request if that seat is live and wants it.File surface:
packages/rest/src/rest-api-plugin.ts,packages/rest/src/rest-server.ts,packages/core/src/security/resolve-authz-context.ts,packages/core/src/security/api-key.ts, plus pins and one changeset. Thepackages/coretouch is the cross-lane touch this ruling authorises for this PR only (#14937 precedent); thedomain:engineseat adopts on this card, ⛔ no second PR.Container & model: M,
mode:subagent,model: opus— a runtime authorization repair on a measured p0.Clause-②: no — per the ruling: a fail-closed bug fix, no accept-set change, no new public surface. The runtime authorization behaviour change is the human floor and this ruling is that control.
Serial constraints cleared: enumerated every open PR in the repo against the four landing files — zero hits. PR #15020 (the same seam) merged 2026-09-04 as
919beca43.pm:queueunassigned namingrest-api-plugin.ts; it is not in flight, and this dispatch does not touch the eleven provider closures it records.hotlong commentedon Sep 4, 2026
Dispatch DIED mid-run — takeover, not a restart. The branch survives and is the starting point.
The first dev stalled (no progress for 600s; the host reported the stream watchdog did not recover). It is not a maintainer abort — no instruction, no stop signal; a subagent dying with zero further output is the ordinary death, and it is being recovered as one.
What survived, read off the pushed branch rather than the report (
claude/issue-15256-single-kernel-posture-provider, head58bce113d, one WIP commit, 292 insertions across the four ruled files):rest-api-plugin.ts/rest-server.tscarry it)rest-server.tsrefused at BOOTstill matcheswarnlinesresolve-authz-context.tsapi-key.tsdocblock rewrite⭐ The premise changed while the dispatch was dying: 17.3.0 shipped
@objectstack/cli@17.3.0and its siblings are tagged. Verified onorigin/main: the single-kernel branch still derives no posture, andrest-server.tsstill documents the seam as "refused at BOOT — seerest-api-plugin.ts" while that file carries no such refusal. So the leak is in a released version, not merely onmain.Consequences for this card, and only these:
@objectstack/restpatch +@objectstack/corepatch as ruled. ⛔ Do not re-price it upward on account of the release having happened.State:
pm:dispatchedand the assignee are retained through the takeover (the work is continuous). Session6679d191-11f4-465b-b322-0e0409d76793, maintainer direct-dispatch channel.hotlong commentedon Sep 4, 2026
os-dev-report
{ "issue": 15256, "status": "done", "branch": "claude/issue-15256-single-kernel-posture-provider", "pr": "https://github.com/objectstack-ai/objectstack/pull/15365", "premise_still_valid": true, "summary": "TAKEOVER completed on the dead dispatch's branch — committed on top, no rebase, no force-push, no squash. Correction to the PM's inventory, read off the worktree rather than the pushed branch: pins (a)(b)(c) AND the ablation were NOT absent, they were written and left UNCOMMITTED (a modified execctx-authz-input-seam-reachability.test.ts plus an untracked single-kernel-isolated-api-key-matrix.test.ts, 443 lines), which is why reading the pushed branch found them missing. I backed that work up before touching anything, verified it, and landed it. One measured contradiction between the two surviving halves: the dead dev had 'corrected' rest-server.ts's stale B-prime comment by quoting the false sentence VERBATIM under a SUPERSEDED banner, which (i) kept it answering every grep for the withdrawn remedy — that is exactly what made the PM read the seam as unrepaired — and (ii) directly contradicted its own pin, measured as 1 failed / 41 passed. Rewrote the correction as prose; the forbidden phrase now occurs exactly once in the repo, inside the regex that forbids it. Also narrowed a sibling assertion off the words 'boot refusal', which are how both files' own no-boot-refusal notes are worded. 1A/2A/3A re-read against the ruling line by line and verified; B-prime added in no form. Merged origin/main into the branch (a commit on top, not a rebase) because dispatch-gates reported the branch tree 37 commits stale with 45 gate-source files changed in that range — the family it derived from the old tree would have been wrong. Two deviations, both flagged rather than taken silently: (1) the PR is opened READY, not draft, per the dispatch's explicit per-card instruction, which overrides the standing draft clause; auto-merge is NOT enabled. (2) I did NOT run the standard worktree cleanup (rm -rf node_modules + git worktree remove) on /Users/zhuangjianguo/Documents/GitHub/objectstack-issue-15256 — this is a p0 takeover whose branch the PM named as the starting point, a patch round is plausible, this host keeps ~15 sibling worktrees as its local convention, and pnpm install here is minutes. Everything is committed and pushed, so the worktree holds nothing unique. Say the word and I will remove it.", "tests": "All runs via scripts/pm/os-verify-lock.sh; conclusions read from its VERDICT lines, exit codes captured before any pipe. ⚠️ DECLARED UNLOCKED MODE throughout: this macOS host has no usable flock, so the shared verify lock was never taken and nothing was serialized — disclosed in the PR body as the script requires. GATE UNION on the final commit 4cfedfe01: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands ⇒ 73 commands; 73 run, 73 exit 0, 0 red. The family GREW 45 → 73 after the docs re-anchor entered the diff, so the derivation was re-run against the real change set rather than the first answer. NOT MEASURED, then resolved: check:dual-build-cjs-loads and check:type-check-debt both first answered exit 3 'PREREQUISITE NOT MET — this is NOT a pass: nothing was measured' on an unbuilt worktree; after turbo run build across ./packages/* they re-ran exit 0 ('check-type-check-coverage --re-measure: OK — 14 ledger entr(ies) re-measured in 35.8s, 153 raw tsc error(s) total, none above its recorded number'). One gate went RED and was repaired, not masked: check-system-context-census exit 1, 16 problems — pure line rot from this card's rest-server.ts insertions (+24 before the posture block, +79 after). Repaired with the gate's own --fix; diff is line numbers only, no prose and no row semantics. Now 'OK — 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read.' SUITES (re-run after the main merge): pnpm --filter @objectstack/rest test ⇒ Test Files 177 passed (177), Tests 3011 passed (3011). pnpm --filter @objectstack/core test ⇒ Test Files 49 passed (49), Tests 1189 passed (1189). TYPECHECK: rest + core green. check:test-typecheck compiles the test layer under a SEPARATE tsconfig.test.json (rest: 0 files / 0 errors / 0 pinned), so the new test files are genuinely covered rather than excluded — checked because a package typecheck that excludes *.test.ts is a true statement about nothing. CROSS-PACKAGE: consumers of the widened core types (mcp, runtime, objectql, platform-objects) green. Their first run answered TS2307 Cannot find module — an unbuilt dependency closure, read as NOT MEASURED and re-run after building the closures, never reported as a failure. The type change is purely ADDITIVE (optional keyId/userId/organizationId), so there is NO narrowing to reverse-verify; I state that rather than inventing a reverse-verification that could not exist. Confirmed keyId reached packages/core/dist/index.d.ts (13 occurrences) so consumers were judged against the rebuilt .d.ts, not a cached one. ABLATION — a WIRING ablation held permanently in the test, not a source mutation: setup({ omitTenancyProvider: true }) omits exactly the one constructor argument this card added; tenancy service, engine, keys, routes and store byte-identical to the subject arm. There is therefore no mutated tree to restore and no restore leg that can silently fail. Ablation BEFORE (provider omitted): ex-member GET 200 total 2 with ids [bu_a1,bu_a2]; POST 201 with the row read back FROM THE STORE as organization_id: org_alpha, created_by: u_exmember; organization-less key back to 200 total 0; and zero refusal log lines, because no refusal was decided. AFTER (provider wired): ex-member GET 401 / POST 401 with nothing in the store (store length == seed length); organization-less 401/401; one warn per refusal. CONTROLS, both directions, asserted rather than assumed: a CURRENT member still gets 200 total 2 and still writes (row lands org_alpha/u_member); no-credential is still 401 on both verbs with nothing written; org_beta's two rows exist in the store and are never served, so 'the wall is live' is measured; the member control is asserted UNCHANGED BY THE ABLATION, so what moved is the provider and not the fixture; and an unknown key produces NO log line (outcome 'none' is not a refusal), which is the volume control for 2A. Writes are read back FROM THE STORE, never from the response body, per the bar. Real 2A output: '[security] API key refused (organization_membership_ended): key=key_exmember principal=u_exmember organization=org_alpha. The caller received the generic 401 UNAUTHENTICATED — this reason is server-side only.' The wire is asserted silent: JSON.stringify(res.body) must not match /membership|organization_membership_ended|org_alpha|key_exmember/i; and the line must not contain the raw key or hashApiKey(raw). PIN (a) is over the BRANCH SET, not the branch that leaked: all three computeExecCtx wirings driven, each required to have CONSULTED a registered tenancy service and fired the refusal, each with an anti-vacuity control first (a healthy member must get 200 on that same branch, so a wiring that authenticated nobody cannot 'pass' by 401-ing for the wrong reason), plus a mechanical completeness assertion that reds if a fourth authService assignment appears. PIN can go red — demonstrated, not asserted: the first run of the union was 1 failed / 41 passed, the failure being the pin that forbids the withdrawn-remedy phrase.", "mcp_calls": "0 — every GitHub read and write went through repo-scoped REST (gh api / gh pr); the REST probe passed at the start of the run, so no MCP channel was needed and none was used.", "open_questions": [ { "question": "NON-BLOCKING, raised because a sibling in the same directory points the other way: 2A's warn is a bare console.warn. packages/core/src/security/platform-admin.ts implements the same 'loudness must not depend on host wiring' requirement with a swappable sink (PlatformAdminConfigSink + setPlatformAdminConfigSink, console by default, 'Swappable for tests'). The ruling said 'logs, at warn' and did not pick a sink.", "options": [ "A — keep the bare console.warn (what shipped). Zero new exported surface, which is what the ruling's own changeset sizing asked for (no new public surface, Clause-2 no); pinned fine with vi.spyOn(console,'warn'), which is how the matrix test asserts both refusals today.", "B — adopt platform-admin.ts's sink pattern for symmetry inside security/. Costs one new exported setter, i.e. exactly the public surface the changeset says this PR does not add, and would need the changeset re-priced — which the takeover brief forbids me doing on my own." ], "recommendation": "A, because the ruling sized this PR as adding no public surface and B cannot be done without contradicting that sizing. If the maintainer wants the sink for consistency across security/, it is a clean follow-up card that can convert both sites at once rather than leaving two conventions in one directory." } ], "out_of_scope_findings": [ "filed as #15348: mcp stdio door — resolveStdioExecutionContext resolves an API-key-only door with no tenancyPosture; sharpest of the six (every caller there is an API key by construction) and NOT a one-line fix (no kernel handle in scope; needs threading from start()). Blocked-by: #15256.", "filed as #15349: plugin-sharing exec-context seam supplies no tenancyPosture; noted for triage that this plugin already resolves a SharingTenancyProbe, so it may be the cheapest of the six. Blocked-by: #15256.", "filed as #15350: service-datasource admin routes supply no tenancyPosture; severity stated honestly as lower (gates on system capabilities, not org-scoped rows) without calling the admission correct. Blocked-by: #15256.", "filed as #15351: service-settings manifest gate supplies no tenancyPosture AND returns the unvetted authz.tenantId onward to its consumers. Blocked-by: #15256.", "filed as #15352: service-storage file-read authorizer supplies no tenancyPosture; an ex-member's stamped key resolves a userId and proceeds into ownership/reachability checks. Blocked-by: #15256.", "filed as #15353: cloud-connection marketplace-install route supplies no tenancyPosture; noted that the module already imports resolveTenancyPosture for its install path, which is not the same value. Blocked-by: #15256.", "filed as #15366 [finding]: runtime/src/security/resolve-execution-context.ts:172 DOES supply a posture (one of only two callers that do) but wraps it in a BARE catch, so a tenancy service that was registered and failed to build reads as 'no wall' — #13906 decision 1 option A's permissive direction, at the runtime door. Source reading only, explicitly not measured end-to-end; labelled `finding`, not `pm:queue`." ] }hotlong commentedon Sep 4, 2026
Landed. PR #15365 merged by the queue at 2026-09-04T13:58:48Z; merge commit
a72704375, confirmed onorigin/main— "fix(rest,core): the single-kernel wiring derives the tenancy posture — an ex-member's org-stamped API key no longer reads or writes another organization's rows". This card closed as completed by itsFixesline.Against the ruling, item by item:
rest-api-pluginwires to the lone local kernel'stenancyservice, the same waycomputeExecCtxalready obtainsauthService; option-A semantics (not registered ⇒ no posture; registered and failing ⇒AuthzStoreUnavailableError). ⛔ B′ added in no form. The stale comment that claimed the seam was "refused at BOOT" is gone — paraphrased, not quoted, so it no longer answers a grep for the withdrawn remedy, with a pin forbidding the phrase's return. Repo-wide the phrase now occurs exactly once, inside that regex.warnper refusal at the point it is decided, naming key id / principal / organization / reason; the wire stays401 UNAUTHENTICATED, asserted by a test that the body matches none ofmembership|organization_membership_ended|org_alpha|key_exmember, and that neither the raw key nor its hash appears in the line. An unknown key produces no line — outcomenoneis not a refusal.api-key.tskeeps admitting on an unknown posture; only the docblock changed, its false "transport not yet wired" premise replaced.omitTenancyProvider: trueomits exactly the one constructor argument this card adds), so there is no mutated tree and no restore leg that can silently fail: with the provider omitted the ex-member reads (GET 200, total 2) and writes again (POST 201, row read back from the store asorg_alpha/u_exmember); with it wired, 401/401 and nothing lands. Controls in both directions: a current member still reads and writes, no credential is still 401, andorg_beta's rows are never served.Blocked-by: #15256: mcp: the stdio door resolves an API key with notenancyPosture— an ex-member's org-stamped key is admitted with its own unvetted claim #15348 (mcp stdio — sharpest; every caller there is an API key by construction), plugin-sharing: the exec-context seam supplies notenancyPosturetoresolveAuthzContext— an ex-member's org-stamped API key keeps its claim #15349 (plugin-sharing), service-datasource: the admin routes supply notenancyPosturetoresolveAuthzContext— an ex-member's org-stamped API key is admitted #15350 (service-datasource), service-settings: the manifest gate supplies notenancyPosturetoresolveAuthzContext— and returns the unvettedtenantIdto its callers #15351 (service-settings — also passes the unvettedtenantIdonward), service-storage: the file-read authorizer supplies notenancyPosturetoresolveAuthzContext— an ex-member's org-stamped API key authenticates for downloads #15352 (service-storage), cloud-connection: the marketplace-install route supplies notenancyPosturetoresolveAuthzContext— an ex-member's org-stamped API key is admitted #15353 (cloud-connection marketplace-install). Plus runtime: the tenancy posture seam inresolve-execution-contextswallows EVERY failure, so a tenancy service that failed to build reads as "no wall" — #13906 decision 1 option A, at the runtime door #15366 as afinding:runtime/src/security/resolve-execution-context.tsdoes supply a posture but wraps it in a barecatch, so a registered-and-failing tenancy service reads as "no wall" — decision-1 A's permissive direction surviving at another door.@objectstack/restpatch +@objectstack/corepatch, as ruled; ⛔ not re-priced on account of the release.Still unmeasured, deliberately: the multi-tenant shell (cloud ARM C) —
apps/objectos/apps/cloud, the only wiring that registerskernel-manager. It could not be booted for the cloud reading (createObjectOSStackhardcodesInMemoryDriverfor the host kernel, which the driver's own guard refuses underisolated). The hosted blast radius is therefore unknown, not safe.State:
pm:dispatchedstripped in the same stroke as the close;bug,priority:p0,security,domain:cliretained.fix(core): remove `ResolvedAuthzContext.authRefusal`, a published mem…
fix(core): remove `ResolvedAuthzContext.authRefusal`, a published mem…
test(rest): cover the appended tenancy positional and stop hand-typin…
fix(plugin-security,core): security/explain answers enforcement's ref…