Skip to content

[decision · p0] an ex-member API key reads AND writes another organization's rows on the single-kernel wiring under isolated — the wall compares against the caller's own unvetted claim #15256

Closed

Description

@hotlong

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

  1. 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.
  2. api-key.ts — tenantId is row.active_organization_id copied verbatim. The key's own stored claim, never vetted against membership.
  3. resolve-authz-context.ts — the organization_membership_ended guard runs only if (keyPrincipal?.tenantId && input.tenancyPosture). No posture ⇒ never runs.
  4. 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

  1. 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.
  2. 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.)
  3. 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.

Activity

  1. added
    bugSomething isn't working
    priority:p0Queue-jump: outranks batch and breaks the round — never exempts claiming or same-file serial
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    on Sep 4, 2026
  2. hotlong commented on Sep 4, 2026

    @hotlong
    ContributorAuthor

    Step 1, partially answered — read on the cloud tree (3856fbf7), read-only

    The maintainer opened the cloud repository for this reading. Two facts established, one decisive fact still open.

    ✅ Established 1 — the real @objectstack/organizations does NOT patch this hole

    cloud/packages/organizations/src contains 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 / resolveAuthzContext returns no request-time admission code — only resolveTenancyPosture inside the two boot gates (license, membership policy). The plugin registers itself as the org-scoping service (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-manager in production

    cloud/packages/objectos-runtime/src/objectos-stack.ts — ctx.registerService('kernel-manager', kernelManager), beside env-registry, kernel-resolver and artifact-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-manager and still behaved byte-identically to the leaking SUBJECT arm, because kernel never became bound in computeExecCtx. The trigger is binding, not presence.

    So the remaining question is narrow and concrete: on an objectos-ee data request, does kernel actually bind? The architecture suggests yes — requests resolve an environment through kernel-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:

    • kernel binds 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.
    • kernel does 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.

  3. hotlong commented on Sep 4, 2026

    @hotlong
    ContributorAuthor

    os-dev-report

    Verdict: OUTCOME 2 — kernel is NEVER bound on the shipped objectos-ee wiring, and cloud is on the EXPOSED side

    Measured, not inferred: two real objectstack serve boots of apps/objectos-ee under a live
    isolated posture, licensed, with the real cloud-private @objectstack/organizations mounted —
    not the SimulatedOrgScopingPlugin stand-in #15163 had to use. On both, an API key stamped with an
    organization 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 — GET and POST on /api/v1/data/sys_business_unit, key in x-api-key

    credential ARM A · shipped single-environment ARM B · composed artifact
    no credential GET 401 · POST 401 GET 401
    unknown key GET 401 —
    member of org_alpha (positive control) GET 200 total 2 · POST 201 GET 200 total 2 · POST 201
    ex-member, key stamped org_alpha GET 200 total 3 · POST 201, row lands org_alpha GET 200 total 3 · POST 201, row lands org_alpha
    org-less key GET 200 total 0 (silent) · POST 403 GET 200 total 0 · POST 403

    Raw, ARM A:

    {"op":"GET","label":"no credential","http":401,"code":"UNAUTHENTICATED"}
    {"op":"POST","label":"no credential","http":401,"code":"UNAUTHENTICATED"}
    {"op":"GET","label":"member key [+control]","http":200,"total":2,"ids":["bu_a1","bu_a2"],"orgs":["org_alpha"]}
    {"op":"POST","label":"member key [+control]","http":201,"id":"wvPt89YwyakCmhr_","org":"org_alpha"}
    {"op":"GET","label":"EX-MEMBER key","http":200,"total":3,"ids":["bu_a1","bu_a2","wvPt89YwyakCmhr_"],"orgs":["org_alpha"]}
    {"op":"POST","label":"EX-MEMBER key","http":201,"id":"P6X5yYS3AIzB-wOL","org":"org_alpha"}
    {"op":"GET","label":"org-less key","http":200,"total":0,"ids":[],"orgs":[]}
    {"op":"POST","label":"org-less key","http":403,"code":"PERMISSION_DENIED"}
    {"op":"GET","label":"unknown key","http":401,"code":"UNAUTHENTICATED"}
    

    Note the ex-member's total 3: it includes wvPt89YwyakCmhr_, the row the legitimate member wrote
    seconds 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:

    {"id":"bu_a1","organization_id":"org_alpha","created_by":null,"name":"alpha unit 1"}
    {"id":"bu_a2","organization_id":"org_alpha","created_by":null,"name":"alpha unit 2"}
    {"id":"bu_b1","organization_id":"org_beta","created_by":null,"name":"beta unit 1"}
    {"id":"bu_b2","organization_id":"org_beta","created_by":null,"name":"beta unit 2"}
    {"id":"wvPt89YwyakCmhr_","organization_id":"org_alpha","created_by":"u_member","name":"w-member"}
    {"id":"P6X5yYS3AIzB-wOL","organization_id":"org_alpha","created_by":"u_exmember","name":"w-exmember"}
    

    The last row is the ex-member's write, stamped org_alpha, in an organization u_exmember has no
    sys_member row for. ARM B produced the same row (xb4qFipiu52jyHiW).

    Both positive controls hold, and a third one falls out of the fixture

    1. Data reaches. The healthy member's key returns 200 with two records on the same route. A probe
      that cannot show data reaching has measured nothing; this one shows it.
    2. The door refuses when it should. No credential is 401 UNAUTHENTICATED, and so is an unknown
      key.
    3. Layer 0 is live and the principal is not exempt. The fixture seeds two rows in org_beta as
      well, and the member key never sees them — GET returns ["bu_a1","bu_a2"], never bu_b1/bu_b2.
      A platform-admin caller (the one principal tenant-layer.ts lets cross the wall) would have seen
      all 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 way test/serve-walled-posture.e2e.test.ts drives it: plain
    node node_modules/@objectstack/cli/bin/run.js serve objectstack.config.ts, NODE_PATH deleted,
    NODE_ENV=production, ready detected on stdout+stderr combined.

    OS_TENANCY_POSTURE=isolated  OS_AUTH_MEMBERSHIP_POLICY=invite-only
    OS_PLATFORM_OWNER_EMAIL=platform-owner@objectos-ee.test  OS_AI_STUDIO_AGENTS=ask,build
    OS_LICENSE_KEY=(ed25519-signed, offline, enterprise)  OS_DATABASE_URL=file:...
    

    Boot banner:

    Mode:    production
    Driver:  SqlDriver(better-sqlite3)  -> file:.../measure.db
    Tenancy: isolated
    Plugins: 44 loaded
             ObjectQL, SqlDriver, HonoServer, PlatformObjects, Auth, @objectstack/setup,
             @objectstack/account, Organizations, Security, Audit,
             com.objectstack.security.enterprise, ...
    

    Organizations there is the real packages/organizations — the walled-posture license gate and the
    membership-policy gate both fired on the way up, so the enterprise multi-org runtime is genuinely in
    the request path. OS_DATABASE_URL points at a FILE rather than :memory: precisely so written rows
    can be read back from the store.

    Fixture. sys_business_unit — a plain platform object carrying organization_id and no domain
    guard of its own. Two rows in org_alpha, two in org_beta. u_member has a sys_member row for
    org_alpha; u_exmember has one for org_beta ONLY, and its key is stamped active_organization_id = org_alpha. RBAC is opened SYMMETRICALLY for all three principals through one sys_permission_set
    carrying viewAllRecords/modifyAllRecords — the Layer 1 superuser bypass, which by tenant-layer.ts
    does NOT cross Layer 0. So RBAC cannot be what separates the arms; only the organization wall can.

    sys_comment was the first candidate and was DISCARDED, not worked around: it carries its own
    thread_id guard (RECORD_NOT_ACCESSIBLE) and answered total 0 for everyone, which would have
    been 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 kernel
    unbound, because no kernel-manager is registered at all. Both of computeExecCtx's kernel-binding
    branches 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:

    ARM A - shipped single-environment: boot=ready
      "registered env-registry + kernel-manager + kernel-resolver" present: false
      any "kernel-manager" mention at all: false
      plugins: 44   Organizations plugin mounted: true
    ARM B - composed artifact: boot=ready
      "registered env-registry + kernel-manager + kernel-resolver" present: false
      any "kernel-manager" mention at all: false
      plugins: 45   Organizations plugin mounted: true
    

    Census over the cloud tree — the ONE registrar, and neither shipped arm reaches it:

    $ git grep -n "kernel-manager" -- packages/** apps/** plugins/** | grep register
    packages/objectos-runtime/src/objectos-stack.ts:451:  ctx.registerService('kernel-manager', kernelManager);
    

    apps/objectos-ee/objectstack.config.ts reaches createObjectOSStack only in its
    else if (cloudUrl) branch, and cloudUrl is undefined unless OS_MULTI_TENANT is truthy
    (objectstack.config.ts:967-968). The shipped default and the composed-artifact shape both take
    other branches.

    ARM C — the multi-tenant shell: COULD NOT BOOT HERE, and exactly why

    OS_MULTI_TENANT=1 is the arm that mounts createObjectOSStack and therefore registers
    kernel-manager + env-registry + kernel-resolver — the same shell apps/objectos and
    apps/cloud build (apps/objectos/objectstack.config.ts:55). I could not boot it in this
    environment. The exact failure, from the real serve child:

      x [driver-memory] Refusing to start: this driver has NO row-level tenant isolation.
      Detected: tenancy posture `isolated` - a multi-tenant deployment
    

    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 refuses
    that driver under a walled posture. OS_DATABASE_URL does not reach it — the host kernel is a
    routing shell (MetadataPlugin({ registerSystemObjects: false })) with a fixed driver, so there is
    no knob to point it elsewhere. Tried, in order: with dist/objectstack.json absent; then with the
    artifact built via objectstack build (the artifact declares no datasources, so
    extractRuntimeFromMetadata yields 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_key table to authenticate against. Whether
    that 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's origin/main. Every file the reading depends on is
    byte-identical:

    $ git diff --stat HEAD origin/main -- apps/objectos-ee/objectstack.config.ts packages/organizations/src \
        packages/objectos-runtime/src/objectos-stack.ts packages/objectos-runtime/src/kernel-resolver.ts \
        packages/objectos-runtime/src/artifact-environment-registry.ts \
        packages/objectos-runtime/src/file-artifact-api-client.ts
    (no output)
    

    Framework: measured against cloud's actual pin, .objectstack-sha = 8a96e666 (the rig-pin checkout
    at that SHA, dists built there), not against a moving local main. That pin is unchanged on today's
    cloud origin/main.

    ⚠️ One thing the pin diff turned up, and it bears on the repair

    Framework origin/main (460134af8) has already MOVED this seam since 8a96e666 — 109 insertions in
    rest-server.ts, all in the posture block. It is the #13906 decision-1-option-A change: the read is
    now guarded if (kernel && typeof kernel.getServiceAsync === 'function') and a tenancy service that
    FAILED 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:

    That path carries no posture at all; its half of this ruling (decision 1 option B-prime) is refused
    at BOOT instead — see rest-api-plugin.ts.

    rest-api-plugin.ts on origin/main carries no such refusal (that is PR #15020, withdrawn
    2026-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 authRefusal
    still consumerless, deriving the posture alone converts this into a 401 byte-identical to sending no
    credential, which is exactly what the no credential and unknown key rows above already look like.

    ⛔ Nothing was changed, committed or pushed. Probes were throwaway and are not in any tree.

  4. os-warren commented on Sep 4, 2026

    @os-warren
    Collaborator

    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 recommendation 1A · 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/organizations mounted): outcome 2 — kernel is never bound on the shipped objectos-ee wiring (no kernel-manager registered on ARM A or ARM B), the enterprise plugin adds no request-time refusal, and the ex-member key reads and writes org_alpha with 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 obtains authService: a provider wired by rest-api-plugin to the lone local kernel's tenancy service (same shape as authServiceProvider), 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. resolveAuthzContext logs, at warn, at the point it decides each posture-conditional refusal (organization_required at admission, organization_membership_ended after 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-reader authRefusal field 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.ts keeps 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 no tenancy service. The reachability pin makes "unknown" unreachable on every shipped wiring.

    Scope for the domain:cli seat — p0, one PR, before the 17.3 tag

    1. packages/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 in rest-api-plugin.ts (the cloud reading found it: framework main documents the single-kernel half as covered when it is not).
    2. packages/core/src/security/resolve-authz-context.ts: the two warn lines (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-sharing matching ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED fits neither existing remedy cleanly #14937 precedent); the domain:engine seat adopts on the card, no second PR. ⛔ Do not add the log inside api-key.ts's admission path twice — one line per refusal, where the decision is made.
    3. packages/core/src/security/api-key.ts: the docblock rewrite (3A). No behaviour change.
    4. Pins: (a) execctx-authz-input-seam-reachability.test.ts (or its successor) asserts that every computeExecCtx branch supplies a posture when a tenancy service 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 under isolated: member 200/201, ex-member 401/401, organization-less 401, no credential 401, with the warn line asserted for the two refusals; (c) the ablation: remove the provider, the ex-member row goes back to 200 — quoted in the PR body.
    5. Census the other seven resolveAuthzContext callers (mcp plugin, runtime resolve-execution-context, plugin-sharing, service-datasource admin routes, service-settings, service-storage, cloud-connection marketplace-install): which of them supply tenancyPosture today. 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 with Blocked-by: and say so in the body. This is the confidence gap the maintainer was told about.
    6. Changeset: @objectstack/rest patch and @objectstack/core patch — 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.
    7. Not this PR's: the multi-tenant shell (ARM C) measurement in cloud; [finding] ResolvedAuthzContext.authRefusal has zero transport readers — both #8287 refusal reasons collapse to the generic anonymous 401 on every wire #14273's field removal (its own PR, A1); the os serve --dev host-config paths beyond the provider.

    State and follow-ups

    needs-user-decision → pm:queue (read-modify-write; bug · priority:p0 · security · domain:cli untouched). 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

  5. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Sep 4, 2026
  6. hotlong commented on Sep 4, 2026

    @hotlong
    ContributorAuthor

    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).
    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-0e0409d76793
    Branch: claude/issue-15256-single-kernel-posture-provider
    Worktree: objectstack-issue-15256
    Domain: domain:cli — seat post #6024 shows an incumbent, but zero pm:dispatched cards in the lane at claim time and its title records fable 额度耗尽. 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. The packages/core touch is the cross-lane touch this ruling authorises for this PR only (#14937 precedent); the domain:engine seat 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. ⚠️ #14251 sits in pm:queue unassigned naming rest-api-plugin.ts; it is not in flight, and this dispatch does not touch the eleven provider closures it records.

  7. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 4, 2026
  8. hotlong commented on Sep 4, 2026

    @hotlong
    ContributorAuthor

    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, head 58bce113d, one WIP commit, 292 insertions across the four ruled files):

    ruled item state on the branch
    1A posture provider on the single-kernel branch present (rest-api-plugin.ts / rest-server.ts carry it)
    1A the stale B′ comment in rest-server.ts ⛔ NOT fixed — refused at BOOT still matches
    2A the two warn lines present in resolve-authz-context.ts
    3A the api-key.ts docblock rewrite present
    pins (a)(b)(c) and the ablation ⛔ absent
    the seven-caller census (ruling item 5) ⛔ absent
    changeset ⛔ absent

    ⚠️ None of it is verified. A WIP commit is an assertion, not a measurement — the takeover re-reads every line and re-drives the acceptance itself. ⛔ Nothing on that branch is to be trusted because it is already written.

    ⭐ The premise changed while the dispatch was dying: 17.3.0 shipped

    @objectstack/cli@17.3.0 and its siblings are tagged. Verified on origin/main: the single-kernel branch still derives no posture, and rest-server.ts still documents the seam as "refused at BOOT — see rest-api-plugin.ts" while that file carries no such refusal. So the leak is in a released version, not merely on main.

    Consequences for this card, and only these:

    • The ruling's "before the 17.3 tag" framing is spent. The scope, the three options and every ⛔ in the ruling are unchanged — it is the release vehicle that moved, and that is the maintainer's call, put to them in chat and ⛔ not decided here.
    • The changeset stays @objectstack/rest patch + @objectstack/core patch as ruled. ⛔ Do not re-price it upward on account of the release having happened.

    State: pm:dispatched and the assignee are retained through the takeover (the work is continuous). Session 6679d191-11f4-465b-b322-0e0409d76793, maintainer direct-dispatch channel.

  9. hotlong commented on Sep 4, 2026

    @hotlong
    ContributorAuthor

    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`."
     ]
    }
  10. hotlong commented on Sep 4, 2026

    @hotlong
    ContributorAuthor

    Landed. PR #15365 merged by the queue at 2026-09-04T13:58:48Z; merge commit a72704375, confirmed on origin/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 its Fixes line.

    Against the ruling, item by item:

    ⚠️ The exposure is in a shipped release. 17.3.0 was tagged before this landed, so every 17.3.0 deployment on a wall-enforcing posture carries it. The release vehicle for this fix is the maintainer's call and is open — an out-of-band 17.3.1 with an advisory, or the next regular release. ⛔ No seat decides that from this card.

    Still unmeasured, deliberately: the multi-tenant shell (cloud ARM C) — apps/objectos / apps/cloud, the only wiring that registers kernel-manager. It could not be booted for the cloud reading (createObjectOSStack hardcodes InMemoryDriver for the host kernel, which the driver's own guard refuses under isolated). The hosted blast radius is therefore unknown, not safe.

    State: pm:dispatched stripped in the same stroke as the close; bug, priority:p0, security, domain:cli retained.

  11. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdomain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpriority:p0Queue-jump: outranks batch and breaks the round — never exempts claiming or same-file serialsecurity

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions