|
| 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. |
0 commit comments