Skip to content

Commit 7e4783f

Browse files
claude[bot]os-zhuangclaude
authored
fix(plugin-security,service-messaging): scope two more tenant-scoped declared unique indexes per organization (#8577) (#8626)
* fix(#8577): scope two more tenant-scoped declared unique indexes per organization Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH * docs(#8577): name the filed follow-up #8617 in the declaration, test and changeset Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH * test(#8577): alias driver-sql and objectql to SOURCE in plugin-security's vitest config check:test-source-alias measured the new install-path suite resolving both workspace deps through dist/ — which would make its verdict a function of build state. A stale driver-sql would report the pre-fix installation-wide index as per-organization: the #8577 defect itself, passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH * test(#8577): route the install-path seams' write verbs through ObjectQL's dispatch predicates check:engine-double-contract counted the two ql handles as engine doubles whose delete()/update() did not route through the producer's predicates. Fixed at the doubles, never by growing the shrink-only baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH --------- Co-authored-by: os-zhuang <jack@objectstack.ai> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 82ea3f5 commit 7e4783f

11 files changed

Lines changed: 1590 additions & 5 deletions
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
"@objectstack/service-messaging": patch
4+
---
5+
6+
fix(plugin-security,service-messaging): two more tenant-scoped declared unique indexes become per-organization (#8577)
7+
8+
Two platform objects declared their uniqueness as a table-level index with bare
9+
`unique: true`. At the DECLARED-index level that is the positional spelling of
10+
`'global'` — the listed columns verbatim — so on a tenant-scoped object each
11+
materialized an **installation-wide** unique index. (Field-level `unique: true`
12+
means the opposite, per-organization, and has since #3696; `packages/lint` names
13+
that divergence "the #4986 trap" and warns on it via
14+
`unique/unscoped-declared-index`.) These are the fifth act of the class ruled on
15+
2026-08-13, after `sys_user_preference` / `sys_capability` (#8461),
16+
`sys_position` (#8556) and the five of #8554.
17+
18+
| object | package | was | now |
19+
|---|---|---|---|
20+
| `sys_notification_subscription` | `service-messaging` | `[topic, principal]` global | same, per organization |
21+
| `sys_audience_binding_suggestion` | `plugin-security` | `[package_id, permission_set_name, anchor]` global | same, per organization |
22+
23+
Measured live on a real engine before the fix — two organizations, the same key,
24+
`OS_TENANCY_POSTURE=isolated`, driving the real shipped declarations. Both
25+
reproduced identically:
26+
27+
```
28+
org_jia POST the key → 201
29+
org_yi POST the SAME → 409 UNIQUE_VIOLATION
30+
org_yi POST an unused → 201 ← the control that makes it an oracle
31+
org_yi GET the key → total 0 ← refused by a row it cannot see
32+
```
33+
34+
`sys_notification_subscription` is the class's usual shape and the direct sibling
35+
of `sys_notification_preference`: a user belonging to two organizations could not
36+
subscribe to the same topic in both, and since `role:x` / `team:x` principal
37+
names are themselves per-organization, the same string denoted different
38+
subscribers while colliding on one installation-wide key.
39+
40+
`sys_audience_binding_suggestion` is **more serious, and it is not a naming
41+
collision at all.** Its key is the owning package's id, the package's own
42+
permission-set name and the anchor — the same triple for every tenant that
43+
installs the same package — while the row is per-tenant by construction
44+
(ADR-0090 D5/D9: raised when a package's `isDefault` set is observed, resolved
45+
when a tenant admin confirms). So the second and every later organization to
46+
install a package never got its suggestion row: its admins were never prompted to
47+
bind the package's default permission set, its users never received that set, and
48+
nothing reported it — the reconciler cannot distinguish the cross-tenant UNIQUE
49+
violation from the benign concurrent-sync race its `catch` was written for. Both
50+
halves are now pinned end to end: two organizations installing the same package
51+
each end up with their own pending row, and re-running one organization's sync
52+
still adds nothing.
53+
54+
### One caveat on `sys_audience_binding_suggestion`
55+
56+
This release makes a per-organization suggestion row **possible**; it is not yet
57+
what the platform writes. The reconciler still reads and writes through a
58+
tenant-less system context, so on a shared-runtime multi-organization
59+
installation the surface continues to hold one organization-less row that every
60+
tenant reads — measured, recorded as a test, and tracked in #8617, which remains
61+
open. Single-organization installations are unaffected either way.
62+
63+
## ⚠️ Operators: a migration is REQUIRED, and deploying this release is not it
64+
65+
Respelling a declared index changes its generated **name**. On an existing
66+
database `initObjects` is additive: it creates the new per-organization composite
67+
at boot and **never drops the old global index**, which goes on enforcing. Until
68+
the retirement is applied, a deployed installation that has taken this release
69+
still refuses the second organization's row — that is asserted as a test, not
70+
assumed.
71+
72+
Run the migration:
73+
74+
```
75+
os migrate plan # shows one `replace_unique_index` per object, categorised `safe`
76+
os migrate apply # no --allow-destructive needed
77+
```
78+
79+
Each object plans as **one pure relaxation**, not as two findings. That matters:
80+
if it read as "composite missing" (safe) plus "old global index orphaned"
81+
(destructive, opt-in), an operator applying only the safe half would keep the
82+
global index — keep the defect — while the plan read as applied. The #8461
83+
`replace_unique_index` arm covers both unchanged (no driver change in this
84+
release), applies CREATE-before-DROP so uniqueness is never unenforced in
85+
between, drops the legacy index only once the replacement is confirmed present,
86+
preserves every row, and converges to no drift.
87+
88+
Two details worth an operator's attention:
89+
90+
- **Both** replacement index names are **hash-suffixed**, because their natural
91+
names are 66 and 90 characters against a 60-character limit:
92+
`uniq_sys_notification_subscription_799a483c` and
93+
`uniq_sys_audience_binding_suggestion_a736dc5a`. On
94+
`sys_audience_binding_suggestion` the legacy name
95+
(`uniq_sys_audience_binding_suggestion_79a05fef`) is hash-suffixed too, so the
96+
two differ only in the hash. That is expected, not corruption.
97+
- Rows with no `organization_id` (platform/seed rows) stay unique **among
98+
themselves**: the organization key part is NULL-safe
99+
(`COALESCE(organization_id, '__global__')`, ADR-0120 D3), so seeding by name
100+
keeps working and a tenant may hold its own row of the same key.
101+
102+
## Not breaking
103+
104+
A relaxation admits key pairs that were previously refused and refuses nothing
105+
that previously succeeded, so no caller that worked before fails now. Every read
106+
path for these two objects goes through the tenant-scoped data API, so no
107+
consumer resolves one of these keys across organizations expecting at most one
108+
row. Shipped as `patch` for that reason — the same call #8556 and #8554 made for
109+
the same shape.
110+
111+
The one published uniqueness claim about either object — "one per package × set ×
112+
anchor" on the permission-sets guide — now reads "one per organization × package
113+
× set × anchor". Neither object's field text made a uniqueness claim, so no
114+
translation bundle changed.

content/docs/permissions/permission-sets.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,9 @@ bit for `everyone` — the platform's own `member_default` baseline is exactly
194194
that shape; the wildcard ban is the stricter `guest` tier's rule.
195195

196196
Pending suggestions are materialized as `sys_audience_binding_suggestion`
197-
rows (one per package × set × anchor, read-only over the data API) and
198-
resolved through the security surface — both installing a package at runtime
199-
and declaring the set in the stack produce them:
197+
rows (one per organization × package × set × anchor, read-only over the data
198+
API) and resolved through the security surface — both installing a package at
199+
runtime and declaring the set in the stack produce them:
200200

201201
```http
202202
GET /api/v1/security/suggested-bindings?status=pending # list (reconciles first)

0 commit comments

Comments
 (0)