Skip to content

fix(plugin-security,spec): scope sys_position.name uniqueness per organization (#8468) - #8556

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-8468-sys-position-unique
Aug 13, 2026
Merged

fix(plugin-security,spec): scope sys_position.name uniqueness per organization (#8468)#8556
os-zhuang merged 2 commits into
mainfrom
claude/issue-8468-sys-position-unique

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#8468

sys_position declared { fields: ['name'], unique: true }. On a declared index that is the positional spelling of 'global' — the listed columns verbatim — so name was an installation-wide key on a tenant-scoped object. It is the third instance of the class ruled on 2026-08-13, after sys_user_preference and sys_capability (#8461).

1. The probe came first, and the premise holds

The ruling was premise-bearing: the card was filed from a static read and no live probe had ever been run against this object. Prediction recorded before running: the oracle would reproduce as 201 / 409 / 201.

Real engine (SqlDriver, better-sqlite3), OS_TENANCY_POSTURE=isolated, driving the real shipped declaration loaded from sys-position.object.ts rather than a hand-copied fixture:

CREATE UNIQUE INDEX uniq_sys_position_name on sys_position (name)
org_jia creates "probe_pos_xtenant" → 201
org_yi creates the SAME name → 409 UNIQUE_VIOLATION
org_yi creates an UNUSED name (control) → 201
org_yi GET on that name → total 0

Measured exactly as predicted. The refusal is per-value and the colliding row is invisible to the caller, so it is an existence oracle; the 201 control is what makes it one rather than a blanket refusal. No fork to report.

One correction to the record, in the ruling's favour. Both the card and the triage comment reason about a position hierarchy (parent_id) as the candidate argument for an installation-wide namespace. sys_position has no parent_id. Measured off the built value: has parent_id? false. The object's own header says positions "carry no capability of their own and no hierarchy", and the generated reference page says they are "deliberately flat — no parent, no hierarchy… the mistake ADR-0057 D5 retired and ADR-0090 D3 finalizes." The counter-argument is not outweighed here, it is absent.

2. What changed

  • sys-position.object.ts: unique: 'organization', spelled explicitly (R3 — no positional default in either direction).
  • packages/spec/src/identity/position.zod.ts: the describe() said "Unique position name". That is the source of the generated reference page, so the accident had reached authors as published contract. Now "Position name, unique per organization", and content/docs/references/identity/position.mdx regenerated from it via pnpm gen:docs — the generated file was not hand-edited.
  • The object's own field description and the clone_position dialog's helpText carried the same bare "Unique" claim; both corrected. The clone dialog is the exact moment an admin types a new name, so the scope has to be right there.
  • No change to schema-drift.ts — see A1 below.

3. Ablations: predicted colour, then measured

Both predicted before running, and both committed-then-reverted (never stashed).

Ablation A — revert only the declaration to bare true.

predictedmeasured
plugin-security pinRED, 4 of 7RED, 4 of 7
driver-sql suiteGREEN, 17/17GREEN, 17/17

The driver suite staying green is the point, not a miss: it carries its own fixture and does not import SysPosition. That is precisely why the pin asserts "matches the fixture the driver suite copies, entry for entry" — without it, reverting the shipped declaration would leave the whole driver suite green. Also as predicted, the test named "declares exactly one unique index, on (name)" stayed green under the ablation: true is truthy and the filter still matches. A pin's name tells you what someone intended to test, not what it reaches.

Ablation B — revert only #8461's declared-index arm in schema-drift.ts (the #8323 measurement, re-run for this object).

Predicted: the fresh-database oracle tests stay green, the deployed-installation block goes red, and the decisive assertion answers 409 where 201 is expected. Measured — 5 red, 12 green:

× is planned as ONE pure relaxation, categorised safe expected undefined to be defined
× applies WITHOUT --allow-destructive … and converges expected false to be true
× after applying, BOTH halves hold on the MIGRATED db expected 409 to be 201
× DROP happens only once the replacement is confirmed expected false to be true
× proposes exactly one retirement, keyed on the column expected undefined to match object

expected 409 to be 201 is the #8323 finding reproduced for this object: with the arm ablated, a fully applied non-destructive migration still answers 409 cross-organization. The declaration change alone would have left every deployed installation enumerable while the plan read as applied. Every section-2 test (the fresh-database oracle) stayed green throughout — a fresh-schema suite cannot see this defect at all.

One honest limit: the three negative guards in section 4 also stayed green under Ablation B, for the wrong reason — they assert emptiness, and an ablated arm returns empty for everything.

After both ablations schema-drift.ts was restored and verified byte-identical to origin/main by object hash, not by eye:
git hash-object = 3a16c6f5e297d81eb63b07781664c6dbddf2a1fe = git rev-parse origin/main:packages/drivers/driver-sql/src/schema-drift.ts.

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

sql-driver-sys-position-organization-unique.test.ts builds an installation that already hasuniq_sys_position_name plus real rows (two admin positions and one platform seed), then migrates it. A named harness guard asserts the seeded database really carries the pre-fix index and that the defect is live on it — without that, the whole block could be exercising a fresh schema and every assertion in it would still pass.

It then pins: one replace_unique_index finding categorised safe, zero orphan drop_index findings, applies without --allow-destructive, keeps all 3 rows, 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.

Anti-vacuity twins throughout: every "409 flips to 201" has a same-organization duplicate that must still be refused. 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.

5. PM assumptions

6. The sweep found the class is not closed

The #8468 triage recorded that the earlier sweep had bounded the rest of the platform's objects, so this card would close the class. Running lintUnscopedDeclaredIndexes over all 76 loadable object definitions, cross-referenced against resolveInjectedSystemColumns, that does not hold. Filed unassigned, not fixed here:

Neither is addressed in this PR.

Verification

  • @objectstack/spec 10461 tests / 394 files, @objectstack/plugin-security 1067 / 55, @objectstack/driver-sql 1474 passed + 52 skipped / 95 — all green; typecheck green on all three.
  • New: 17 driver-level tests, 7 declaration pins.
  • Gates green: check:cross-package-test-inputs, check:test-source-alias, check:type-source-resolution, check:spec-parsed-alias, check:docs-audit-scope, check:quick-reference-counts, check:role-word, check:merge-driver, check:changeset-gate-self-tests, check:objectui-changeset, check:nul-bytes, check:query-options-erasure, 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:i18n — first run reported PREREQUISITE NOT MET (the built CLI was missing) and explicitly says that result means nothing. Built the CLI and re-ran: 9 packages, all bundles in sync. Worth running, since this change edits field text in a package that owns an i18n-extract.config.ts.
  • check:type-check-debt — the --re-measure half first refused to run (missing built closure for @objectstack/service-knowledge), which is the dispatch-gates.mjs names check:type-check-coverage for a rationale only check:type-check-debt satisfies — the ratchet half never runs locally #8545 trap. Built the full workspace closure and re-ran: 33 ledger entries re-measured, 1969 raw errors, none above its recorded number, surplus none. No ledger was grown.

Breaking: no — this is a pure relaxation. Shipped as patch.


Generated by Claude Code

…anization (#8468)
The declared index carried bare `unique: true` — the positional spelling of
`'global'` on a DECLARED index — so `name` was an installation-wide key on a
tenant-scoped object. Measured live before the fix (two organizations, same
name): 201 / 409 UNIQUE_VIOLATION / 201 control, with the caller's own GET on
the colliding name returning zero rows. A per-value refusal on an unreadable row
is a cross-tenant existence oracle, and a plain dead end for an admin who simply
wanted to name a position `sales_manager`.
Third instance of the class ruled on 2026-08-13, after sys_user_preference and
sys_capability (#8461). The hierarchy counter-argument does not arise: positions
are deliberately flat (ADR-0090 D3) and this object has no parent_id.
Also corrects the published text — the spec `describe()` said "Unique position
name" and the reference page is generated from it, so the accident had reached
authors as contract — and regenerates the page from source.
Migration reuses #8461's `replace_unique_index` arm unchanged: one finding,
categorised safe, CREATE before DROP.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
@vercel

vercelBot commented Aug 13, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 13, 2026 7:53pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-security, @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 @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/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/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/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, @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, @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/plugin-security, @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/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/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/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 documentation Improvements or additions to documentation tests tooling labels Aug 13, 2026
… guarantees (#8468)
PM review Q2: the "matches the fixture the driver suite copies" pin claimed the
two copies "cannot drift apart silently". It guards ONE direction — shipped
declaration moves, driver fixture does not. A driver-side edit is unguarded,
because nothing compares the pin's literal to FIXED_APP. Both comments now say
which direction is covered and which is not, rather than implying the loop is
closed. Comment-only; no assertion changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

Both addressed. Q1 turned out to be worth measuring rather than answering — two of my three predictions were wrong, and the reason is the interesting part.

Q1 — the negative guards: coverage exists, but not where I assumed

You asked whether an ablation exists that makes the arm over-claim. I ran them rather than reasoning about it. Each removes one guard from legacyUniqueReplacements and runs only this PR's driver suite; schema-drift.ts restored between each.

ablationguard removedpredictedmeasured
C1the explicitly-named-index guardREDREDclaims nothing for an EXPLICITLY NAMED index
C2legacyName === replacement.name (S6)REDGREEN 17/17
C3let bare true through the spelling filterREDGREEN 17/17

So C1 is genuinely single-guard-discriminating: that negative test is a positive control, and it goes red the moment the guard it describes is deleted. Coverage confirmed here, and #8461's equivalent test has the identical shape, so it holds upstream too.

C2 and C3 staying green was not a coverage hole in the test — it is defence in depth, which I then confirmed by double- and triple-ablating:

ablationguards removedmeasured
D1S6 same-name +declaredNames.has(legacyName) (#3955)RED — the S6 test
D2bare-spelling filter + S6 same-nameGREEN 17/17
D3bare-spelling filter + S6 same-name +#3955RED — the S6 test and the bare-spelling test

Mechanism, now measured rather than argued:

The answer to your actual question: covered, no action needed. The failure mode that threatens sys_team / sys_business_unit / sys_member is guard removal, and the S6 test does go red once the redundancy is exhausted — the protection is really pinned, it is just not attributable to a single line. What no test can do is tell you which guard is doing the work, and I would rather record that than leave my earlier one-line caveat implying the guards are untested.

That also sharpens my Ablation B caveat in the PR body, which was correct but for an incomplete reason. I said the negative guards "assert emptiness, and an ablated arm returns empty for everything" — true, but the deeper reason those tests are hard to move is that two of the three inputs are redundantly guarded, so even a targeted single-guard ablation leaves them green.

schema-drift.ts is byte-identical to origin/main after all six ablations — git hash-object = 3a16c6f5e297d81eb63b07781664c6dbddf2a1fe = git rev-parse origin/main:packages/drivers/driver-sql/src/schema-drift.ts. No ledger touched, no stash, no force-push, protocol.ts untouched.

Q2 — the comment now says what the pin delivers

You are right, and the overclaim was on both copies, not just the pin. Fixed in ad0d564c (comment-only; no assertion changed, both suites still 7/7 and 17/17).

The pin now names the two directions explicitly — caught: "the shipped declaration changes and the driver fixture does not… measured: reverting the declaration to bare true leaves the entire driver suite green at 17/17"; not caught: "the DRIVER fixture is edited and this declaration is not. Nothing compares the literal below to FIXED_APP… A driver-side edit drifts silently and this test cannot see it."

The driver-side comment had made the same claim from the other end ("so this fixture and the real metadata cannot drift apart silently"), which was the more misleading of the two since it sits directly above the unguarded copy. It now carries the warning instead, telling the next reader to treat FIXED_APP as hand-maintained and to change it only alongside the declaration.

I did not close the loop by importing across the package boundary, per your instruction and #8461's precedent.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 13, 2026 22:07
@os-zhuang
os-zhuang added this pull request to the merge queueAug 13, 2026
Merged via the queue into main with commit 6b441a8Aug 13, 2026
28 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8468-sys-position-unique branch August 13, 2026 22:24
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-zhuang@claude