Repository navigation
[finding] API keys carry no organization — under the isolated posture a minted key reads no org data at all (no leak, but the key surface is inert) #8287
Description
Activity
Concentrated findings round (maintainer-directed, 2026-08-13): promoted
finding→needs-user-decision(staysdomain:identity,securityadded — the fork is a security-surface decision, on the human floor by rule).Why promoted rather than held: the dead-end is user-visible today on a real EE deployment — a tenant admin can mint a valid-looking key that reads nothing, with no warning at mint time. The evidence is complete (probe table on-card, root cause located:
sys_api_keyhas no org column;isolatedwall needs an active org), and both exits change either the console's promise or the credential model, so no seat can settle it.Four-prism block:
- Platform long-term coherence — a user-level credential on an org-walled platform is the incoherence; option 2 (keys carry an organization, established as the request's active org) aligns the key model with the tenancy model. Option 1 documents the incoherence honestly but permanently.
- Measured business pull — found by dogfooding, not a customer; but API-key automation against org data is table-stakes for any EE deployment, and today the surface is inert under
isolated. The mint UI already exists, so the pull is "make an existing shipped surface truthful", not new capability. - AI-agent error-resistance — option 2 with an explicit org at mint + membership check is a closed structure; today's silent-empty reads are the masked-failure class (200 + total 0). Option 1 only helps if the console copy actually reaches the caller at call time, which it cannot.
- Startup scope discipline — option 2 is real security-surface work (long-lived tenant-pinned credential: mint-time membership check, revocation-on-membership-loss question, audit). Option 1 is one console string + a docs paragraph.
Recommendation: option 2, scoped to v1 = key inherits the minter's active organization at mint time (no cross-org keys, no org parameter), with a mint-time membership check and org recorded on the row — that is the smallest truthful shape. Option 1 as the interim if appetite is zero this cycle (console copy: "API keys authenticate the user without an organization; under isolated tenancy they cannot read org data"). Prisms 1–3 point at 2; prism 4 is the brake — your call on appetite. Also note the on-card cleanup: two probe keys remain on the test environment's owner account.
Generated by Claude Code
Maintainer ruling — Option 2, minimal v1 scope
Ruled in a live PM session, 2026-08-13, accepting the triage seat's four-prism recommendation in full (maintainer verbatim: 「接受你的全部建议」). Recorded by the triage seat as a ruling record.
Ruling: option 2 (keys carry an organization), scoped to the smallest truthful shape:
- A key is minted against the minter's active organization — inherited, ⛔ not an org parameter, ⛔ no cross-org keys in v1.
- Membership check at mint time; the organization is recorded on the
sys_api_keyrow. - Key authentication establishes that organization as the request's active organization, so the
isolatedwall can match.
⛔ Option 1 (document the inertness in the console) is rejected as the destination: it would freeze the incoherence into shipped copy. The console string may still be worth adding as a stopgap while the work lands, at the identity seat's discretion — but it is not the fix.
Prism basis: an existing shipped surface (the mint UI) currently hands a tenant admin a valid-looking credential that reads nothing — making it truthful is not new capability; a user-level credential on an org-walled platform is the incoherence; today's
200 + total 0is the silent-empty class, while an explicit org + membership check is a closed structure. The brake was startup scope — a long-lived tenant-pinned credential is real security work — which the v1 narrowing above is chosen to keep small.Follow-through the identity seat must decide explicitly during implementation (report as declared scope, do not silently skip):
- What happens to a key when its owner's membership in that organization ends.
- Whether revocation/audit rows need the organization stamped too.
- Whether existing org-less keys are migrated, refused, or left inert — ⛔ never silently upgraded to org access.
Housekeeping from the card: two probe keys (
probe-key-orgA,probe-key-bound) remain on the test environment's owner account — delete if that environment is kept.State:
needs-user-decision→pm:queue,securityretained. Blocked-by: none (checked live).
Generated by Claude Code
- addedpriority:p0Critical: blocker, must ship before MVPCritical: blocker, must ship before MVPand removed
on Aug 13, 2026 Dev claim + STOP: irreducible cross-lane file surface
Session:
session_01PEVB6w7D7uCszR9Mw1BL73· Branch reserved (not created, not pushed):claude/issue-8287-api-key-organization· No worktree created, no file edited, no PR.Stopping before the first edit per the dispatch instruction to verify lane ownership against the repo's own table rather than the brief. The table disagrees with the card's label, and the disagreement is not cosmetic — it is 4 lanes wide.
1. Lane table says
platform-objectsis NOT this card's lane.claude/skills/pm-dispatch/SKILL.mdline 185:label package family domain:metadatapackages/metadata*(加载、注册、持久化、缓存、目录)、packages/platform-objectsdomain:identityplugin-auth、plugin-security、plugin-sharing、plugin-auditThis card is labeled
domain:identity. The identity lane ownsplugin-authonly — one file out of the surface below, and not the central one. The anchoring rule directly above that table is explicit: "an issue'sdomain:*label is the domain of the package the fix lands in, decided at triage by reading the code." Read from the code, this card lands mostly outsidedomain:identity.2. The irreducible file surface, per lane
The ruling requires three things — column on the row, membership check at mint, key auth establishes the org. Traced through the code, each lands in a different package:
file why it is unavoidable package lane packages/platform-objects/src/identity/sys-api-key.object.tsdeclare organization_id+ index. The ruling says "the organization is recorded on thesys_api_keyrow" — there is no other home for the columnplatform-objectsdomain:metadatapackages/platform-objects/src/apps/translations/{en,zh-CN,es-ES,ja-JP}.objects.generated.tsa labeled field on an owned object; bundle-ownership.test.tsownssys_api_keyhereplatform-objectsdomain:metadatapackages/plugins/plugin-auth/src/managed-extension-fields.ts(+ its test)sys_api_keyismanagedBy: 'better-auth'; ADR-0105 D7 requires every ObjectStack-added column on a managed table to be declared hereplugin-authdomain:identitypackages/runtime/src/domains/keys.ts(+http-dispatcher.keys.test.ts)the only mint path. Membership check + stamping organization_idon the inserted row go hereruntimedomain:clipackages/core/src/security/api-key.ts(+api-key.test.ts)the shared verifier; the ex-member fail-closed check (decision 1) and the org-less refusal (decision 3) belong here coredomain:engine-coreFour lanes:
domain:metadata,domain:identity,domain:cli,domain:engine-core. Good news:packages/specis not touched, so the absolute "凡触packages/spec一律转domain:spec座位" rule does not add a fifth.The split the brief forbids is exactly the one available here — identity could ship
managed-extension-fields.tsalone, which is auth wiring with no column. Not doing that.This is the lane table's documented 跨域例外路径: a genuinely un-splittable cross-domain PR, designated by the 分诊座位 to one lane PM, whose claim comment declares the full file surface. Designation is not a dev agent's call, nor a lane PM's self-designation. Handing it back for that designation.
3. Two premise corrections found while verifying
(a) The root cause is deeper than "the column was forgotten."
resolveInjectedSystemColumns(packages/spec/src/data/injected-system-columns.ts) injectsorganization_idinto every registered object — except that rule 2 returnsnothingformanagedBy === 'better-auth'.sys_api_keycarries that flag (while, permanaged-extension-fields.ts, better-auth'sapiKeyplugin is not even loaded and the table is hand-rolled ObjectStack). So the column is absent by an inherited managed-table rule, not by oversight — which is precisely why the fix cannot be one file: it needs the declaration and the D7 extension-field registration to be consistent.(b) The read side is already wired — only the write side is missing.
resolveApiKeyPrincipalalready doestenantId: row.organization_id ?? row.organizationId ?? undefined, andresolveAuthzContextalready doestenantId = keyPrincipal.tenantId. The ruling's third clause ("key auth establishes that organization as the active organization") is already implemented and reads a column that never exists. Scope is smaller than the card implies, but no less cross-lane.(c) ADR-0087 registration is NOT required — contrary to the dispatch brief's expectation.
scripts/check-adr-0087-registration.mjsfires only on a changeset that declares a breaking/major change ("the gate only notices that a declared-breaking change arrived saying NOTHING about the ledger"). This is an additive column on anisSystem+protection: { lock: 'full' }object that tenants cannot author, so there is no consumer metadata to migrate:minorchangeset, no entry file, no three-projection regeneration. If a reviewer wants a marker anyway, the honest one isnot-required (no-migration-prescription). Nodocs/adr/**edit needed either, so no maintainer-merge-only constraint.
4. The three follow-through decisions — decided, with reasoning
Decided as instructed even though I am not implementing them, so the designated seat inherits the analysis rather than redoing it.
1 — Key whose owner's membership in that organization ends: fail closed at VERIFY time, not revoke-on-event.
Membership can end through many paths (better-auth org endpoints, SCIM deprovisioning, directsys_memberdelete, ADR-0091 validity windows). A revoke-on-removal hook must catch every one or it silently misses; a verify-time check cannot be bypassed by an unhooked path. The check is free:resolveUserAuthzGrantsalready runstryFind(ql, 'sys_member', { user_id: userId })on the very same request to buildaccessible_org_ids, so asserting the key's stamped org is in that set adds zero queries. Verdict: no principal (401) rather than degrading to a user-only principal — degrading would resurrect the exact200 + total 0silent-empty class this card exists to kill. Engineering call, not product: an ex-member's credential reading the org's data is not a matter of appetite. Revoke-on-event is worth adding later as hygiene, never as the enforcement.2 — Revocation / audit rows: yes, stamp the organization.
sys_api_keydeclaresenable.trackHistory: true, and audit rows are themselves org-walled data — an unstamped audit row is a row no tenant admin can read underisolated, reproducing this defect one layer down. Mostly automatic once key auth establishestenantId, since the audit writers stamp fromExecutionContext.tenantId. The one gap needing an explicit choice: a revocation performed from a session whose active org differs from the key's org. Stamp from the row'sorganization_id(the org the record is about), not the actor's active org. Engineering call.3 — Existing org-less keys: posture-conditional refusal. NEVER backfilled.
Backfilling from the owner's current org is out by the maintainer's own ⛔ — it silently upgrades credentials minted under a different promise. The real choice is inert vs refused, and "inert" is not neutral, which is the measured part:computeTenantLayer0Filter(plugin-security/tenant-layer.ts:126) exempts objects with no org column —if (input.objectHasOrgIdField === false) return null;. Declaring the column removes that exemption, sosys_api_keyitself becomes org-walled and existingorganization_id IS NULLrows go invisible in the console's "My Keys" list to their own owner. Leaving them "inert" therefore degrades them from reads nothing to invisible and still authenticating — a new silent-empty.But refusal must be posture-conditional, because org-less keys are not uniformly broken today:
single— no wall at all; org-less keys work fine. Refusing breaks working automation for zero security gain. Leave working.group— the wall isorganization_id IN accessible_org_ids, andaccessible_org_idsis derived purely from the owner'ssys_memberrows, independent oftenantId(verified atresolve-authz-context.ts:290-296). So an org-less key already works undergroup, reading the union of its owner's orgs. Refusing breaks working deployments. Leave working.isolated— provably reads nothing (this card's probe table). Refuse at verify time with a distinguishable error code, so the failure is loud at call time instead of200 total 0.
⚠️ Residual product question I am flagging rather than settling: refusing underisolateddoes break one thing the card measured as working —GET /data/sys_userreturning the key owner's own row (walled by an enumerated member-id list, not the org column). I judge self-reading your own user row not to be a use case anyone builds automation on, and the card's own thesis is that a valid-looking credential which reads nothing is the defect. But that is the single point in these three where a product voice could reasonably differ, so it is on the record for the maintainer rather than buried in a diff.
5. Recommendation on the interim console string
Recommend against. The ruling permits it at the identity seat's discretion, but the seat cannot reach the console honestly either:
packages/consolehas no fixed lane owner (dist is script-generated, ⛔ hand edits; UI defects route torepo:objectui). Shipping copy that documents a defect we are about to fix is a second cross-lane PR to add text the fix then has to remove.6. Housekeeping (relayed, not actioned)
Two probe keys —
probe-key-orgAandprobe-key-bound— remain on the test environment's owner account (objectos-ee-deploy,localhost:8080). Passing this to that environment's owner; I did not go looking for the environment.7. Not verified
No build, no test, no gate was run — the stop is before the first edit, so there is nothing to verify and nothing was queued on the shared verification lock. Every claim above is read from source at
origin/main(3373a290d) with the file and line cited. I did not change assignees (nothing was implemented to claim, and the shared identity makes the field unreadable as a claim signal anyway).
Generated by Claude Code
{ "issue": 8287, "status": "blocked", "branch": "claude/issue-8287-api-key-organization", "pr": null, "premise_still_valid": true, "summary": "STOPPED before the first edit on an irreducible cross-lane file surface, per the dispatch instruction to verify lane ownership against the repo's own table. .claude/skills/pm-dispatch/SKILL.md:185 puts packages/platform-objects under domain:metadata, not domain:identity, and the ruled v1 spans FOUR lanes: platform-objects (sys-api-key.object.ts + 4 translation bundles) = domain:metadata; plugin-auth/managed-extension-fields.ts = domain:identity; runtime/domains/keys.ts = domain:cli; core/security/api-key.ts = domain:engine-core. packages/spec is NOT touched, so no fifth lane. The card's premise holds and the root cause is deeper than stated: resolveInjectedSystemColumns injects organization_id into every object EXCEPT managedBy:'better-auth', which sys_api_key carries, so the column is absent by an inherited managed-table rule. Conversely the read side is already wired - resolveApiKeyPrincipal already reads row.organization_id into tenantId and resolveAuthzContext already adopts it - so only the column and the mint-time write are missing. Splitting identity's one file off would ship auth wiring without the column, which the brief forbids; the lane table's cross-domain exception path requires designation by the triage seat, not by a dev agent or a self-designating lane PM.", "tests": "None run. The stop is before the first edit, so no worktree, no build, no test, no gate, and nothing queued on /tmp/os-heavy-verify.lock. Every claim is read from source at origin/main (3373a290d) with file and line cited: lane table .claude/skills/pm-dispatch/SKILL.md:185,188; injection suppression packages/spec/src/data/injected-system-columns.ts (managedBy==='better-auth' returns nothing); wall packages/spec/src/security/tenancy-posture.ts + plugin-security/tenant-layer.ts:119-147; org-column exemption tenant-layer.ts:126; read side already wired core/src/security/api-key.ts (tenantId: row.organization_id ?? row.organizationId) and core/src/security/resolve-authz-context.ts:117-134; accessible_org_ids is tenantId-independent at resolve-authz-context.ts:290-296; D7 registration requirement plugin-auth/src/managed-extension-fields.ts:82-89; mint path runtime/src/domains/keys.ts; ADR-0087 gate scope scripts/check-adr-0087-registration.mjs header.", "open_questions": [ { "question": "Who implements a P0 card whose irreducible surface spans domain:metadata, domain:identity, domain:cli and domain:engine-core?", "options": [ "A. Triage seat designates one lane PM via the lane table's documented cross-domain exception path (跨域例外路径), that PM declares the full four-lane file surface in its claim comment and runs the targeted in-flight check", "B. Re-label the card domain:metadata (where the load-bearing file lands) and dispatch there, accepting that it still reaches three other lanes", "C. Split into per-lane PRs" ], "recommendation": "A. It is the lane table's own named channel for exactly this shape ('真拆不动的跨域单 PR'), and designation is explicitly the 分诊座位's call - not a dev agent's, and not a lane PM self-designating. C is rejected outright: any split lands either the column without the auth wiring or the auth wiring without the column, and the brief forbids the latter. B understates the reach and skips the in-flight check the exception path mandates." }, { "question": "Follow-through #3 residual: refusing org-less keys under `isolated` breaks the one behavior the card measured as WORKING - GET /data/sys_user returning the key owner's own row (walled by an enumerated member-id list, not the org column).", "options": [ "A. Refuse org-less keys under `isolated` at verify time with a distinguishable error code; leave `single` and `group` untouched", "B. Leave org-less keys inert under `isolated`" ], "recommendation": "A, and I decided it - but flagging the residual rather than burying it, because it is the single point of the three where a product voice could reasonably differ. Reasoning: 'inert' is NOT neutral once the column is declared. tenant-layer.ts:126 exempts objects with no org column, so declaring it makes sys_api_key itself org-walled and existing organization_id IS NULL rows go invisible in the console's My Keys list TO THEIR OWN OWNER - a NEW silent-empty of exactly the class this card kills. Refusal must be posture-conditional: `single` has no wall and org-less keys work; under `group` the wall is organization_id IN accessible_org_ids and accessible_org_ids is derived purely from the owner's sys_member rows independent of tenantId (verified resolve-authz-context.ts:290-296), so org-less keys ALREADY WORK there - refusing either would break working deployments for zero security gain. Only `isolated` is provably dead today, so only `isolated` refuses. Self-reading your own user row is not a use case automation is built on, and the card's own thesis is that a valid-looking credential which reads nothing is the defect." } ], "out_of_scope_findings": [ "Not filed as issues - all three are inside this card's own completion scope and belong in its PR, not scattered: (1) resolveApiKeyPrincipal reads `row.organization_id ?? row.organizationId` - a lenient consumer-side alias chain of the class AGENTS.md PD #12 forbids; the producer (the mint path) will write exactly one spelling, so the alias should collapse to the canonical column in the same PR that starts writing it. (2) MANAGED_EXTENSION_FIELDS.sys_api_key lists only `revoked` while its own doc comment states 'EVERY column here is an extension field' - a declared-vs-actual drift the new column forces a decision on. (3) sys_api_key's per-lane consequence: declaring organization_id removes the tenant-layer.ts:126 exemption and newly org-walls the object itself, which changes console list behavior for pre-existing rows and needs a fixture in whichever PR lands the column.", "Housekeeping relayed, not actioned: probe keys `probe-key-orgA` and `probe-key-bound` remain on the objectos-ee-deploy test environment's owner account. Passing to that environment's owner; I did not go looking for the environment." ] }
Generated by Claude Code
PM:转
pm:blocked。⚠️ 这是一张priority:p0,卡在四个 lane** 的跨域指定上——而按 lane 表,指定权在分诊席位,不在我、也不在 dev。**domain:identity执行席位,sessionsession_01PEVB6w7D7uCszR9Mw1BL73。派发后 dev 在第一次编辑前停下并上报,零 diff、零分支、零 worktree。这是派发令要求的行为。所有断言都是从
origin/main读的,附文件行号。裁定的 v1 范围横跨四个 lane
文件 lane platform-objects/src/identity/sys-api-key.object.ts+ 4 个翻译包domain:metadata(lane 表SKILL.md:185)plugin-auth/src/managed-extension-fields.tsdomain:identityruntime/src/domains/keys.tsdomain:clicore/src/security/api-key.tsdomain:engine-corepackages/spec未触及,所以没有第五个 lane。⛔ 按 lane 表,「真拆不动的跨域单 PR」有一条命名的例外通道,但必须由分诊席位指定——dev agent 不能自行进入,lane PM 也不能自我指定。所以这张 p0 停在这里等指定,不是等开发。
根因比卡片写的更深,而且是好消息
resolveInjectedSystemColumns(packages/spec/src/data/injected-system-columns.ts)向每一个对象注入organization_id,除了managedBy: 'better-auth'的——而sys_api_key正带着这个标记。所以那一列不是被漏掉的,是被一条继承来的托管表规则挡住的。⚠️ 而读侧已经接好了:resolveApiKeyPrincipal(core/src/security/api-key.ts)已经把row.organization_id读进tenantId,resolveAuthzContext(:117-134)已经采纳它。所以缺的只有那一列和铸造时的写入——这比裁定当初设想的范围小。⚠️ 后续项 #3 有一处残留,dev 决定了但没有埋掉,值得单看裁定要求显式决定「既有的无组织密钥是迁移、拒绝、还是保持惰性」。dev 的发现是:
「惰性」在列被声明之后就不再是中性的。
tenant-layer.ts:126豁免的是没有 org 列的对象;一旦声明了这一列,sys_api_key自己就变成被 org 墙管的对象,于是既有的organization_id IS NULL行会在 console 的 My Keys 列表里对它们自己的所有者不可见——一个全新的 silent-empty,正是这张卡要消灭的那一类。它的决定是拒绝必须按 posture 分条件,只有
isolated拒绝,依据是实测而非推断:single没有墙,无组织密钥正常工作;group的墙是organization_id IN accessible_org_ids,而accessible_org_ids纯粹由所有者的sys_member行派生、与tenantId无关(实测resolve-authz-context.ts:290-296),所以无组织密钥在那里本来就能用——拒绝它们会为零安全收益打断正在工作的部署;- 只有
isolated是可证明今天就是死的,所以只有isolated拒绝。
⚠️ 它明确标注这是三条后续项里唯一一处产品声音可能合理不同意的地方,并把残留摆出来而不是埋掉:拒绝isolated下的无组织密钥,会打断卡片实测为唯一能用的那个行为(GET /data/sys_user返回密钥所有者自己的行——那是被成员 id 枚举列表挡的,不是被 org 列挡的)。它的理由是「自读己身不是自动化建立其上的用例,而本卡的论点正是『读不到东西的有效凭据就是缺陷』」。如果裁定方对这一条有不同看法,现在是最便宜的时刻——还没有一行代码。
我的派发令有两处错,dev 纠正了,记在这里
- ⛔ ADR-0087 注册不需要——那道门只在 changeset 声明 breaking/major 时触发,而这是在一个受保护锁定的
isSystem对象上增列。所以没有 entry 文件、不需要重新生成三个投影、不涉及docs/adr/**,也就没有 maintainer-merge-only 的约束。我按最坏情况预警,过头了。 - lane 表没有把
platform-objects归domain:identity——我在简报里说不确定并要求它查表,这次的做法是对的。
三条留在本卡完成范围内、不另开 issue 的东西
dev 明确说这三条属于本卡自己的 PR、不该散出去:
resolveApiKeyPrincipal读的是row.organization_id ?? row.organizationId—— AGENTS.md PD Add comprehensive test suite for Zod schema validation #12 禁止的消费者侧宽容别名链。生产者(铸造路径)只会写一种拼写,所以别名应当在开始写它的同一个 PR 里收敛到规范列名。MANAGED_EXTENSION_FIELDS.sys_api_key只列了revoked,而它自己的文档注释写着「EVERY column here is an extension field」——新列迫使这处声明与实际的漂移必须做决定。- 声明
organization_id会移除tenant-layer.ts:126的豁免、把该对象本身变成 org 墙管的,这改变既有行在 console 列表里的行为,需要在落地该列的那个 PR 里配一个 fixture。
顺带(非本席位动作)
测试环境
objectos-ee-deploy的所有者账号上仍有两把探针密钥probe-key-orgA、probe-key-bound。转达给该环境的所有者;dev 没有去找那个环境,我也不会。
Generated by Claude Code
Maintainer ruling (2026-08-14, verbatim: 「同意」, approving item 2 of the six-item ruling list: 「#8287 归席 A 一体实施?」): the cross-lane designation this card was blocked on is resolved — it belongs to the v17 bug-focus Seat A (#8667) as one integrated implementation of the already-ruled minimal v1 (key minted against the minter's active org, membership check at mint, org stamped on row, key auth establishes that org, ex-member keys fail-closed at verify, no silent backfill of org-less keys). The theme seat spans domains by construction, so the irreducible 4-lane file surface is no longer a blocker.
pm:blocked→pm:queue,target:v17applied; Seat A first batch.
Generated by Claude Code
- added a commit that references this issue
on Sep 18, 2026 - added a commit that references this issue
on Sep 28, 2026 - added 4 commits that reference this issue
on Oct 7, 2026
Recording an observation for the platform to rule on.
Good news first: an API key does not leak across tenants. Every probe below stayed empty or 404 — there is no cross-organization exposure here.
The finding is the other side of that: on a deployment running
OS_TENANCY_POSTURE=isolated, a minted key appears unable to read any organization's data, which makes the key surface effectively inert while the console still offers minting.Measured
Minted as the owner of 测试组织甲, from a session whose active organization was that org:
Then,
credentials: 'omit'so only the key authenticates:x-api-key)401 UNAUTHENTICATEDGET /data/sys_user200, total 1 — only the key's own ownerGET /data/sys_team200, total 0 (the org has 1 team)GET /data/sys_team/<orgA team id>404GET /data/sys_team/<orgB team id>404GET /auth/me/permissions200with an empty body — notenantId, nopositionsAttempts to give the key an organization, all ineffective:
{"organizationId": "<orgA>", "organization_id": "<orgA>"}→ key created, stillsys_teamtotal 0x-organization-id,x-org-id,organization-id→ still total 0Why it lands this way
sys_api_keyhas no organization column at all — its declared fields arename, prefix, user_id, scopes, expires_at, last_used_at, revoked, key, id, created_at, updated_at(packages/platform-objects/src/identity/sys-api-key.object.ts). Key auth therefore establishes a user but no active organization, and theisolatedwall isorganization_id = activeOrganizationId(packages/spec/src/security/tenancy-posture.ts:15) — with no active org, no row can match.sys_useris the exception only because it is walled by an enumerated member-id list rather than by the org column.The decision
Option 2 is the one that makes the existing UI honest, but it is a real security-surface decision (a long-lived credential pinned to a tenant), so it should be made deliberately rather than by patching the symptom.
Environment
objectos-ee-deploy(Caddy → app → postgres:16,NODE_ENV=production,OS_TENANCY_POSTURE=isolated) onhttp://localhost:8080, observed 2026-08-13. Two probe keys were minted during this test and are still present on the owner account (probe-key-orgA,probe-key-bound) — they carry no org access, but delete them if the environment is kept.Related: #8286 (the same isolation wall, described back to callers in analytics SQL).