Repository navigation
sys_position.name is the third instance of the #8323 class: an admin-authored name on a tenant-scoped RBAC object carries an installation-wide unique index #8468
Description
Activity
- addedbugSomething isn't workingSomething isn't working
on Aug 13, 2026 Decision card — this may be a third instance of the #8323 release blocker
Labelled
bug+security+needs-user-decision,domain:metadata. Filed by the #8323 dev from a sweep it ran while fixing the two objects the maintainer's ruling named.⚠️ What is and is not establishedEstablished:
sys_position.namecarries{ fields: ['name'], unique: true }with no tenancy opt-out andmanagedBy: 'config'— structurally identical to thesys_capabilityinstance just fixed in PR #8461. Under the semantics that PR documents, a declared index's bareunique: trueis the positional spelling of'global', so on a tenant-scoped object it materializes an installation-wide unique index.NOT established: that the cross-tenant enumeration actually reproduces here. This is structural identity read from source, not a measured 409-vs-201 oracle, and the report says so plainly. ⛔ Do not treat it as a confirmed exploit until someone reproduces it.
So the first step is a measurement, not a ruling — does the #8323 §1 reproduction (409 for a value another organization already holds, 201 otherwise) fire on
sys_position? If it does not, this drops to a tidiness question and can leave the decision box.Why it needs a ruling and not just a fix
sys_capabilityhad no published uniqueness claim — the dev checked the wholecontent/docstree and found none, which is why respelling it was uncontroversial.sys_positionis the opposite:references/identity/position.mdx:64publishes "Unique position name". So installation-wide uniqueness may be intended and documented rather than accidental, and the correct fix could go either direction:- respell to
unique: 'organization'— matching what fix(platform-objects,plugin-security,driver-sql): scope sys_user_preference and sys_capability uniqueness per organization (#8323) #8461 just did for its two siblings; or - make
'global'explicit — keeping today's behaviour but stating it deliberately, so the lint rule stops flagging it and the next sweep does not re-raise it.
No ADR settles which. That is the decision.
The scoping question the maintainer actually owns
The 2026-08-13 ruling chose option 2 — "the platform's own objects change to explicit organization scoping" — and named
sys_user_preferenceandsys_capability. It separately accepted the §1 oracle on customer-authored bare-uniqueobjects as the documented cost of'global'uniqueness until v18.sys_positionis a platform object, not customer-authored, so it falls on the first side of that split by category while sitting outside the two objects the ruling enumerated. Does the ruling extend to a third platform object the sweep found, or was the enumeration exhaustive by intent? I am not answering that on your behalf.What makes this actionable rather than an open worry
The same sweep bounded the rest of the platform's objects, which is what turns one observation into a closed set:
- legitimately installation-wide —
sys_session.token,sys_api_key.key, the oauth and device-code tokens; - already hand-write the organization composite —
sys_team,sys_business_unit,sys_member(the ADR-0120 S6 spelling, valid indefinitely); - the one open case — this card.
So whatever is ruled here closes the class for the platform's own metadata rather than leaving a tail.
Decision box for
domain:metadatais now four: #8284, #8376, #8459, #8468.
Generated by Claude Code
- respell to
Triage — four-prism card face + recommendation. Kept in the decision box rather than absorbed into the parent ruling; the reason is stated at the end, because it is close.
- Platform long-term coherence — structurally identical to the pair already ruled a defect on 2026-08-13:
managedBy: 'config', no tenancy opt-out (soorganization_idis injected), admin-authored content, bareunique: trueon a declared index = the positional spelling of installation-wide. Treating the third instance differently needs a positive reason. The hierarchy argument (parent_id) is the candidate — but hierarchy does not imply a shared namespace; the neighbouring tenant-scoped objects already carry organization composites and are no less structured. - Measured business pull — same shape as the ruled two: an organization can POST a position name and read 409-vs-201 to learn whether another organization holds it, while its own read of that name returns nothing. That is a cross-tenant enumeration oracle plus a functional dead end (cannot create the name, cannot see why).
⚠️ Not measured live here — the card is explicit that no 409/201 probe was run against this object. - AI-agent error-resistance — bare
unique: trueon a tenant-scoped object is precisely the spelling that states no scope at all, and the generated reference docs currently assert the strong reading as if intended (describe()says "Unique position name"), so the accident has already propagated into published text an AI author will read as contract. Whichever way this is ruled, the scope must end up spelled explicitly — that is the half of the fix that is not in question. - Startup scope discipline — both directions are one word plus a small follow-on: per-organization reuses the
replace_unique_indexmigration already generalized to declared indexes by the parent fix (machinery in place, free); installation-wide costs an explicit'global'spelling plus adescribe()correction. The expensive outcome is neither — leaving a positional accident that the v18 train will have to re-litigate.
Recommendation: per-organization (inherit the parent ruling), with two conditions on the implementing card: run the live 409/201 probe first and report a fork if it contradicts the static read; and spell the resulting scope explicitly in both directions rather than relying on positional defaults, correcting the
describe()and therefore the generated reference page in the same PR.Why this came to you at all — the standing meta-criterion says a sibling branch of an already-ruled family inherits the parent ruling together with its reasoning, and re-opens only on a real semantic difference. The parent's reasoning leaned on an explicit ADR statement that admins EXTEND a platform registry; the filer looked for the equivalent statement for positions and did not find one. So the inheritance test lands on "is a single installation-wide position taxonomy intended?" — a product-semantics question about an RBAC vocabulary, which is yours. If your reading is that the parent ruling simply carries (my recommendation is that it does), say so and this becomes a queue card immediately, and the same answer will cover any fourth instance without another trip to the inbox.
Generated by Claude Code
- Platform long-term coherence — structurally identical to the pair already ruled a defect on 2026-08-13:
Maintainer ruling — the parent ruling carries:
sys_position.namebecomes per-organizationRuled in a live PM session, 2026-08-13, accepting the triage seat's recommendation (maintainer verbatim: 「同意」). Recorded by the triage seat as a ruling record.
Ruling. This is a sibling branch of the already-ruled installation-wide-unique class, and the parent ruling carries together with its reasoning: an admin-authored name on a tenant-scoped object is scoped per organization. A position hierarchy does not imply a shared namespace, so the semantic difference that would have justified re-opening the family is not present.
Standing consequence — this settles the family, not just this card: any further instance matching the same shape (tenant-scoped object, no tenancy opt-out, admin-authored content, bare
unique: trueon a declared index) inherits this answer and goes straight to the lane queue. ⛔ Do not bring a fourth instance to the decision box; bring it only if it carries a genuine semantic argument for an installation-wide namespace, and state that argument explicitly.This ruling is premise-bearing — the premise is falsifiable and must be verified before the fix:
- Premise: the static read is correct — the declared bare
unique: truematerializes as an installation-wide unique index on this object, reproducing the 409-vs-201 cross-tenant oracle. The card is explicit that no live probe was run here. - Required first step: run the live 409/201 probe against
sys_positionon a real engine, two organizations, same name. - ⛔ If the probe contradicts the static read, STOP and report the fork on this card. Do not proceed to the migration, and do not silently switch to the other spelling. A ruling whose premise fails goes back to the maintainer, it does not get reinterpreted by the implementer.
Scope once the premise holds:
- Scope the uniqueness per organization, reusing the
replace_unique_indexmigration already generalized to declared indexes by the sibling fix. - Spell the scope explicitly. Leaving any positional default in place is not an acceptable end state on this object, in either direction — that half was never in question.
- Correct the field's
describe()("Unique position name") and therefore the generated reference page in the same PR; the published text currently asserts installation-uniqueness as if it were intended, which is how the accident reaches authors as contract.
State:
needs-user-decision→pm:queue,securityretained. Blocked-by: none (checked live — the sibling fix that generalized the migration machinery has landed). Serial note for the metadata seat: verify at claim time that nothing in flight is touchingsys-position.object.tsor the same migration registry region.
Generated by Claude Code
- Premise: the static read is correct — the declared bare
Claim
Claiming this card for implementation.
- Session:
session_012WMpuAfA2KSdDjGF6tm1bH - Branch:
claude/issue-8468-sys-position-unique - Worktree:
../objectstack-issue-8468
Serial check at claim time:
git worktree listshows one other live worktree,objectstack-issue-8470(branchclaude/issue-8470-capability-seed-lookup) — the sibling capability card. I will not touchsys-capability.object.ts, thebootstrap-*capabilities*files, orpackages/metadata-protocol/src/protocol.ts.Per the maintainer ruling of 2026-08-13 14:30, my first step is the live 409/201 probe against
sys_positionon a real engine with two organizations and the same name. If it contradicts the static read I will stop and report the fork here rather than proceeding to the migration.
Generated by Claude Code
- Session:
Probe result — the ruling's premise HOLDS
The required first step is done. Prediction was recorded before running: I predicted the oracle would reproduce (201 / 409 / 201). It did, exactly.
Real engine (
SqlDriver, better-sqlite3),OS_TENANCY_POSTURE=isolated,OS_MULTI_ORG_ENABLED=true, driving the real shipped declaration loaded fromsys-position.object.ts— not a hand-copied fixture.════ materialized indexes on sys_position ════ uniq_sys_position_name unique=1 origin=c cols=[name] DDL: CREATE UNIQUE INDEX `uniq_sys_position_name` on `sys_position` (`name`) ════ THE LIVE 409/201 PROBE — two organizations, same 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 0The refusal is per-value and the colliding row is invisible to the caller — the #8323 §1 existence oracle, reproduced. No fork to report; I am proceeding to the migration per the ruling.
A2 confirmed, derived from the built value (
resolveInjectedSystemColumns, not a source grep):tenancydecl isnull,systemFieldsdecl isnull,plan.tenant === true, and the injected set isid, organization_id, created_at, created_by, updated_at, updated_by.organization_idis injected.One correction to the record, in the ruling's favour
Both the card and the triage comment reason about a position hierarchy — the card says "positions carry a hierarchy (
parent_id)", and the triage comment treats that as the one candidate semantic argument for an installation-wide namespace before rejecting it.sys_positionhas noparent_idfield. Measured off the built value:has parent_id? false; the authored fields arelabel, name, description, permissions, active, is_default, delegatable, managed_by, id, created_at, updated_at. The object's own header comment says positions "carry no capability of their own and no hierarchy — the visibility tree lives onsys_business_unit", and the generated reference page opens with "Positions are deliberately flat — noparent, no hierarchy… re-adding a second tree here is the mistake ADR-0057 D5 retired and ADR-0090 D3 finalizes."So the counter-argument the ruling weighed and rejected was not merely outweighed — it is factually absent. This strengthens the ruling; nothing about it changes the outcome.
Generated by Claude Code
{ "issue": 8468, "status": "done", "branch": "claude/issue-8468-sys-position-unique", "pr": "https://github.com/objectstack-ai/objectstack/pull/8556", "premise_still_valid": true, "summary": "PROBE FIRST, AND IT CONFIRMED THE PREMISE. Prediction recorded before running (201/409/201); measured exactly that on a real SqlDriver under OS_TENANCY_POSTURE=isolated, driving the REAL shipped declaration rather than a hand-copied fixture: uniq_sys_position_name materialized over ['name'] verbatim; org_jia 201, org_yi same name 409 UNIQUE_VIOLATION, org_yi unused name 201, org_yi GET on the colliding name total 0. No fork to report. Respelled to unique:'organization' with the scope spelled explicitly, corrected the spec describe() that generates the reference page (plus the object's own field description and the clone_position helpText), and regenerated content/docs/references/identity/position.mdx via pnpm gen:docs — the generated file was never hand-edited. schema-drift.ts is UNTOUCHED: #8461's arm covered this object unchanged, so R5 really was free. ONE CORRECTION TO THE RECORD, in the ruling's favour: both the card and the triage comment weigh a position hierarchy (parent_id) as the candidate argument for an installation-wide namespace — sys_position has no parent_id at all (measured off the built value; ADR-0090 D3 makes positions deliberately flat). The counter-argument is not outweighed, it is absent. SEPARATELY: the card's claim that this closes the class is FALSE — running the real lint rule over all 76 loadable object definitions found five more clean inheritors plus one judgement call, filed as #8554 and #8555.", "tests": "NEW: driver-sql/src/sql-driver-sys-position-organization-unique.test.ts 17 tests; plugin-security/src/objects/sys-position.organization-unique.test.ts 7 tests. FULL SUITES (all green): @objectstack/spec 10461 tests/394 files; @objectstack/plugin-security 1067/55; @objectstack/driver-sql 1474 passed + 52 skipped/95. Typecheck green on all three. ABLATION A (revert only the declaration to bare true) — predicted 'pin RED 4/7, driver suite GREEN 17/17'; measured exactly that. The driver suite staying green is the finding, not a miss: it carries its own fixture, which is why the pin asserts 'matches the fixture the driver suite copies'. As predicted the test named 'declares exactly one unique index, on (name)' stayed GREEN under ablation because true is truthy — a pin's name is not its reach. ABLATION B (revert only #8461's declared-index arm in schema-drift.ts) — predicted 'fresh-DB oracle tests stay green, deployed-installation block goes red, decisive assertion answers 409 where 201 expected'; measured 5 red / 12 green with 'after applying, BOTH halves hold on the MIGRATED database: expected 409 to be 201'. That is the #8323 finding reproduced here: with the arm ablated a FULLY APPLIED non-destructive migration still answers 409 cross-organization. Honest limit: the three negative guards stayed green for the wrong reason (they assert emptiness, and an ablated arm returns empty for everything). schema-drift.ts restored and proven byte-identical to origin/main by object hash 3a16c6f5e297d81eb63b07781664c6dbddf2a1fe (not by eye). Both ablations used commit-then-revert; never git stash. GATES green: cross-package-test-inputs, test-source-alias, type-source-resolution, spec-parsed-alias, docs-audit-scope, quick-reference-counts, role-word, merge-driver, changeset-gate-self-tests, objectui-changeset, nul-bytes, query-options-erasure, adr-0087-registration, changeset-no-major, empty-changeset, authorable-surface, docs, generated, api-surface, export-origins. TWO GATES NEEDED A PREREQUISITE BUILD AND SAID SO RATHER THAN LYING: check:i18n first reported PREREQUISITE NOT MET (unbuilt CLI, 'Nothing was checked') — built the CLI, re-ran, 9 packages all bundles in sync; this one MATTERS here because the change edits field text in a package owning an i18n-extract.config.ts. check:type-check-debt's --re-measure half refused to run for a missing built closure (@objectstack/service-knowledge) — the #8545 trap — so I 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 anywhere. Gate list re-derived from actual changed paths with scripts/pm/dispatch-gates.mjs; that derivation surfaced check:i18n, check:role-word, check:quick-reference-counts and check:docs-audit-scope, which the dispatch prompt did not name.", "open_questions": [], "out_of_scope_findings": [ "filed as #8554: five MORE instances of the #8323 class the card said was closed — sys_permission_set.name, sys_sharing_rule.name, sys_webhook.name, sys_email_template.(name,locale), sys_notification_preference.(user_id,topic,channel). All tenant-scoped with organization_id injected and bare unique:true on a declared index over authored content. Labelled pm:queue per the ruling's standing consequence. sys_permission_set is the third leg of the same ADR-0090 RBAC triad and sits in the very directory this PR edits.", "filed as #8555: sys_setting.(namespace,key,scope,user_id) is installation-wide on a tenant-scoped object, but is engine-owned with all writes through SettingsService, so it MAY genuinely want a global namespace depending on what `scope` encodes. Filed WITHOUT pm:queue and with the semantic argument stated explicitly, exactly as the ruling requires for a non-inheriting instance. Also records the sweep's negative triage (tokens/id-composites and ADR-0120 S6 hand-written composites) so a future sweep need not re-derive it.", "observation, no issue filed: the card and its triage both assert sys_position 'carries a hierarchy (parent_id)'. It does not — there is no parent_id field. Reported here and pinned as a test rather than filed, since it strengthens rather than changes the ruling." ] }
Generated by Claude Code
- added a commit that references this issue
on Aug 13, 2026 Updated report — supersedes the earlier
os-dev-reportcomment on this card. Same status and PR; thetestsfield now includes the review round (six additional targeted ablations answering PM Q1, and a comment correction answering Q2, pushed asad0d564c).{ "issue": 8468, "status": "done", "branch": "claude/issue-8468-sys-position-unique", "pr": "https://github.com/objectstack-ai/objectstack/pull/8556", "premise_still_valid": true, "summary": "PROBE FIRST, AND IT CONFIRMED THE PREMISE. Prediction recorded before running (201/409/201); measured exactly that on a real SqlDriver under OS_TENANCY_POSTURE=isolated, driving the REAL shipped declaration rather than a hand-copied fixture: uniq_sys_position_name materialized over ['name'] verbatim; org_jia 201, org_yi same name 409 UNIQUE_VIOLATION, org_yi unused name 201, org_yi GET on the colliding name total 0. No fork to report. Respelled to unique:'organization' with the scope spelled explicitly, corrected the spec describe() that generates the reference page (plus the object's own field description and the clone_position helpText), and regenerated content/docs/references/identity/position.mdx via pnpm gen:docs — the generated file was never hand-edited. schema-drift.ts is UNTOUCHED: #8461's arm covered this object unchanged, so R5 really was free. ONE CORRECTION TO THE RECORD, in the ruling's favour: both the card and the triage comment weigh a position hierarchy (parent_id) as the candidate argument for an installation-wide namespace — sys_position has no parent_id at all (measured off the built value; ADR-0090 D3 makes positions deliberately flat). The counter-argument is not outweighed, it is absent. SEPARATELY: the card's claim that this closes the class is FALSE — running the real lint rule over all 76 loadable object definitions found five more clean inheritors plus one judgement call, filed as #8554 and #8555.", "tests": "NEW: driver-sql/src/sql-driver-sys-position-organization-unique.test.ts 17 tests; plugin-security/src/objects/sys-position.organization-unique.test.ts 7 tests. FULL SUITES (all green): @objectstack/spec 10461 tests/394 files; @objectstack/plugin-security 1067/55; @objectstack/driver-sql 1474 passed + 52 skipped/95. Typecheck green on all three. ABLATION A (revert only the declaration to bare true) — predicted 'pin RED 4/7, driver suite GREEN 17/17'; measured exactly that; the driver suite staying green is the finding, not a miss, and is why the fixture pin exists. As predicted the test named 'declares exactly one unique index, on (name)' stayed GREEN under ablation because true is truthy. ABLATION B (revert only #8461's declared-index arm) — predicted 'fresh-DB tests stay green, deployed-installation block goes red, decisive assertion answers 409 where 201 expected'; measured 5 red / 12 green with 'after applying, BOTH halves hold on the MIGRATED database: expected 409 to be 201' — the #8323 finding reproduced: with the arm ablated a FULLY APPLIED non-destructive migration still answers 409 cross-organization. REVIEW ROUND (PM Q1: do the negative guards have positive controls?) — measured with six further targeted ablations instead of asserted, and TWO OF MY THREE PREDICTIONS WERE WRONG. C1 (remove the explicitly-named-index guard) → RED as predicted, so that negative test IS a real positive control. C2 (remove legacyName===replacement.name) → predicted RED, measured GREEN 17/17. C3 (let bare true through the spelling filter) → predicted RED, measured GREEN 17/17. Cause, then confirmed by double/triple ablation: those two inputs are redundantly guarded. D1 (S6 guard + #3955 declaredNames guard) → RED on the S6 test; D2 (bare filter + S6 guard) → still GREEN; D3 (bare filter + S6 guard + declaredNames guard) → RED on both the S6 and bare-spelling tests. So the S6 composite is double-guarded and the bare spelling triple-guarded: coverage EXISTS and the protection for sys_team/sys_business_unit/sys_member is genuinely pinned, but no test can attribute it to a single line. Answer to Q1: covered, no action. schema-drift.ts byte-identical to origin/main after all six ablations, verified by object hash 3a16c6f5e297d81eb63b07781664c6dbddf2a1fe. All ablations used commit-then-revert; never git stash. GATES green: cross-package-test-inputs, test-source-alias, type-source-resolution, spec-parsed-alias, docs-audit-scope, quick-reference-counts, role-word, merge-driver, changeset-gate-self-tests, objectui-changeset, nul-bytes, query-options-erasure, adr-0087-registration, changeset-no-major, empty-changeset, authorable-surface, docs, generated, api-surface, export-origins. TWO GATES NEEDED A PREREQUISITE BUILD AND SAID SO RATHER THAN LYING: check:i18n first reported PREREQUISITE NOT MET (unbuilt CLI, 'Nothing was checked') — built the CLI, re-ran, 9 packages all bundles in sync; this MATTERS here because the change edits field text in a package owning an i18n-extract.config.ts. check:type-check-debt's --re-measure half refused to run for a missing built closure (@objectstack/service-knowledge) — the #8545 trap — so I 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 anywhere. Gate list re-derived from actual changed paths with scripts/pm/dispatch-gates.mjs, which surfaced check:i18n, check:role-word, check:quick-reference-counts and check:docs-audit-scope that the dispatch prompt did not name.", "open_questions": [], "out_of_scope_findings": [ "filed as #8554: five MORE instances of the #8323 class the card said was closed — sys_permission_set.name, sys_sharing_rule.name, sys_webhook.name, sys_email_template.(name,locale), sys_notification_preference.(user_id,topic,channel). All tenant-scoped with organization_id injected and bare unique:true on a declared index over authored content. Labelled pm:queue per the ruling's standing consequence. sys_permission_set is the third leg of the same ADR-0090 RBAC triad and sits in the very directory this PR edits.", "filed as #8555: sys_setting.(namespace,key,scope,user_id) is installation-wide on a tenant-scoped object, but is engine-owned with all writes through SettingsService, so it MAY genuinely want a global namespace depending on what `scope` encodes. Filed WITHOUT pm:queue and with the semantic argument stated explicitly, exactly as the ruling requires for a non-inheriting instance. Also records the sweep's negative triage (tokens/id-composites and ADR-0120 S6 hand-written composites) so a future sweep need not re-derive it.", "observation, no issue filed: the card and its triage both assert sys_position 'carries a hierarchy (parent_id)'. It does not — there is no parent_id field. Reported on the card and pinned as a test rather than filed, since it strengthens rather than changes the ruling.", "observation from the review round, no issue filed: the #8461 declared-index arm's guards are redundant with each other — the S6 composite is caught by BOTH `legacyName === replacement.name` AND the #3955 `declaredNames` guard, and the bare spelling by three mechanisms. Measured, not inferred (D1/D2/D3 above). Defence in depth rather than a defect, so nothing filed, but it means a future refactor could delete one of those guards with every test still green." ] }
Generated by Claude Code
Landed —
6b441a84onorigin/main(PR #8556, squash-merged, 22:07:59Z). Confirmed by both readings: the merge event, and the commit present onorigin/main.The premise-bearing ruling was honoured in the order it specified
The probe ran first, with the prediction recorded before it: 201 / 409 / 201. Measured exactly that on a real
SqlDriverunderOS_TENANCY_POSTURE=isolated, driving the real shipped declaration loaded fromsys-position.object.tsrather than a hand-copied fixture —org_jiacreatesprobe_pos_xtenant→ 201;org_yicreates the same name → 409UNIQUE_VIOLATION;org_yicreates an unused name → 201;org_yi's own GET on the colliding name → 0 rows. A per-value refusal on a row the caller cannot read, with the 201 control proving it is an oracle rather than a blanket refusal. No fork to report.A correction to this card and to its triage
Both the card body and the triage comment reason about a position hierarchy (
parent_id) as the candidate argument for an installation-wide namespace — the triage explicitly weighed it and set it aside.sys_positionhas noparent_id. Measured off the built value. The object's own header says positions "carry no capability of their own and no hierarchy", and the generated reference page calls them "deliberately flat — noparent, no hierarchy… the mistake ADR-0057 D5 retired and ADR-0090 D3 finalizes."The counter-argument was not outweighed; it was absent. The ruling stands and is better supported than it was. The absence is now pinned as a test, so a
parent_idappearing later turns something red at exactly the moment someone would want to revisit this.The migration half is what made the fix real
Ablation B — reverting only #8461's declared-index arm in
schema-drift.ts— reproduced the #8323 finding for this object: 5 red / 12 green, the decisive one being× after applying, BOTH halves hold on the MIGRATED database → expected 409 to be 201With that arm ablated, a fully applied non-destructive migration still answers 409 cross-organization. Every fresh-database test stayed green throughout — a fresh-schema suite cannot see this defect at all. The deployed-installation block therefore builds a database that already carries
uniq_sys_position_nameplus real rows, and a named harness guard asserts the seeded database really has the pre-fix index and the defect is live on it, so the block cannot silently be exercising a fresh schema.Also pinned: deploying the new code is not by itself the fix —
initObjectsis additive, so until the retirement is applied the old index keeps enforcing.schema-drift.tsis untouched; #8461's arm covered this object unchanged, so R5 really was free. Bothdescribe()and the generatedcontent/docs/references/identity/position.mdxwere corrected in the same PR, regenerated from source rather than hand-edited.⚠️ The class is NOT closed — the triage's expectation was wrongThe triage recorded that the earlier sweep had bounded the rest of the platform's objects, so this card would close the class. Running the real lint rule over all 76 loadable object definitions, cross-referenced against
resolveInjectedSystemColumns, it does not:- Five more instances of the #8323 class: admin- and user-authored names on tenant-scoped objects still carry installation-wide unique indexes #8554 — five more clean inheritors (
sys_permission_set,sys_sharing_rule,sys_webhook,sys_email_template,sys_notification_preference), queued per this ruling's standing consequence rather than sent to the decision box.sys_permission_setis the third leg of the same ADR-0090 RBAC triad and sits in the directory this PR edits. sys_setting's unique key is installation-wide on a tenant-scoped object — but unlike the #8323 class it has a real argument for staying that way #8555 —sys_setting, filed with its semantic argument stated. Relabelledpm:queueby the lane PM: the argument it carries is contingent on an unmeasured fact (whatscopemeans toSettingsService), and both branches resolve without the maintainer once that reading is done. If the reading fails to settle it, it comes back as a decision.
Residue
- finding: the declared-index replacement arm's guards are redundantly covered, so any single one can be deleted with every test still green #8557 — the declared-index arm's guards are redundantly covered, so any single one can be deleted with every test still green. Measured by six further ablations, two of whose three single-guard predictions were wrong.
Generated by Claude Code
- Five more instances of the #8323 class: admin- and user-authored names on tenant-scoped objects still carry installation-wide unique indexes #8554 — five more clean inheritors (
- added a commit that references this issue
on Aug 14, 2026
Found while implementing #8323 (option 2), sweeping the docs for uniqueness claims at the PM's request. Not fixed there — the ruling of 2026-08-13 named exactly two objects, and this is a third. Filed for triage rather than folded in.
The declaration
packages/plugins/plugin-security/src/objects/sys-position.object.ts:Structurally identical to the
sys_capabilityinstance #8323 just fixed:sys_capability(fixed in #8461)sys_position{ fields: ['name'], unique: true }{ fields: ['name'], unique: true }tenancyopt-outorganization_idorganization_idmanagedBy: 'config', admins extend in SetupmanagedBy: 'config', admins author positionsOn a DECLARED index bare
unique: trueis the positional spelling of'global'— the listed columns verbatim — sonameis an installation-wide key on a tenant-scoped object. That is the #8323 §1 shape exactly: an organization can POST a position name and read 409-vs-201 to learn whether another organization already holds it, while its own read of that name returns nothing.The reference docs assert the strong reading as though it were intended —
content/docs/references/identity/position.mdx:name… "Unique position name (lowercase snake_case)". That line is generated from the field'sdescribe(), so the spec text says installation-unique too.What I did and did not establish
organization_idis injected), and that the declared-index bare spelling materializes verbatim — the same mechanism measured and pinned in fix(platform-objects,plugin-security,driver-sql): scope sys_user_preference and sys_capability uniqueness per organization (#8323) #8461.sys_position, and — the real question — whether installation-wide position names are intended. Unlike capabilities, positions carry a hierarchy (parent_id), and it is at least arguable that a deployment wants one global position taxonomy. tenant-scoped objects get GLOBAL unique indexes: 409-vs-201 enumerates other tenants' values, and a user's preferences silently stop persisting in their second org #8323'ssys_capabilityhad ADR-0066 D1's "admins EXTEND the registry" to settle intent; I did not find the equivalent statement for positions, so I am not asserting the answer.That intent question is why this is a separate card and not a line in #8461: if per-organization is right the fix is one word plus the same
replace_unique_indexmigration (already generalized to declared indexes in #8461, so the migration machinery is in place and free); if installation-wide is right the correct fix is the opposite — spell itunique: 'global'explicitly so the intent stops being a positional accident, and correct thedescribe().Either way the current state is the one spelling ADR-0120 says states no scope at all, on a tenant-scoped object, which
unique/unscoped-declared-indexalready warns about.Related
sys_user_preferenceandsys_capability.true→'global'plus a loud refusal); whichever way this card is decided, stating the scope explicitly is what that train wants.unique: trueon a tenant-scoped object under group/isolated postures — point authors at the explicit 'organization' | 'global' choice #8379 — the publish-time advisory that would have caught this at authoring time.I also swept the other platform objects while here. Most bare declared uniques are legitimately installation-wide (
sys_session.token,sys_api_key.key,sys_oauth_*.token,sys_device_code.*) and several already hand-write the organization composite (sys_team['name','organization_id'],sys_business_unit['code','organization_id'],sys_member['organization_id','user_id']— the ADR-0120 S6 legacy spelling, valid indefinitely).sys_positionis the one that looked like the fixed pair rather than like either of those groups.Filed unassigned. Found by session
session_012WMpuAfA2KSdDjGF6tm1bH.Generated by Claude Code