Skip to content

Commit ef1fcb2

Browse files
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

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
'@objectstack/platform-objects': patch
5+
'@objectstack/metadata-core': patch
6+
'@objectstack/metadata-protocol': patch
7+
'@objectstack/plugin-security': patch
8+
'@objectstack/plugin-auth': patch
9+
'@objectstack/plugin-sharing': patch
10+
'@objectstack/service-messaging': patch
11+
'@objectstack/service-automation': patch
12+
'@objectstack/service-realtime': patch
13+
---
14+
15+
A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface.
16+
17+
Clause-②: no (narrowing)
18+
19+
<!-- adr-0087: registered declared-index-bare-unique-true-retired, visibility-strict-options-unexported -->
20+
21+
**BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`).
22+
23+
**Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated.
24+
25+
**What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by:
26+
27+
- the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`;
28+
- lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses.
29+
30+
**What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently.
31+
32+
**What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before.
33+
34+
## FROM → TO
35+
36+
| you wrote | write instead |
37+
|:--|:--|
38+
| `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes |
39+
| …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change |
40+
| `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged |
41+
42+
**The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.
43+
44+
**Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured.
45+
46+
### The kit
47+
48+
- **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating.
49+
- **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture.
50+
- **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment.
51+
- **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal.
52+
- **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`.

‎content/docs/data-modeling/indexing.mdx‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,15 @@ Two rules make the choice safe to write and safe to deploy:
6767
valid indefinitely — `email: Field.email({ unique: true })` is correct, and the
6868
explicit spelling is simply preferred in new code.
6969

70-
On a **declared index** it is the deprecated spelling of `'global'`: it
71-
materializes over exactly the listed columns. Because that reads like the
72-
field-level meaning but does the opposite, `os lint` / `os build` / `os validate`
73-
report it as `unique/unscoped-declared-index`, and it is rejected outright at
74-
protocol 18. State the scope.
70+
On a **declared index** it is refused since protocol 18. It was the positional
71+
spelling of `'global'` — it materialized over exactly the listed columns —
72+
which reads like the field-level meaning but does the opposite. `os validate`,
73+
`os build` and the runtime save door refuse it at the schema, `os lint`
74+
reports it as `unique/unscoped-declared-index`, and every refusal names both
75+
replacements. State the scope: `'global'` builds exactly the index bare `true`
76+
built. Metadata already stored or built with the bare spelling converts to
77+
`'global'` when it loads — the same physical index, no migration — and
78+
`os migrate meta --from 17` lists the edits for your sources.
7579
</Callout>
7680

7781
<Callout type="warn">
@@ -105,7 +109,8 @@ constraint by listing the column itself:
105109

106110
```typescript
107111
// the legacy spelling — still valid, still materialized exactly as written
108-
indexes: [{ fields: ['name', 'organization_id'], unique: true }]
112+
// (bare `true` before protocol 18, which converts to 'global')
113+
indexes: [{ fields: ['name', 'organization_id'], unique: 'global' }]
109114
```
110115

111116
This keeps working forever and forces no migration, so `os lint` only *suggests*

‎content/docs/protocol/objectql/schema.mdx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -628,10 +628,11 @@ indexes:
628628
unique: global
629629
```
630630

631-
Bare `unique: true` on a *declared index* is the deprecated spelling of
632-
`'global'` (materialized over exactly the listed columns). It is warned by
633-
`os lint` / `os build` / `os validate` as `unique/unscoped-declared-index` and
634-
rejected at protocol 18 — state the scope instead. A legacy index that lists the
631+
Bare `unique: true` on a *declared index* is refused since protocol 18. It was
632+
the positional spelling of `'global'` (materialized over exactly the listed
633+
columns); `os validate` / `os build` refuse it at the schema and `os lint`
634+
reports it as `unique/unscoped-declared-index` — state the scope instead.
635+
Stored metadata carrying it converts to `'global'`, the same physical index. A legacy index that lists the
635636
organization column itself (`fields: [organization_id, code]`) keeps working
636637
unchanged; respelling its scope to `'organization'` makes the listed column
637638
NULL-safe in place.

‎content/docs/references/api/metadata.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -948,7 +948,7 @@ Metadata query with filtering, sorting, and pagination
948948
| **datasource** | `string` | optional (default: `"default"`) | Target Datasource ID. "default" is the primary DB. |
949949
| **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record<string, string>; … }` | optional | Remote table binding for federated (external) objects. |
950950
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. |
951-
| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes |
951+
| **indexes** | `{ name?: string; fields: string[]; unique?: false \| 'global' \| 'organization' }[]` | optional | Database performance indexes |
952952
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
953953
| **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
954954
| **access** | `{ default?: Enum<'public' \| 'private'> }` | optional | [ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default). |

0 commit comments

Comments
 (0)