Skip to content

The audience-binding-suggestion reconciler writes with a tenant-less system context, so one organization-less row serves every tenant — the second half of the #8577 install-path dead end #8617

Description

@os-zhuang

Found while implementing #8577 (scoping sys_audience_binding_suggestion's declared unique index per organization), by measuring the install path end to end through the real reconciler rather than stopping at the driver-level 409/201 oracle. Filed separately because #8577's scope is exactly the two index respellings; nothing here is fixed there.

What #8577 fixes, and what it does not

#8577 makes the storage able to hold one suggestion row per organization: (package_id, permission_set_name, anchor) stops being an installation-wide key. Measured with a tenant-threaded execution context on a real ObjectQL + SqlDriver engine, driving the real syncAudienceBindingSuggestions:

PRE-fix   org_jia sync -> created 1 | org_yi sync -> created 0, org_yi sees 0 rows
POST-fix  org_jia sync -> created 1 | org_yi sync -> created 1, each sees its own

But the shipped call path never threads a tenant. packages/plugins/plugin-security/src/suggested-audience-bindings.ts reads and writes through a module-level constant:

const SYSTEM_CTX = { isSystem: true };

and security-plugin.ts invokes the reconciler with the bare engine at boot and after a package-door permission publish. listAudienceBindingSuggestions gates on the caller's identity but then reconciles with SYSTEM_CTX too.

Measured on a real engine (better-sqlite3, OS_MULTI_ORG_ENABLED=true, OS_TENANCY_POSTURE=isolated)

measurement
insert under { isSystem: true } stores organization_id NULL
insert under { isSystem: true, tenantId: X } stores organization_id = X
find under { isSystem: true } sees every organization's rows
find under { isSystem: true, tenantId: X } sees X's rows and the NULL-organization rows

The last row is the platform's declared behaviour, not a surprise: the driver expands a tenant predicate to organization_id = :tenant OR organization_id IS NULL (ADR-0120 D3's platform bucket).

Running the reconciler exactly as shipped, twice, on the post-#8577 schema:

first  sync -> { created: 1 }
second sync -> { created: 0 }
stored rows -> [ { org: NULL, key: com.acme.crm/sales_readonly/everyone, status: pending } ]

Identical before and after #8577. So on a shared-runtime multi-organization installation the surface holds one organization-less row that every tenant reads, and its consequences are:

  1. The first tenant admin to confirm or dismiss flips that single row. Every other tenant's console then shows a resolved suggestion and never prompts them — while confirmAudienceBindingSuggestion created the actual sys_position_permission_set binding with the caller's context, i.e. in the confirming tenant's organization only. So for every other tenant the package's default permission set is still not bound, and the surface says it is.
  2. findAnchorPositions resolves sys_position by name under the same tenant-less context with limit 1, so the everyone anchor a suggestion is checked against is whichever organization's row the driver returns first.
  3. bindingExists therefore answers a question about some other tenant's binding.

This is the same functional dead end #8577 describes — the second organization's admin is never prompted — reached by a second, independent road. Fixing the index is necessary (without it even a correctly tenant-scoped write is refused) but it is not sufficient on this deployment shape.

Suggested shape (not a decision — the tenancy question is the maintainer's)

Two readings, and they lead to different architectures, so this needs the ruling before code:

  • A. The suggestion surface is per-organization (what the object's own header says: rows are "resolved when a tenant admin confirms", and organization_id is injected with no tenancy opt-out). Then the reconciler must run per organization with that organization's context, and every read in it must be tenant-scoped. This is the reading Two MORE clean inheritors of the #8323 class the #8554 sweep missed: sys_notification_subscription and sys_audience_binding_suggestion — both measured live #8577's respelling assumes.
  • B. The suggestion surface is installation-wide (one row per package x set x anchor, deliberately organization-less). Then the object should declare a tenancy opt-out and its index should be an explicit unique: 'global' — and the per-tenant prompt has to live somewhere else, because one row cannot carry N tenants' decisions.

B contradicts the object's declared shape and ADR-0090 D5/D9's per-tenant confirm gate, so A looks right, but the choice is a public-contract question about the object.

Pinned, not just described

packages/plugins/plugin-security/src/suggested-audience-bindings-install-path.test.ts (added by #8577) records today's behaviour in its section 3, the shipped SYSTEM_CTX call path is tenant-blind (recorded, not endorsed). Those two assertions are a record, not an endorsement: the fix for this issue must DELETE them, and if they still pass afterwards the fix did not work.

Related

Filed unassigned, unlabeled for triage. Found by session session_012WMpuAfA2KSdDjGF6tm1bH (dev seat for #8577).

Activity

  1. added theissue type on Aug 14, 2026
  2. hotlong commented on Aug 14, 2026

    @hotlong
    Contributor

    Blocked-by: #8577

    Triage: lands in packages/plugins/plugin-security (suggested-audience-bindings.ts — SYSTEM_CTX at line 43 verified on current origin/main; invocations in security-plugin.ts at boot and post-publish) ⇒ domain:identity + security, type Bug, state pm:blocked on #8577, auto-adjudicated → Reading A (the suggestion surface is per-organization).

    Adjudication basis (veto window — flagged in the round report):

    • The contract question is already decided by existing authority, not open: the object's own declared shape (organization_id injected, no tenancy opt-out, rows "resolved when a tenant admin confirms") and ADR-0090's model ("packages suggest, admins confirm" with the per-tenant confirm gate) both say per-organization. Reading B would need a tenancy opt-out, an explicit unique: 'global', and a brand-new per-tenant prompt surface — contradicting the declared shape and expanding architecture. Mechanical boundary test: A is declared = enforced restoration; accept set and public face unchanged ⇒ repair lane.
    • Four-prism block:
      1. Platform long-term coherence: A closes a declared/enforced split and aligns with ADR-0090 and ADR-0120 D3 tenancy; B multiplies special cases (opt-out + parallel prompt surface).
      2. Measured business pull: measured on a real engine — on any shared-runtime multi-org install, the second tenant's admin is never prompted, and the surface reports a suggestion resolved while the binding does not exist in that tenant's org.
      3. AI-agent error-resistance: declaration must equal enforcement; a tenant-blind reconciler behind a tenant-scoped declaration is exactly the silent-divergence class the platform refuses elsewhere.
      4. Startup scope discipline: A repairs an existing capability with no new surface; B declares-and-maintains new architecture.
    • Ruled scope: the reconciler runs per organization with that organization's tenant-scoped context, and every read inside it (findAnchorPositions, bindingExists, list/reconcile paths) is tenant-scoped. The two section-3 assertions in suggested-audience-bindings-install-path.test.ts must be DELETED by the fix, per the card's own pin note.
    • Legacy-rows guard: pre-existing organization-less suggestion rows on deployed instances — the dev must measure and state their disposition in the PR. Reaping derived, reconciler-regenerable rows is inside this ruling; anything beyond that (touching non-derived data, or any tenancy opt-out on the object) triggers the fork-back clause: stop, flip this card to needs-user-decision, never a silent re-decision.
    • Routing note: sibling Two MORE clean inheritors of the #8323 class the #8554 sweep missed: sys_notification_subscription and sys_audience_binding_suggestion — both measured live #8577 is domain:metadata (index respelling on the object declaration); this card's named fix files are all in plugin-security ⇒ identity per the anchoring rule.

    Blocked-by rationale: #8577 (in flight) lands both the index respelling this fix requires (without it a tenant-scoped write is refused) and the test file this fix must edit. The unlock scan returns this card to the queue when #8577 closes.
    Size/model suggestion: M, mode:subagent, opus.


    Generated by Claude Code

  3. claude commented on Aug 14, 2026

    @claude
    Contributor

    Unlock sweep: blocker cleared. pm:blocked → pm:queue.

    Declared blocker: objectstack#8577 — verified closed against the GitHub API on 2026-08-14. No other blocker is declared on this card.

    ⚠️ This card's Blocked-by: line lives in a comment, not the body. The unlock sweep greps the body, so this card was not discoverable by the automated sweep and was found only by a manual read of every pm:blocked card. Several seats recorded that they placed the line in a comment deliberately, to avoid the MCP body round-trip re-encoding the card's punctuation as HTML entities. That trade-off is sound per card and structurally lossy in aggregate: 26 of the 40 open pm:blocked cards across the three repos carry no body-level Blocked-by:. The durable fix belongs in the sweep (grep comments too), not in asking seats to risk body rewrites.

    Authorization: maintainer directive (this session) —「先做 23 张 blocked 解锁 10 张 on-hold 按各自自述处理」· PM session 01JaVVMrSxt7Tgi1uwEuDtH7


    Generated by Claude Code

  4. claude commented on Aug 14, 2026

    @claude
    Contributor

    上发版板 —— 加 target:v17。

    audience-binding-suggestion reconciler 用无租户的 system context 写入,于是一行 organization-less 记录服务了所有租户 —— 跨租户串数据,是 #8577 install-path 死路的后半段。

    它不是凭据外泄,但同属第 ① 类:多租户部署里一个租户看到另一个租户的绑定建议,是「错数据」最直接的形态。

    发版板判据是二元的:「不修它,当前 RC 能不能发?」,第 ① 类是「用户会撞上的已发布缺陷 —— 错数据、静默丢失、安全漏洞」。存储中的密钥可被读走、或被轮换流程销毁,都正落在这一类里。

    v17 不是关闭的列车:17.0.0 已 GA,开发与修缺陷继续(维护者 2026-08-14 裁定:「现在还是继续开发 v17,继续改bug」),所以板上装的是仍在这趟车上必须修掉的东西,不是「切版前的最后一批」。

    Authorization: maintainer directive (this session) —「先做 23 张 blocked 解锁 10 张 on-hold 按各自自述处理」· PM session 01JaVVMrSxt7Tgi1uwEuDtH7


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions