Skip to content

feat(spec)!: refuse bare unique: true on a declared index at protocol 18 — stated scope, zero-drift conversion (ADR-0120 D2/D5a/D7) - #22103

Merged
os-zhuang merged 7 commits into
mainfrom
claude/issue-5082-declared-index-unique-scope-18
Oct 8, 2026
Merged

os-zhuang merged 7 commits into
mainfrom
claude/issue-5082-declared-index-unique-scope-18

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

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.
  • 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

claude added 6 commits October 7, 2026 13:19
… 18 (ADR-0120 D2/D5a/D7)

WIP: schema refusal, declared-index-unique-scope conversion (toMajor 18),
D3 semantic entries, R11 to error, in-repo respelling to 'global',
synonym pin retired with a D2 nine-key corpus pin, VISIBILITY_STRICT_OPTIONS
moved out of the shared barrel. Generated artifacts follow.

Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7
Co-authored-by: Claude <noreply@anthropic.com>
…pec fixtures state the index scope

Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7
Co-authored-by: Claude <noreply@anthropic.com>
…minor, BREAKING, ADR-0087 registered)

Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7
Co-authored-by: Claude <noreply@anthropic.com>
…or the declared-index-unique-scope conversion (D6.7)

Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 11 package(s): @objectstack/lint, @objectstack/metadata-core, @objectstack/metadata-protocol, @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/plugin-security, @objectstack/plugin-sharing, @objectstack/service-automation, @objectstack/service-messaging, @objectstack/service-realtime, @objectstack/spec, touching 63 documentable anchor(s). ⚠️ 6 changed file(s) yielded no anchor (packages/plugins/plugin-auth/README.md, packages/spec/api-surface/shared.json, packages/spec/export-origins/shared.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

39 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json db4c45b8c3c5d35eb4c6774e1ce832258c264806.

⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 6 changed file(s) yielded no anchor (packages/plugins/plugin-auth/README.md, packages/spec/api-surface/shared.json, packages/spec/export-origins/shared.json, …) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 33 pages)
  • 13 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 149 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json db4c45b8c3c5d35eb4c6774e1ce832258c264806 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3cec7c6a55563a08d0a58fdb01a5f2bc081014fe — the merge of head ccfbfbaa58733f7552f7a64e80f4df01dfe815f2 into base db4c45b8c3c5d35eb4c6774e1ce832258c264806, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3cec7c6a55563a08d0a58fdb01a5f2bc081014fe && git checkout 3cec7c6a55563a08d0a58fdb01a5f2bc081014fe
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin db4c45b8c3c5d35eb4c6774e1ce832258c264806 ccfbfbaa58733f7552f7a64e80f4df01dfe815f2 && git checkout -B drift-repro db4c45b8c3c5d35eb4c6774e1ce832258c264806 && git merge --no-ff ccfbfbaa58733f7552f7a64e80f4df01dfe815f2

node scripts/docs-audit/affected-docs.mjs --json db4c45b8c3c5d35eb4c6774e1ce832258c264806

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs db4c45b8c3c5d35eb4c6774e1ce832258c264806 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0cb065b48fd7614b1e74008d63bb801168425634
Local-runs: none

Inputs read: card #5082 (body and all 10 comments; triage release 6038134696, director pointer 5807287522, claim 6038423588, dev report 6042104916), ADR-0120 at main, PR #22103 body, its 81-file list and the diff against main (merge-base e67ba80049; main now at aa71c4d9d1), the 34 check-runs on the head (34 completed: 32 success, 2 skipped — Console Pin Gate, whose console filter did not select because no console build input moved, and the opt-in packed-tarball smoke; none in progress), objectui at the pin a58626c8 by git show, and the state of open PRs #22084 and #22094 as the brief names them.

① Derived judgments

Accept set. IndexSchema.unique narrows from boolean | 'global' | 'organization' to false | 'global' | 'organization' (DeclaredIndexUniqueScopeSchema, object.zod.ts). Right — ADR-0120 D1 retires the positional spelling, D7 stages it to protocol 18, and the triage release 6038134696 opened that train on main. Every door an author reaches is the one schema: ObjectSchema.parse / .create and defineStack; os validate / os build (parse); the runtime save door (saveMetaItem parses against ObjectSchema before the authoring gate — stored.ts states the write path uses the current schema); objectExtensions[].indexes (same IndexSchema, visible in the regenerated reference); and tsc, since ServiceObject = z.input of the base schema no longer admits true. The prescription (DECLARED_INDEX_BARE_TRUE_RETIRED) is true in each clause: 'global' is the index bare true built (normalizeDeclaredIndex takes both verbatim; the new driver-sql corpus pin proves the bytes), 'organization' prepends the NULL-safe key part (D3, shipped in 17.x), field-level unique: true is unaffected (UniqueScopeSchema in field.zod.ts keeps z.boolean()), and os migrate meta --from 17 lists the edits on this build: meta.ts:91 sets the chain terminus to the maximum of PROTOCOL_MAJOR and the registered majors, so --from 17 replays step 18 today, with PROTOCOL_VERSION still 17.0.0. Nothing else moves: unique-scope-message.test.ts now pins a 15-row value table on which the field and index surfaces split on exactly one row, bare true; false, 'global', 'organization', the 'tenant'/'org' refusals and the invalid_union / ['unique'] envelope are unchanged on both. Right.

Lint R11. unique/unscoped-declared-index moves warning → error, advisory → gating, ['validate','build'] → all three commands, and lintDataModel stops calling it. Right: os lint runs the registry through runAuthoringRules('lint', { normalized, parsed: lowered }) (cli/commands/lint.ts:680), and the runner hands a parsed-input rule run.parsed ?? run.normalized, so the rule reaches os lint once through its own entry; lintDataModel's only non-test caller is that same command, so no other consumer silently loses the rule. Under validate / build the parse refuses first with the same prescription, which the new surfaceReason states correctly; the runtime object door stays closed to it for the stated reason and the runtime-gate.object-writes.test.ts fence is re-pointed by name rather than silently shrunk. Right.

Conversion declared-index-unique-scope. toMajor: 18, retiredFromLoadPath: true, retiredAfter: '17.7.0' (the label on main; the unpublished-entry rule of retired-after.census.test.ts). Reach: objects[].indexes[].unique and objectExtensions[].indexes[].unique, only where the value is === true; false, 'global', 'organization' and every field-level unique pass through, copy-on-write, idempotent. Notice: one { from: 'true', to: 'global', path } per rewritten index; the fixture carries S4 (http_delivery), S5 (sys_notification, with a field-level true as the negative control) and an extension, expectedNotices: 3. Seams: applyConversionsToStoredItem pins includeRetired: true and is the seam behind getMetaItem and the layered read's overlay arm (protocol.ts convertStoredItem), the metadata database loader and the objectql plugin; the artifact door (applyArtifactForwardConversions) replays with includeRetired: true inside its declared-floor window, per entry by retiredAfter; os migrate meta --from 17 replays it by id off step18.conversionIds. Retired from the authoring funnel, so a live author is refused, not converted — the D1 intent. Right. Zero-drift claim: by construction ('global' is what bare true materialised) and pinned — the nine-key corpus (sys_job, sys_notification, http_delivery, sys_presence, sys_email_template, notification_delivery, notification_receipt, notification_subscription, notification_preference) replays through applyConversionsToStoredItem, expectedIndexes bytes are identical before and after, detectManagedDrift on a database built from the bare spelling is [], and the lit control (sys_presence moved to 'organization') shows drift. The pin lives in driver-sql rather than beside the conversion because expected-index output is a driver reading; conversions.test.ts's generic fixture and retired-entry runs cover the spec side. Right.

Ledger. D3 entries declared-index-bare-unique-true-retired (conversionIds: ['declared-index-unique-scope']; the "which scope the author meant" framing is the correct residue a mechanical rewrite cannot decide) and visibility-strict-options-unexported (no conversion: a TS surface), each as an entries/semantic/18.*.ts file plus the generated registry.ts copy (check:generated green). STEP18_RATIONALE fragment at order: 86, placed where its id sorts. check-adr-0087-registration green on the changeset's registered marker. Right.

Respelling. Counted from the diff: 48 unique: true → unique: 'global' literals in 39 object sources — metadata-core 4 in 3 files, platform-objects 35 in 27, plugin-security 3 in 3, plugin-sharing 1, service-automation 1, service-messaging 3 in 3, service-realtime 1 — matching the dev's census. No source gains 'organization' (the only added 'organization' literals are the driver-sql drift control, the conversion fixture's pass-through rows, and prose); no field-level unique moves; false entries stay false. Each respelled index is byte-identical in meaning. Prose that quotes those declarations (overlay-index.ts, view-definition-active-index.ts, account-identity-preflight.ts, plugin-auth README.md, 18.sys-account-issuer-retired.ts) is respelled with them. Right.

Synonym pin. The "'global' is a synonym of true" case and its header note retire; the verbatim pin is restated for 'global' (D6.6); the D2 corpus pin takes the retired case's place; the field-level pin is untouched (D1). Right.

Public surface. VISIBILITY_STRICT_OPTIONS moves unchanged to the unbarrelled shared/visibility-strict-options.ts beside its type; the two internal importers (ui/view.zod.ts, shared/editability-boundary.ts) re-point; api-surface/shared.json −1 and export-origins/shared.json −1 are the expected reading the director's pointer predicted; the type is not published instead, as the pointer ruled. Right.

Written surfaces. indexing.mdx, objectql/schema.mdx, the skill rule, the generated references and the three ADR anchors (two refreshed, one new for the conversion — D6.7) now state the protocol-18 posture; the indexing.mdx legacy-composite example reads 'global' with a note, meaning preserved. The cli e2e fixtures that relied on R11's warning now plant R10 (unique/double-declaration: field true and index 'global' on one column is a real cross-scope contradiction that still builds) and the dedup control plants R12 with a hand-written organization composite at 'global' — a nested-index finding as the file's header demands, with its ablation re-run per the dev. Right.

Governed skill edit (Tier H). skills/objectstack-data/rules/indexing.md changes three lines: the ❌ example now says refused since protocol 18 and names the two refusal channels; the field-level note says the declared-index spelling is refused and stored metadata converts to 'global'. Both sentences are true on this head and the surface was on the claim's list; line count unchanged, token ratchet green. True and owed. The merge tier is not judged here.

② Semver level

.changeset/5082-declared-index-unique-scope.md: @objectstack/spec minor, @objectstack/lint minor, nine respelled packages patch; BREAKING banner, FROM → TO table, adr-0087: registered marker, Clause-②: no (narrowing). The PR body's Clause-②: line reads no (narrowing: …) — the claim's line verbatim, parsed as declared-no plus narrowing; consistent with the changeset. check-changeset-no-major green.

  • If this PR lands before chore(release): enter Changesets pre mode (next) with one major marker, so v18 opens at 18.0.0-next.0 #22084: .changeset/pre.json is absent on main (read at aa71c4d9d1; .changeset/ lists only config.json beside the .md entries), so the launch-window convention in check-changeset-no-major.mjs applies and triage's release rule (6038134696: "before the opening, minor with its BREAKING banner and ADR-0087 disposition") is met. Every sentence of the changeset is true as written. Matches the diff.
  • If this PR lands after chore(release): enter Changesets pre mode (next) with one major marker, so v18 opens at 18.0.0-next.0 #22084: pre.json (mode: pre, tag next) and one major marker are on main; the guard stands aside in pre mode and nothing refuses a minor, and the group's version is already 18.0.0-next.N from chore(release): enter Changesets pre mode (next) with one major marker, so v18 opens at 18.0.0-next.0 #22084's marker, so the computed version is unaffected. But triage's rule then says major, and the sentence "shipped as minor under the launch-window convention … (Changesets pre mode is not in on main)" becomes a false release record. Owed by the later lander (this PR, in that order): re-grade @objectstack/spec to major and rewrite that parenthetical before merge. Nothing is owed in the other order.
  • The nine patch grades: a shipped definition's published value moves (SysOauthResource.indexes now reads 'global' where it read true, pinned), which reads as at least minor by the window's own rule; immaterial in the fixed group (highest bump wins, and the banner and ledger are the carriers), so recorded, not refused.
  • spec-changes.json and the upgrade guide do not move: protocolVersion is 17.0.0 and no toMajor: 18 entry on main projects there either (0 hits for an 18 record), so the card's 再生成 bullet is met structurally — the entry is in the chain, and chore(release): enter Changesets pre mode (next) with one major marker, so v18 opens at 18.0.0-next.0 #22084's changeset states PROTOCOL_VERSION follows the major at version time, which is when the projection happens. check:spec-changes / check:upgrade-guide green on that reading.

③ Boundary flags

Dev report 6042104916: open_questions: []; nine deviations and three out-of-scope findings, each answered:

  1. Clause-② line copied from the claim — fine; parses as declared no plus narrowing; the changeset carries the bare form.
  2. PR body size line stale (1378 / 78 files read before the last commit; the head is 1390 (+1015 / −375) / 81) — cosmetic; the governed-merge reading comes from the gate, not the body. The seat may correct the line; not a landing condition.
  3. Files beyond the claim's listed surface — all owed: objectql/schema.mdx is a fourth surface stating the 17.x posture (D6 names every surface that states the old contract); the four cli tests and runtime-gate.object-writes.test.ts encode R11's old tier by name and would lie otherwise; the spec fixtures and the two object pins read the refused spelling; the three anchors are D6.7 and check:adr-anchors.
  4. driver-sql test-only; schema-drift.ts prose left — within the claim's driver-sql allowance (its one listed file is the test). The "PARKED on ADR-0120 协议 18 项:D2 conversion(声明索引 unique: true → 'global')+ 裸 true 硬拒 + synonym pin 退役 —— 挂 18 列车,现在勿动工 #5082" comment near :100 and the three object comments (sys-email-template, notification-preference, notification-subscription) that still say bare true "is" the positional spelling are now stale history prose nothing reads: follow-up for their owners, not a blocker.
  5. origin/main not merged before pr_create — H4: the later lander merges. GitHub reads mergeable: true at this head against aa71c4d9d1, which already carries fix(lint)!: the object save door gives the build's validation-rule verdict (#22032 pass 1) #22041 (authoring-rules.ts, disjoint hunks) and feat(plugin-security,plugin-auth,verify): sys_user_permission_set gains the permission-set name column, written by every grant writer (ADR-0131 C2 S4a) #22100 (sys-user-permission-set.object.ts, S4a's field column — disjoint from this PR's one-line index respelling, as 6039194608 predicted). Clean at this read.
  6. Conversion order 61 and rationale order 86 — no pin or gate fails in any landing order. conversions-major18-merge.test.ts and step18-rationale-merge.test.ts require only a finite positive order and placement where the identifier sorts, and both explicitly model two in-flight PRs taking the same next order ("equal order from a shared base renders in key order"); inApplicationOrder and joinRationale break ties by id, and main already carries ties (conversions 55 and 57, rationale 62). Open feat(spec)!: retire the flat string-list arm of manifest.permissions — the structured ADR-0025 block is the only form (#13458) #22094 (Phase 2 of #11333: retire the legacy string[] arm of manifest.permissions (major, standard retirement route) #13458) takes conversion order 61 (manifestPermissionsStringListRemoved) and rationale order 85 (tying main's 85 from feat(spec)!: the build doors refuse a builtin node config value its executor contract refuses, with its location #21974); this PR's declaredIndexUniqueScope sorts between datasetCountMeasureEmptyFieldRemoved and elementFilterRemoved, and its rationale fragment between dataset-member-field-expression-refused and duration-keys-unit-in-key — different gaps from feat(spec)!: retire the flat string-list arm of manifest.permissions — the structured ADR-0025 block is the only form (#13458) #22094's in every file, so the server-side merge is clean; the two conversions walk disjoint collections (objects/objectExtensions indexes versus manifest.permissions), so their relative order is immaterial. What the later lander must do: merge main, and under the seat's own convention in the claim take the next free numbers (62 for the conversion; 87 for the rationale if the other 85 stands) — a convention, not a gate.
  7. spec-changes.json / upgrade guide unchanged — answered in ②.
  8. Patch bumps on nine packages — answered in ②.
  9. Overlap with feat(core,objectql,plugin-security,plugin-sharing): the catalog is read from the registry; assignment tables reference it by name (ADR-0131 D2/D3/D4) #15196 S4a — landed as feat(plugin-security,plugin-auth,verify): sys_user_permission_set gains the permission-set name column, written by every grant writer (ADR-0131 C2 S4a) #22100 on main; disjoint regions, mergeable at this read (item 5).

Out-of-scope findings:

  • objectui at the pin a58626c8 — judged: this PR does not make a shipped objectui screen emit a refused value from any server-sourced index. FALLBACK_SCHEMAS.index puts the scope enum first, so a new index can only author 'global' / 'organization'; the boolean switch renders only for a value that arrives as a boolean, and after this PR every server read path (getMetaItem, the layered read's overlay arm, the artifact door, the respelled code objects) serves 'global' for a legacy row. The residual is an admin hand-typing true in the raw-JSON fallback, which the save door refuses loudly with the prescription and stores nothing — D1's intended behaviour. What goes red is a test control, EmbeddedItemEditor.indexFallback.test.tsx asserting IndexSchema.safeParse({ fields: ['c'], unique: true }).success === true, on objectui's next @objectstack/spec bump (it depends on ^17.6.0 from npm; the Console Pin Gate builds and runs no objectui tests, and did not select on this head). Two further 17.x-posture strings sit beside it at the pin and should ride the same bump: packages/app-shell/src/views/metadata-admin/i18n.ts:1775 (and its zh twin at :4764) teaches a console user to write indexes: [{ fields: [...], unique: true }] — a user who follows it after this lands meets the 422 — and packages/types/src/data-protocol.ts:811 calls bare true the deprecated spelling. Follow-up, not a landing blocker; carrier objectui's next spec bump. Escalated to the seat: file a card on objectui naming those three sites, so the bump does not discover them by a red test.
  • ADR-0120 status line still reads "implementation not started … protocol-18 items deliberately deferred" — false once this lands; the claim forbade docs/adr/** edits. Escalated: a one-line follow-up for the docs/adr owner.
  • packages/types unique-scope-install-gate.ts true arm — reads unparsed input as 'global', which is the conversion's meaning; nothing owed.

Implemented-by: claude/issue-5082-declared-index-unique-scope-18
Reviewed-by: session_01GV6oYwgc1kWiUCb1YaprQ7

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

✅ ACCEPT: PR #22103 at 0cb065b48f (ADR-0120's protocol-18 items: bare unique: true on a declared index is refused, stored metadata converts to 'global', the synonym pin retires; VISIBILITY_STRICT_OPTIONS leaves the public surface). ⛔ Governed (Tier H): it lands by the maintainer's hand

domain:spec seat 2 · os-warren · session session_01GV6oYwgc1kWiUCb1YaprQ7 · 2026-10-07T17:57Z · holder of claim 6038423588; the review of record for the report 6042104916.

Checklist (read on GitHub and on origin/main, not from the report):

Checks on 0cb065b48f: 32 success and 2 skipped. check-expected-skips reads both as on the roster. git merge-tree onto origin/main 172be37da7: clean. .changeset/pre.json is absent there.

⛔ Landing tier H. check-governed-merges --pr 22103 reads GOVERNED: skills/objectstack-data/rules/indexing.md is on the register (skills/**), 1,390 changed lines. That edit is owed: without it the published skill would still call the refused spelling "deprecated" (record ③). No seat readies, enqueues or arms this PR. It lands by the maintainer's merge, or by an authorized approval after which the seat lands it. The question is with the maintainer in this seat's session.

Landing conditions the record names:

Acceptance notes (follow-ups whose trigger is this PR's merge):

  • objectui (escalated by the record): a card on objectui names three sites that still teach or pin bare true: EmbeddedItemEditor.indexFallback.test.tsx (a control that goes red on objectui's next @objectstack/spec bump), packages/app-shell/src/views/metadata-admin/i18n.ts:1775 and its zh twin :4764 (help text a console user would follow into a 422), and packages/types/src/data-protocol.ts:811. The seat files it at the merge.
  • carrier: the docs/adr owner · ADR-0120's status line ("implementation not started … protocol-18 items deliberately deferred") is false once this lands. The claim kept docs/adr/** out of this PR.
  • carrier: none · Stale 17.x-posture comments nothing reads: driver-sql schema-drift.ts near :100, and three object comments. They ride their next edit.
  • The PR body's size line (1,378) predates the last commit. The gate reads 1,390.

Generated by Claude Code

…lared-index-unique-scope-18

Conflict: packages/spec/src/migrations/registry.ts, one hunk, in the
hand-written STEP18_RATIONALE array (outside every os-generated region).
Both sides inserted a fragment at order 86 at the same anchor. Resolved as
the union of both sides' lines verbatim, in id order:
declared-index-bare-unique-true-retired, then
deployment-plumbing-organization-columns-retired. The generated regions
merged textually and are re-derived by gen:migration-registry next.

Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读(终稿):PR #22103 · #5082 协议 18 的唯一范围收口

domain:spec seat 2 · os-warren · session session_01GV6oYwgc1kWiUCb1YaprQ7 · 2026-10-07T21:20Z · 对照席位自己读过的 diff 校正 dev 草稿;审核记录是 ACCEPT 6043719207 与合约复审 6043697519(PASS)。合并 main 的补丁轮(head ccfbfbaa58)只解了生成文件里一处并列冲突,PR 自身改动逐行不变。

改了什么:声明索引(indexes[])上的裸 unique: true 从协议 18 起被拒绝,报错直接告诉作者写 'global'(全安装唯一,和原来建出来的索引完全一样)或 'organization'(每个组织内唯一)。已经存进数据库或构建产物里的旧写法,加载时自动改写成 'global',物理索引一字节不变。仓库里 39 个文件、48 处平台对象的声明全部改成 'global';字段级 unique: true 不变,继续有效。另外把外部无法使用的内部常量 VISIBILITY_STRICT_OPTIONS 从 @objectstack/spec 的公开导出里撤掉。

为什么改:ADR-0120 已裁定(D1 / D7):裸 true 在声明索引上读起来像"每个组织唯一",实际却是"全安装唯一",AI 和人都会照字面写错。17.x 只是警告,v18 开启后按裁定改为直接拒绝,作者必须写明范围。

风险与代价(含回滚):

  • 对仓库外仍写裸 true 的应用是破坏性变更:os validate / os build / 保存元数据会报错,照提示改成 'global' 即可(os migrate meta --from 17 会列出改点)。已存储的数据不受影响。
  • 实测零漂移:9 个引擎去重键和 39 个平台对象,改写前后的索引输出逐字节相同。
  • objectui 有三处还按旧写法:一个测试断言"裸 true 仍可解析"(下次升级 spec 时变红),以及控制台帮助文本(中英各一处)仍教用户写裸 true(照做会在保存时得到 422)。合并后席位在 objectui 立卡跟进。
  • ADR-0120 的状态行仍写着"尚未实施",合并后需要文档负责人补一句。
  • 改动 1,390 行,81 个文件;因为改到 skills/** 而进入 Tier H。
  • 回滚:还原本 PR 即可,没有数据迁移。

席位意见:建议批准。复审 PASS;CI 在 ccfbfbaa58 全绿(33 项成功、2 项按名册跳过);与 main 无冲突。skill 那一处改动是必需的:不改的话,发布出去的技能文档会把一个已被拒绝的写法仍称为"已弃用"。时序上:若 PR #22084(pre mode)先合并,本 PR 的 @objectstack/spec 定级要先改成 major 再落地。

你要做的:在本 PR 上给出 APPROVED 审核(os-zhuang 或 hotlong)。席位随即做落地前检查、撤下 needs-user-decision、翻 ready 并入队。直接手动合并也可以。


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

ACCEPT re-head: PR #22103 at ccfbfbaa58 (merge of main only). The ACCEPT 6043719207 stands on this head

domain:spec seat 2 · os-warren · session session_01GV6oYwgc1kWiUCb1YaprQ7 · 2026-10-07T21:22Z · patch round 1 report 6047020096.

The governed endgame is in place: needs-user-decision on this PR, the final maintainer quick-read 6047103927, and reviews requested from os-zhuang and hotlong.


Generated by Claude Code

This was referenced Oct 8, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review October 8, 2026 01:58
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 8, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Landing after the authorized approval: PR #22103 at ccfbfbaa58

domain:spec seat 2 · os-warren · session session_01GV6oYwgc1kWiUCb1YaprQ7 · 2026-10-08T02:00Z · holder of claim 6038423588.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0120 协议 18 项:D2 conversion(声明索引 unique: true → 'global')+ 裸 true 硬拒 + synonym pin 退役 —— 挂 18 列车,现在勿动工

3 participants