Skip to content

fix(plugin-security,plugin-sharing,plugin-webhooks,platform-objects,service-messaging,spec): scope five tenant-scoped declared unique indexes per organization (#8554) - #8599

Merged
os-zhuang merged 4 commits into
mainfrom
claude/issue-8554-unscoped-unique-indexes
Aug 14, 2026
Merged

fix(plugin-security,plugin-sharing,plugin-webhooks,platform-objects,service-messaging,spec): scope five tenant-scoped declared unique indexes per organization (#8554)#8599
os-zhuang merged 4 commits into
mainfrom
claude/issue-8554-unscoped-unique-indexes

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#8554

Five platform objects declared unique: true on a table-level index. On a declared index that is the positional spelling of 'global' — the listed columns verbatim — so each was an installation-wide key on a tenant-scoped object. Fourth act of the class ruled on 2026-08-13, after sys_user_preference / sys_capability (#8461) and sys_position (#8556).

1. The probe came first, per object — and all five reproduced

R2 makes the live probe the required first step: #8468 established the shape for sys_position, not for these five. Predictions were recorded before each run. Real engine (SqlDriver, better-sqlite3), OS_TENANCY_POSTURE=isolated, driving the real shipped declarations imported from source — not hand-copied fixtures — with organization_id derived from resolveInjectedSystemColumns rather than typed in.

objectmaterialized indexpredictionmeasured
sys_permission_setuniq_sys_permission_set_name (name)201 / 409 / 201 / GET 0exactly that
sys_sharing_ruleuniq_sys_sharing_rule_name (name)201 / 409 / 201 / GET 0exactly that
sys_webhookuniq_sys_webhook_name (name)201 / 409 / 201 / GET 0exactly that
sys_email_templateuniq_sys_email_template_name_locale (name, locale)201 / 409 / 201 / GET 0, plus a trailing-column control at 201exactly that
sys_notification_preferenceuniq_sys_notification_preference_user_id_topic_channel (user_id, topic, channel)201 / 409 / 201 / GET 0exactly that
org_jia POST the key → 201
org_yi POST the SAME → 409 UNIQUE_VIOLATION
org_yi POST an unused → 201 ← the control that makes it an ORACLE
org_yi GET the key → total 0 ← refused by a row it cannot see

No object contradicted the static read, so there is no fork to report. One honest note on process: sys_email_template's first probe run answered 500 on every insert — that was my harness (a filler naming body instead of the required body_html, and omitting label), not the object. Fixed and re-run before drawing any conclusion; a 500 is not a 409 and I did not count it as one.

The two composite cases got a second control that varies only a trailing column (locale; channel) and is accepted before and after. That is what proves the installation-wide key was the composite rather than its leading column, and it is pinned.

2. What changed

  • Five declarations respelled to unique: 'organization', spelled explicitly (R1 — no positional default in either direction), each with the measured probe recorded at the declaration.
  • No change to schema-drift.ts — see A1. Verified byte-identical to origin/main by object hash 3a16c6f5e297d81eb63b07781664c6dbddf2a1fe, not by eye.
  • Published text (A3), fixed at source and regenerated — never hand-edited on a generated page:
    • packages/spec/src/security/permission.zod.tsdescribe() → regenerates references/security/permission.mdx
    • packages/spec/src/automation/webhook.zod.tsdescribe() → regenerates references/automation/webhook.mdxand references/integration/connector.mdx, which embeds the same webhook schema. I did not expect the third page; it came out of gen:docs and is correct.
    • sys_permission_set's field description and its clone-dialog helpText (the exact moment an admin types a new name), and sys_webhook's field description.
    • content/docs/automation/webhooks.mdx — hand-written, corrected by hand.
    • Translation bundles in all four shipped locales (see §6, which is a finding).
  • New: one driver-level suite (85 tests) covering all five, and five per-package declaration pins.

3. Ablations: colour predicted, then measured

Both predicted before running, both committed-then-reverted (never stashed), and the reverts proven by hash.

Ablation A — revert only the five declarations to bare true.

predictedmeasured
each package pinRED 3 of 5RED 3 of 5 (×5 packages = 15)
driver suiteGREEN 85/85GREEN 85/85

The driver suite staying green is the point, not a miss: it carries its own copied fixtures and imports no declaration. That is exactly why each package pin asserts "matches the fixture the driver suite copies, entry for entry". And as on #8556, the test named "declares exactly one unique index" stays green under the ablation — true is truthy and the filter still matches. A pin's name is not its reach, which is why the scope assertion is toBe('organization') and never a truthiness check.

Ablation B — revert only #8461's declared-index arm in schema-drift.ts.

Predicted: fresh-database sections stay green; the deployed-installation block goes red; the decisive assertion answers 409 where 201 is expected; and the three negative guards stay green for the wrong reason (they assert emptiness, and an ablated arm returns empty for everything). Predicted 26 red / 59 green.

Measured: 26 red / 59 green, decisive line:

× after applying, BOTH halves hold on the MIGRATED database
→ expected 409 to be 201

With that arm ablated, a fully applied non-destructive migration still answers 409 cross-organization, on all five objects. Every fresh-database test stayed green throughout — a fresh-schema suite cannot see this defect at all.

Both ablations' predictions were correct this time. I flag that rather than claim credit: #8556's review round found its real information in the wrong predictions, and two clean sweeps mean these ablations confirmed a mechanism already understood rather than discovering a new one. The genuinely wrong prediction on this card is in §6.

4. The deployed-installation half is the load-bearing part

For each of the five, the suite builds an installation that already has the old global index plus real rows (two tenant rows and one organization-less platform row), then migrates it. A named harness guard asserts the seeded database really carries the pre-fix index, that it holds 3 rows, and that the defect is live on it — without it the whole block could be exercising a fresh schema with every assertion still passing.

It then pins, per object: exactly one replace_unique_index finding categorised safe, zero orphan drop_index findings, applies without --allow-destructive, keeps every row, converges to no drift, and both halves hold on the migrated database. Also pinned: deploying the new code is not by itself the fixinitObjects is additive, so until the retirement is applied the old index keeps enforcing and the cross-organization create still 409s. The changeset says this to operators in those terms.

Anti-vacuity twins throughout: every "409 flips to 201" has a same-organization duplicate that must still be refused, plus an organization-less pair that must still be refused among themselves. A fix that removed uniqueness instead of scoping it would be a worse defect and is indistinguishable from the real fix by the 201 assertion alone.

One harness fact worth recording: seeding the platform row with the same key as the tenant row made seedDeployed throw a raw UNIQUE violation and took 30 tests red at once — because under the pre-fix global index those two rows genuinely collide. That is the defect, not a harness accident, and the fixture now carries a distinct platformKey with the reason written next to it.

5. PM assumptions

  • A1 — fix(platform-objects,plugin-security,driver-sql): scope sys_user_preference and sys_capability uniqueness per organization (#8323) #8461's arm covers all five unchanged: CONFIRMED, and the composites are the interesting half.legacyUniqueReplacements emits exactly one entry per object, with legacyColumns equal to the whole listed key (not the leading column) for both composites. schema-drift.ts untouched.
    ⚠️sys_notification_preference lands on the name-truncation path, which I flagged in the claim comment before touching anything: its natural replacement name is uniq_sys_notification_preference_organization_id_user_id_topic_channel70 characters, past INDEX_NAME_MAX = 60 — so buildIndexName truncates it to uniq_sys_notification_preference_a22d7d27 while the 54-character legacy name is emitted verbatim. The two therefore differ and the legacyName === replacement.name guard correctly does not fire. This matters because if those two names had ever collapsed to one string, the guard would read the respelling as "nothing was superseded" and emit no migration at all — the declaration would change, a fresh database would look right, and every deployed installation would keep the global index forever. Section 6 of the suite pins it.
  • A2 — no tenancy opt-out, organization_id injected on all five: CONFIRMED, derived from the built value via resolveInjectedSystemColumns (tenancy decl undefined, plan.tenant true, injected set id, organization_id, created_at, created_by, updated_at, updated_by), never a regex over source. Pinned in each package, because if it ever goes false the 'organization' spelling has no column to key on and the fix is silently inert.
  • A3 — published uniqueness claims: CONFIRMED for two of five, absent for three.sys_permission_set (three sites, one of them a generated reference page) and sys_webhook (three sites, two generated pages) carried them. sys_sharing_rule, sys_email_template and sys_notification_preference carry none. All corrected at source; a final sweep confirms no bare uniqueness claim about any of the five survives.
  • A4 — the lint rule: CONFIRMED by execution, and it falsified my own prediction. See §6.

6. What I got wrong, and the sixth and seventh instances

A4 by execution, and my prediction was wrong. I predicted the tenancy-filtered sweep would leave exactly one object (sys_setting, #8555) after my fix. Measured on origin/main: lintUnscopedDeclaredIndexes over 76 loadable definitions gives 41 raw findings, of which 20 — not 6 — are tenant-scoped. My five are correctly gone afterwards, so A4's before/after holds. But the card's stated method (rule verdict × tenancy filter) is not what produced its five-object table; an unstated human judgement about which keys are authored names was applied on top, and that undeclared step is where instances were dropped.

Chasing that down is where the real information was:

Neither is addressed here — R5 bounds this card's scope at exactly five objects.

A second finding, and this one already affects a merged PR. Editing a field's description does not update the translation bundles: node scripts/check-i18n-bundles.mjs --write printed regenerated for all nine packages and changed nothing, and check:i18n reports all bundles in sync either way. That is #8543 (dispatched to another seat), measured here. Its consequence is that #8556's landed source correction left sys_position's en bundle still saying "Unique machine name for the position" on main right now — the corrected source and the shipped English text disagree.

I did not touch the generator (#8543 owns it) and did not touch sys_position (out of scope). For my own objects I followed the bundle header's own documented workflow — "Edit translations in place; re-run extract (with --merge) to fill new gaps. Do not hand-edit the structure — only the leaf string values" — and corrected the leaf values in all four shipped locales. Without that, this PR would have claimed to correct the published text while the text an admin actually reads in Setup still asserted the old contract.

Verification

  • Suites, all green on the merged tree: @objectstack/spec 10520 / 397 files · plugin-security 1084 / 56 · plugin-sharing 574 / 22 · plugin-webhooks 86 / 7 · platform-objects 362 / 19 · service-messaging 224 / 21 · driver-sql 1565 passed + 52 skipped / 97. Typecheck green on all seven.
  • New: 85 driver-level tests, 25 declaration pins.
  • Gates green: check:nul-bytes, check:changeset-gate-self-tests, check:cross-package-test-inputs, check:docs-audit-scope, check:merge-driver, check:objectui-changeset, check:quick-reference-counts, check:role-word, check:spec-parsed-alias, check:test-source-alias, check:type-source-resolution, check:query-options-erasure, check:i18n, check:doc-formula-expressions, check:dev-prereqs, check:adr-0087-registration, check:changeset-no-major, check:empty-changeset, check:authorable-surface, check:docs, check:generated, check:api-surface, check:export-origins.
  • ⚠️check:query-options-erasure went RED and was repaired at the source, not by raising the ceiling. The new driver suite grew the test surface 240 → 245 sites via {} as any on query options. Removing the casts (they were never needed — find/count accept {}) returned it to 240, at the ceiling. ⛔ No ledger was grown anywhere.
  • check:type-check-debt — the --re-measure half first refused to run, naming @objectstack/platform-objects and @objectstack/service-realtime as having type entry points older than their sources ([finding][devx] check:type-check-debt 的 ledger 余量会让新写的 pin 变哑:mongodb 曾有 33 条余量吞掉一次真实回退,另有 5 条目前带 4–19 余量 #6376). It refused rather than measuring through a stale .d.ts, which is the gate working. Built both directly and re-ran: 33 ledger entries re-measured, 1969 raw errors, none above its recorded number, surplus none.
  • The gate list was re-derived from the actual changed paths with scripts/pm/dispatch-gates.mjs, which surfaced check:doc-formula-expressions, check:dev-prereqs, check:merge-driver, check:spec-parsed-alias, check:adr-0087-registration, check:changeset-no-major, check:empty-changeset and check:query-options-erasure — none of which the dispatch brief named, and the last of which was the one that actually failed.
  • origin/main was merged and the entire union plus every suite re-run after the final commit (A green gate union proves nothing about the head it is reported against — nothing ties the run to a commit, so a mid-round union goes stale silently #8550).

Breaking: no — a pure relaxation. Shipped as patch, same call #8556 made for the same shape. ⚠️ But it is not a no-op for operators: the changeset states that os migrate apply is required and why deploying alone is insufficient.


Generated by Claude Code

@vercel

vercelBot commented Aug 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 14, 2026 12:51am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 6 package(s): @objectstack/platform-objects, @objectstack/plugin-security, @objectstack/plugin-sharing, @objectstack/plugin-webhooks, @objectstack/service-messaging, @objectstack/spec.

109 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx(via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx(via @objectstack/spec)
  • content/docs/ai/skills.mdx(via @objectstack/spec)
  • content/docs/api/client-sdk.mdx(via @objectstack/spec)
  • content/docs/api/environment-routing.mdx(via @objectstack/spec)
  • content/docs/api/error-catalog.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx(via @objectstack/spec)
  • content/docs/api/index.mdx(via @objectstack/spec)
  • content/docs/automation/approvals.mdx(via @objectstack/spec)
  • content/docs/automation/connectors.mdx(via @objectstack/spec)
  • content/docs/automation/flows.mdx(via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx(via packages/spec)
  • content/docs/automation/hooks.mdx(via @objectstack/spec)
  • content/docs/automation/index.mdx(via @objectstack/spec)
  • content/docs/automation/webhooks.mdx(via packages/plugins/plugin-webhooks, @objectstack/service-messaging, @objectstack/spec)
  • content/docs/automation/workflows.mdx(via @objectstack/spec)
  • content/docs/concepts/architecture.mdx(via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx(via packages/spec)
  • content/docs/concepts/index.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx(via packages/spec)
  • content/docs/concepts/north-star.mdx(via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx(via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx(via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx(via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx(via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx(via @objectstack/spec)
  • content/docs/data-modeling/index.mdx(via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx(via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx(via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx(via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx(via @objectstack/spec)
  • content/docs/deployment/cli.mdx(via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/deployment/tenancy-modes.mdx(via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx(via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx(via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx(via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx(via @objectstack/spec)
  • content/docs/getting-started/examples.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx(via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/spec)
  • content/docs/kernel/cluster.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx(via @objectstack/spec)
  • content/docs/kernel/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx(via @objectstack/plugin-sharing, @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via @objectstack/plugin-security, @objectstack/plugin-sharing, @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx(via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/plugin-security, @objectstack/plugin-sharing, @objectstack/service-messaging, @objectstack/spec)
  • content/docs/kernel/services.mdx(via @objectstack/spec)
  • content/docs/permissions/access-recipes.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/authorization.mdx(via @objectstack/plugin-security, packages/plugins/plugin-sharing, @objectstack/spec)
  • content/docs/permissions/explain.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/permission-sets.mdx(via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx(via packages/plugins/plugin-security, packages/plugins/plugin-sharing, @objectstack/spec)
  • content/docs/permissions/positions.mdx(via @objectstack/spec)
  • content/docs/permissions/rls.mdx(via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/permissions/system-context.mdx(via packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx(via @objectstack/spec)
  • content/docs/plugins/development.mdx(via @objectstack/spec)
  • content/docs/plugins/index.mdx(via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/plugins/packages.mdx(via @objectstack/platform-objects, @objectstack/plugin-security, @objectstack/plugin-sharing, @objectstack/plugin-webhooks, @objectstack/service-messaging, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx(via @objectstack/spec)
  • content/docs/protocol/diagram.mdx(via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx(via packages/plugins/plugin-sharing, packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx(via @objectstack/spec)
  • content/docs/ui/actions.mdx(via @objectstack/spec)
  • content/docs/ui/apps.mdx(via @objectstack/spec)
  • content/docs/ui/audience-based-interfaces.mdx(via packages/plugins/plugin-security)
  • content/docs/ui/create-vs-edit-form.mdx(via @objectstack/spec)
  • content/docs/ui/dashboards.mdx(via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx(via @objectstack/spec)
  • content/docs/ui/forms.mdx(via @objectstack/spec)
  • content/docs/ui/index.mdx(via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx(via @objectstack/spec)
  • content/docs/ui/setup-app.mdx(via @objectstack/platform-objects, @objectstack/spec)
  • content/docs/ui/translations.mdx(via @objectstack/spec)
  • content/docs/ui/views.mdx(via @objectstack/spec)

7 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/plugin-security, @objectstack/plugin-sharing, @objectstack/plugin-webhooks, @objectstack/service-messaging, @objectstack/spec)
  • content/docs/releases/index.mdx(via @objectstack/spec)
  • content/docs/releases/v12.mdx(via @objectstack/spec)
  • content/docs/releases/v13.mdx(via @objectstack/spec)
  • content/docs/releases/v16.mdx(via @objectstack/spec)
  • content/docs/releases/v17.mdx(via @objectstack/spec)
  • content/docs/releases/v9.mdx(via @objectstack/spec)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added size/xl documentation Improvements or additions to documentation tests tooling labels Aug 14, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 14, 2026 01:10
@os-zhuang
os-zhuang added this pull request to the merge queueAug 14, 2026
Merged via the queue into main with commit b45c71eAug 14, 2026
28 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8554-unscoped-unique-indexes branch August 14, 2026 01:27
qq9340100 pushed a commit that referenced this pull request Aug 14, 2026
Discharges the merge commit's placeholder: node scripts/check-i18n-bundles.mjs
--write over the merged tree. The result carries both sides — main's #8599
source wording and this branch's source-tracking rewrites — as the extractor's
own output, not a hand-picked side. Only the en file changed; the 30
translated-locale bundles were rewritten in place and came back byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jqe56GnYFddggeAyfkZFVz
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Five more instances of the #8323 class: admin- and user-authored names on tenant-scoped objects still carry installation-wide unique indexes

2 participants

@os-zhuang@claude