Repository navigation
Commit ef1fcb2
feat(spec)!: refuse bare unique: true on a declared index at protocol 18 — stated scope, zero-drift conversion (ADR-0120 D2/D5a/D7) (#22103)
Fixes #5082
Clause-②: no (narrowing: bare `unique: true` on a declared index stops
being accepted at validate / publish, and `VISIBILITY_STRICT_OPTIONS`
leaves `@objectstack/spec`; nothing widens)
The protocol-18 half of ADR-0120 (D2, D5a, D7), plus the export item
folded into this card. On a declared index, bare `unique: true` is
refused with a prescription. Stored and built metadata converts it to
`'global'`, which is the same physical index. Every in-repo author moves
to the explicit spelling.
## What changes
**The refusal (D5a), on every door an author's declared index reaches.**
- `IndexSchema.unique` is now `false | 'global' | 'organization'`. Bare
`true` is refused (`invalid_union`, path `unique`) with its own
prescription. The prescription names `'global'` (installation-wide, the
exact index bare `true` built) and `'organization'` (one holder per
organization), says field-level `unique: true` is unaffected, and ends
with the house `os migrate meta --from 17` sentence. `tsc` refuses it
too, because the input type no longer admits `true` (`ServiceObject` and
`ObjectSchema.create` inputs included).
- Lint `unique/unscoped-declared-index` (R11) moves from `warning` to
`error`, and from advisory to gating on all three commands.
`lintDataModel` stops calling it, so `os lint` reports it once, through
the registry. It stays off the runtime door. The surface reason is new
and measured: the save door's own `ObjectSchema` parse refuses the
spelling before the authoring gate runs, so a runtime crossing could
never fire.
**The conversion (D2).** `declared-index-unique-scope` (`toMajor: 18`,
`retiredFromLoadPath: true`, `retiredAfter: '17.7.0'`) rewrites a
declared index's bare `true` to `'global'` on `objects[]` and
`objectExtensions[]`. Field-level `unique: true` is never touched. It is
inserted where its identifier sorts in `MAJOR_18_CONVERSIONS`, at
`order: 61`, with an S4/S5 fixture. It is retired from the authoring
funnel, so authors are refused. The data-at-rest seams replay it:
`applyConversionsToStoredItem`, the artifact door inside its
declared-floor window, and `os migrate meta --from 17`.
**The ledger.**
- D3 semantic entries: `declared-index-bare-unique-true-retired`
(judging the conversion's applied edits: keep `'global'`, or move to
`'organization'`) and `visibility-strict-options-unexported`.
- A `STEP18_RATIONALE` fragment at `order: 86`. Order 85 is held by an
in-flight PR, so this takes the next free number.
- `spec-changes.json` and the upgrade guide do not move.
`PROTOCOL_VERSION` is still `17.0.0`, so no step-18 entry projects there
yet. `check:spec-changes` and `check:upgrade-guide` are green on that
reading.
**The synonym pin retires.** In `sql-driver-unique-tenancy.test.ts`:
- The "accepts `unique: 'global'` on a declared index as a synonym of
true" pin and its header note are gone.
- The verbatim pin ("exactly as authored") is restated in `'global'`
(ADR-0120 D6.6).
- In their place is the D2 corpus pin. The nine engine-owned keys are
frozen at their ADR-time bare spelling and replayed through
`applyConversionsToStoredItem`. Their expected-index output is
byte-identical before and after. On a SQLite database built from the
bare spelling, `detectManagedDrift` for the converted metadata is `[]`.
A lit control (one key moved to `'organization'`) shows drift.
- The field-level pin is untouched (D1).
**The in-repo respelling.** Every declared index with a literal `unique:
true` becomes `'global'`. That is 48 indexes in 39 source files across
`platform-objects`, `metadata-core`, `plugin-security`,
`plugin-sharing`, `service-messaging`, `service-automation` and
`service-realtime`. Nothing becomes `'organization'`, and no field-level
`unique` moves. The prose that quotes those declarations is respelled
with them:
- `metadata-protocol` `overlay-index.ts` and
`view-definition-active-index.ts`;
- `plugin-auth` `account-identity-preflight.ts` and `README.md`;
- the `18.sys-account-issuer-retired` entry.
The teaching surfaces now say "refused" instead of "deprecated":
- `content/docs/data-modeling/indexing.mdx`;
- `skills/objectstack-data/rules/indexing.md`;
- `content/docs/protocol/objectql/schema.mdx`. This is a fourth teaching
surface, found by grepping the rule id. It stated the 17.x posture.
**The export item.** `VISIBILITY_STRICT_OPTIONS` moves, unchanged, to
the unbarrelled `shared/visibility-strict-options.ts`, beside its type
`StrictObjectOptions`. `check:api-surface` reads `shared.json` −1, the
expected reading. The type is not published instead.
**Anchors.** The two ADR-0120 anchors now read the protocol-18 state,
and the conversion entry gains its own anchor (ADR-0120 D6.7).
## The refusal point (H1), door by door, measured
| door | what happens to `indexes: [{ fields: ['code'], unique: true }]`
| reading |
|:--|:--|:--|
| `ObjectSchema.parse` / `.create`, `defineStack` | refused,
`invalid_union` at `indexes.0.unique`, with the prescription |
`unique-scope-message.test.ts` and `unique-scope.test.ts`. A respelled
object reverted to `true` fails to compile (3 TS2322 in
`object.test.ts`, seen before its fixtures were respelled) |
| `os validate` / `os build` | exit 1, `✗ objects.0.indexes.0.unique
invalid_union: …retired at protocol 18…`. Control: `'global'` exits 0 |
temp project, CLI run from this tree |
| `os lint` | exit 1, `unique/unscoped-declared-index` error at
`objects[0].indexes[0]`. Control: `'global'` exits 0 | same project |
| runtime save door (`saveMetaItem`) | `INVALID_METADATA` / 422 with the
prescription, nothing stored. Control: `'global'` stores one row |
one-off probe against `ObjectStackProtocolImplementation` (deleted, not
committed) |
| stored `sys_metadata` row carrying it | reads back as `unique:
'global'` | same probe, `getMetaItem` |
| code-registered system objects | they are `ObjectSchema.create` calls,
so they are refused at module load and by `tsc`. All 39 respelled
objects import and parse | H3 proof below |
| raw, untyped `registry.registerObject` / driver input | not refused,
but not reinterpreted either: every driver builds `true` exactly as
`'global'` | no authoring door hands it unparsed metadata. Stored rows
convert first, and typed callers are refused by `tsc` |
**Why the schema.** It is the one contract every parsing door shares,
and it is the only place the refusal reaches `ObjectSchema.create` and
the save door. Against the four-axis framework:
- **Real need:** 48 platform declarations carried the spelling, and its
meaning differs from the field-level one.
- **Long-term soundness:** contract-first, with no consumer-side
tolerance.
- **Preventing AI mistakes:** a loud prescription at parse and at `tsc`
makes the spelling hard to write.
- **Startup scope:** retired immediately, no dual-spelling window.
Existing data is covered by the D2 conversion.
Lint R11 is kept as the second channel because `os lint` never parses.
## Zero drift (H3)
- **Nine-key corpus:** pinned as above, byte-identical, with an empty
drift plan and a lit control.
- **The 39 respelled objects:** a one-off script imported each object
from this tree. For every object it ran `driver-sql`'s own
`expectedIndexes` and `normalizeDeclaredIndex` over the `'global'`
declarations and over the same declarations with `'global'` set back to
`true`, which is exactly the base tree: none of these files carried
`'global'` at `e67ba80049`, and the diff touches only those 48 literals.
Both tenancy shapes were checked (tenant column and none). Result:
`files=39 objects=39 respelled-indexes-seen=48 (census target 48)
index-normalizations-compared=268 mismatches=0`. Control: `true` vs
`'organization'` differs.
## Census (H2)
Run against `e67ba80049`, with an AST walk (TypeScript compiler API). It
finds an object literal with `unique: true` inside an array that
initialises `indexes`, and any index-shaped literal (`fields` + `unique:
true`). Doc fences are read too, including bare fragments, which parse
as broken labelled blocks rather than objects. Firing control: a
synthetic file with an index hit, a held variable index and a doc
fragment was seen 3/3, while its field-level `unique: true`, `'global'`
and `false` were seen 0/3.
| population | hits | disposition |
|:--|:--|:--|
| source, declared indexes | 48 in 39 files | respelled `'global'` |
| docs / README authored examples | `indexing.mdx` (legacy composite
example), plugin-auth `README.md`, the skill's refused example |
respelled. The skill keeps its ❌ example as the refused spelling |
| tests | 103 in 42 files | Driver tests feed the driver API directly,
are unparsed and stay. Parse-level fixtures were respelled (spec ×3
files, platform-objects ×1, service-realtime ×1). The CLI e2e fixtures
that used the R11 warning as "an authoring-rule advisory" now plant R10
(`unique/double-declaration`), or R12 for the nested-index control |
| `CHANGELOG.md` | 4 | release-owned, untouched |
| objectui at its pin `a58626c8` | 3, all in `EmbeddedItemEditor` tests
| not this repo. See acceptance notes |
| `examples/**`, `apps/**` | 0 | none |
The claim's text census reached 47 paths. The difference is prose and
code that is not an authored index:
- `data-model-rules.ts`: R11's own message and R12's doc example,
updated.
- `schema-drift.ts`: driver comments about semantics, and one
driver-internal `ExpectedIndex` boolean. Unchanged (driver-sql takes the
respelling only).
- `overlay-index.ts`, `view-definition-active-index.ts`,
`account-identity-preflight.ts` and `18.sys-account-issuer-retired.ts`:
quotes of respelled declarations, respelled with them.
- `migrations/registry.ts:10855`: field-level prose, unchanged.
## Verification at `0cb065b48f`
**Tests**, each run through the shared verify lock. They ran at
`ded6c918f6`. The only commit after it touches
`scripts/adr-anchors/*.json` and no package source.
| package | files | tests |
|:--|--:|--:|
| `@objectstack/spec` (`local` project) | 621 | 18523 passed, 1 todo |
| `@objectstack/spec` (`repo` project: the step-18 rationale and
major-18 conversion merge tests) | 2 | 21 |
| `@objectstack/lint` | 120 | 5639 |
| `@objectstack/driver-sql` | 218 (+11 skipped) | 3635 |
| `@objectstack/cli`, `unit` project | 259 | 3786 |
| `@objectstack/cli`, the two edited `*.e2e` files
(`OS_TEST_TIERS=nightly`, `integration` project) | 2 | 14 |
| `platform-objects` · `metadata-core` · `metadata-protocol` | 63 · 18 ·
221 | 1006 · 415 · 28222 |
| `plugin-security` · `plugin-auth` · `plugin-sharing` | 169 · 126 · 40
| 3640 · 2612 · 1002 |
| `service-messaging` · `service-automation` · `service-realtime` | 48 ·
173 · 5 | 534 · 2112 · 33 |
| census consumers: `objectql` · `rest` · `types` · `cloud-connection` |
378 · 260 · 24 · 41 | 7508 · 4914 · 739 · 505 |
| census consumers: `driver-memory` · `driver-mongodb` · `driver-turso`
| 70 · 31 · 88 | 1718 · 690 · 2373 |
`typecheck` exits 0 on all 13 packages this diff touches: spec, lint,
cli, platform-objects, metadata-core, metadata-protocol, driver-sql,
plugin-security, plugin-auth, plugin-sharing, service-messaging,
service-automation and service-realtime.
**Gates.** `node scripts/pm/dispatch-gates.mjs --commands` at
`0cb065b48f` derives 134 families. All 134 ran and exited 0. `--ran`
reconciliation: 0 NOT-MEASURED, 0 UNRUN, and every entry carries its
exit code. Readings from that run:
- `check:generated`: all 15 artifacts up to date.
- `check:api-surface` ✓. The removal is the committed
`api-surface/shared.json` −1 (`VISIBILITY_STRICT_OPTIONS`) and
`export-origins/shared.json` −1.
- `check:spec-changes` and `check:upgrade-guide` ✓, with no change: step
18 does not project until the protocol major moves.
- `check:liveness` ✓. No ledger row moves: the key lives, and a value is
not a property.
- `check-adr-0087-registration` ✓, registering
`declared-index-bare-unique-true-retired` and
`visibility-strict-options-unexported`.
- `check-changeset-no-major` ✓.
- `check:docs` ✓ (226 generated reference files in sync).
- `check:adr-anchors` ✓ (61 anchored files).
- `check:nul-bytes` ✓.
- `check:skills-token-ratchet` ✓ (`rules/indexing.md` 2183 of 3241
tokens).
- `check:i18n` ✓.
**ESLint, narrowed and proven.** The population comes from
`eslint.config.mjs`: `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus
`NEVER_LINTED`. I ran all 68 lintable files this diff touches (`--format
json`: 68 files, 0 errors, 0 warnings, none ignored). Untouched files
cannot change verdict: the config enables no type-aware linting (its own
note says so), and no untouched file imports the one removed export.
**Size and tier.** `check-governed-merges --pr 22103` reads 1390 changed
lines (+1015 / −375 over 81 files, generated files included) at
`ccfbfbaa58`, under 5000. One path is on the governed register
(`skills/**`), so this PR is Tier H.
**`skills/**` readings.**
- `rules/indexing.md`: 229 → 229 lines (1249 → 1259 words). The edit
rewrites three lines in place and buys no line.
- All `SKILL.md`: 4411 → 4411 lines. No `SKILL.md` is touched.
- The whole `skills/` tree: 13366 → 13366 lines.
**Ablation, on the edited dedup control.**
`per-package-dedup-positional-echo.test.ts` now builds its nested-index
control on R12. Its header asks for its ablation to be re-run on edit.
Widening `findingKey`'s rewrite from the top-level index to every index
turns exactly that control red (1 failed, 5 passed). The restore was
proven by blob hash (`0868281` before and after) and an empty `git diff
HEAD`.
## Acceptance notes (not filed, nothing changed for them)
- **objectui at its pin.** `EmbeddedItemEditor.indexFallback.test.tsx`
asserts `IndexSchema.safeParse({ fields: ['c'], unique: true }).success
=== true` as a "still ACCEPTED" control, and names this card. It turns
red on objectui's next `@objectstack/spec` bump. That bump is also where
its fallback schema's boolean branch has to be decided: that branch
renders a switch for a stored boolean, and switched on it would now be
refused at save, loudly. The Console Pin Gate builds and does not run
tests, so it stays green. Carrier: objectui's next spec bump.
- **Dormant comments that describe the 17.x posture.** These are history
notes, not authoring surfaces, and nothing reads them. Carrier: none.
- `driver-sql` `schema-drift.ts` near `:100` ("PARKED on #5082"),
outside this card's driver-sql allowance;
- three object comments that still say bare `true` "is" the positional
spelling (`sys-email-template`, `notification-preference`,
`notification-subscription`).
- **The `isolated` install gate.** `packages/types`
`unique-scope-install-gate.ts` still treats bare `true` as `'global'`.
That is now reachable only from unparsed input, and it reads the
spelling correctly. Carrier: none.
- **ADR-0120 status line.** It still says "implementation not started…
protocol-18 items deferred". Updating it is a follow-up for the
`docs/adr` owner. This PR does not touch `docs/adr/**`.
## 维护者速读(草稿)
**改了什么**:声明索引(`indexes[]`)上的裸 `unique: true` 从协议 18 起被拒绝,报错直接告诉作者写
`'global'`(全安装唯一,和原来建出的索引完全一样)或
`'organization'`(每个组织内唯一)。已经存进数据库或已构建产物里的旧写法,加载时自动改写成
`'global'`,物理索引一字节不变。仓库里 48 处平台对象的声明全部改成 `'global'`;字段级 `unique: true`
不变,继续有效。另外把一个外部用不了的内部常量 `VISIBILITY_STRICT_OPTIONS` 从公开导出里撤掉。
**为什么改**:ADR-0120 已裁定(D7):裸 `true` 在声明索引上读起来像"每个组织唯一",实际却是"全安装唯一",AI
和人都会照字面误用。17.x 只警告,协议 18 起改为直接拒绝,让作者必须把范围写明。
**风险与代价(含回滚)**:对仓库外仍写裸 `true` 的应用是破坏性变更:`os validate` / `os build` /
保存元数据会报错,按提示改成 `'global'` 即可(`os migrate meta --from 17`
列出改点),已存储的数据不受影响。已实测零漂移:39 个平台对象、9 个引擎去重键前后索引输出逐字节相同。objectui 有一个测试断言"裸
true 仍可解析",下次升级 spec 时会变红,需要在 objectui 那边跟进。回滚即还原本 PR(无数据迁移)。
**席位意见**:
**你要做的**:本 PR 改到 `skills/**`(Tier H),需要你本人审核合并或给出授权批准。
---
_Generated by [Claude
Code](https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 033e5c5 commit ef1fcb2
81 files changed
Lines changed: 1015 additions & 375 deletions
File tree
- .changeset
- content/docs
- data-modeling
- protocol/objectql
- references
- api
- data
- system
- packages
- cli/test
- drivers/driver-sql/src
- lint/src
- metadata-core/src/objects
- metadata-protocol/src/migrations
- platform-objects/src
- audit
- identity
- system
- plugins
- plugin-auth
- src
- plugin-security/src/objects
- plugin-sharing/src/objects
- services
- service-automation/src
- service-messaging/src/objects
- service-realtime/src/objects
- spec
- api-surface
- export-origins
- src
- conversions
- data
- migrations
- entries/semantic
- ui
- scripts/adr-anchors
- skills/objectstack-data/rules
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
67 | 67 | | |
68 | 68 | | |
69 | 69 | | |
70 | | - | |
71 | | - | |
72 | | - | |
73 | | - | |
74 | | - | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
75 | 79 | | |
76 | 80 | | |
77 | 81 | | |
| |||
105 | 109 | | |
106 | 110 | | |
107 | 111 | | |
108 | | - | |
| 112 | + | |
| 113 | + | |
109 | 114 | | |
110 | 115 | | |
111 | 116 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
628 | 628 | | |
629 | 629 | | |
630 | 630 | | |
631 | | - | |
632 | | - | |
633 | | - | |
634 | | - | |
| 631 | + | |
| 632 | + | |
| 633 | + | |
| 634 | + | |
| 635 | + | |
635 | 636 | | |
636 | 637 | | |
637 | 638 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
948 | 948 | | |
949 | 949 | | |
950 | 950 | | |
951 | | - | |
| 951 | + | |
952 | 952 | | |
953 | 953 | | |
954 | 954 | | |
| |||
0 commit comments