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

Merged via the queue into main with commit ef1fcb2 Oct 8, 2026
58 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5082-declared-index-unique-scope-18 branch October 8, 2026 02:32
os-litant pushed a commit that referenced this pull request Oct 8, 2026
…; the lint index rule's sentence joins the class pin

Carries the house sentence into the two sites the merged index-scope
retirement brought (DECLARED_INDEX_BARE_TRUE_RETIRED and the lint
unique-unscoped-declared-index fix text) and into the test that pins the
former verbatim. The lint sentence was a template literal, which the class
pin cannot read, so it is now plain-quoted, as the lint corpus's other site is.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…ubflow node config, with its location (objectstack-ai#22129)

Part of objectstack-ai#21982
Clause-②: no (narrowing)

**Draft, patch round 1 done** (claim revision `6050287134`). This PR
lands the `script` / `subflow` half of the card. The card stays open for
the remainder named below.

## What changes

The build doors now refuse a key that a `script` or `subflow` node's
executor contract does not declare. They refuse it at its location, in
the existing family of flow slot refusal codes.

- **The doors:** `FlowSchema.parse` / `defineFlow()`, `defineStack`,
`objectstack validate`, `objectstack compile`, an artifact's parse, the
metadata save door's `flow` type schema, and `registerFlow`, which
parses `FlowSchema` first.
- **The code:** the existing `node-config-refused-by-contract`, `params:
{ nodeType, key }`. There is one refusal per undeclared key, anchored at
the key (`nodes.N.config.bogusKey`), and its message is the contract's
own sentence.
- **The edit:**
`packages/spec/src/automation/flow-node-config-refusals.ts`, the builtin
branch of `flowNodeConfigRefusals`. It judges key membership where
`builtinKeysJudged(nodeType)` holds. That is a builtin contract whose
type is in the spec's own schemaless class,
`SCHEMALESS_NODE_CONFIG_SCHEMAS`.
- **Why the narrow lift:** a schemaless descriptor publishes no
`configSchema`, so `registerFlow`'s undeclared-key walk skips it. Its
executor still parses the strict contract, so it refuses the node at
every run.
- **Unchanged:** `getBuiltinNodeConfigContracts()` keeps its 13 entries,
and no new code joins `FLOW_SLOT_REFUSAL_CODES`.
- **Premise corrected:** `builtinValueJudged`'s docblock said
"registration refuses an undeclared key against the descriptor". That
was false for exactly these two types. The docblock now says which door
owns which key.
- **Comments made true:** the `FlowSchema` header in `flow.zod.ts` and
the `node-config-refused-by-contract` docblock in
`flow-node-expression-paths.ts` now name each arm that judges key
membership: approval whole (objectstack-ai#21850), builtin values (objectstack-ai#21898), and
`script` / `subflow` keys.
- No `service-automation` source line moves, and there is no second key
check anywhere.

## Built-ins: which get the key refusal, and why (measured at
`15ec50e528`)

| type | in `getBuiltinNodeConfigContracts` | descriptor `configSchema`
| contract strict | executor parses it | key refusal here |
|:--|:--|:--|:--|:--|:--|
| `script` | yes | none | `strictObject` | yes (`screen-nodes.ts`
`parseNodeConfig`) | **yes** |
| `subflow` | yes | none | `strictObject` | yes (`subflow-node.ts`
`parseNodeConfig`) | **yes** |
| `decision` | no | none | `strictObject` | no: its executor reads
`conditions[]` raw | no. An undeclared key fails no run.
`decisionShapeRefusals` judges the `conditions` shape, not keys. |
| `wait`, `connector_action` | no | none | their contracts are the
FlowNode sibling blocks `waitEventConfig` / `connectorConfig`,
`strictObject` inside `FlowNodeSchema` | they read the sibling block,
not `config` | no. `FlowSchema` already refuses an unknown key in those
blocks. |
| `get_record`, `create_record`, `update_record`, `delete_record`,
`notify`, `http`, `screen`, `map`, `loop`, `parallel`, `try_catch` | yes
| yes | `strictObject` (all 11) | yes | no: the remainder, below |
| `assignment` | no | yes (keyValue map) | no: open top-level variable
names | no single contract | no |

## The remainder: the card stays open for it

Triage's direction step 2 covers every builtin whose executor contract
is strict. All 13 are. This PR takes the two schemaless ones, by the
seat's ruling `6050287134`.

- **Remainder 1, the 11 descriptor-`configSchema` builtins at the build
doors:** `get_record`, `create_record`, `update_record`,
`delete_record`, `notify`, `http`, `screen`, `map`, `loop`, `parallel`
and `try_catch`.
- They have the same gap at the build doors. Measured at this branch's
round-0 head `f281d801f` on `examples/app-showcase`, flow
`showcase_task_completed`, node `notify`, with `config.bogusKey: 1`:
    - `objectstack validate` exits 0;
- `objectstack compile` exits 0, and the artifact carries
`"bogusKey":1`;
- `registerFlow` refuses the same node: "Flow 'p' rejected: 1 undeclared
config key(s). … unknown config key `bogusKey` at config.bogusKey".
  - So boot drops the flow with a warning, and the build never says so.
- **Remainder 2, an open design choice, not decided here:** once the
spec arm covers those 11 types, who judges a builtin's undeclared key at
`registerFlow`? The spec arm pre-empts registration's descriptor walk,
and with it that walk's pinned prescriptions (`service-automation`
`config-unknown-keys.test.ts`).

## Census first (triage step 1): no writer found

| corpus | read at | `script` nodes | `subflow` nodes | with a key
outside the contract |
|:--|:--|:--|:--|:--|
| this repo: `examples/**`, `packages/platform-objects/**`,
`packages/apps/**`, `packages/create-objectstack/**` (templates),
`skills/**`, `content/docs/**` | `15ec50e528` | 6 | 2 | 0 |
| hotcrm, whole tree | `c9678036d9` | 0 | 5 | 0 |
| objectui flow designer `FLOW_NODE_CONFIG` | pin `a58626c88d` (same
file at objectui `main` `9990f9e122`) | form writes `function`,
`inputs`, `outputVariable` | form writes `flowName`, `input`,
`outputVariable` | 0 (its `timeoutMs` field writes the node; the five
retired `script` keys sit behind a `showWhen` no field satisfies) |
| objectui designer seeds `defaultNodeExtras` | `a58626c88d` | empty
`config` | empty `config` | 0 |
| objectui console preview samples | `a58626c88d` | 4 | 0 | 0 |

- **Method:** a TypeScript-AST scan for object literals carrying `id`
and `type: 'script'` / `'subflow'`, reading the keys of their `config`.
Code fences in `.md` / `.mdx` and `.json` files were parsed too.
- **Control:** over this whole repo, tests included, the same scan finds
103 nodes and flags 17. All 17 are fixtures:
  - 9 in the D2 conversion fixtures (`conversions/registry.ts`);
  - 5 in `lint` tests;
- 3 test-double keys in `service-automation` `engine.test.ts`, repaired
below.

## Doors, measured

- **`objectstack validate` / `compile`.** Built CLI at round-0 head
`f281d801f`, `examples/app-showcase` node `summarize` (`script`), one
edit: `function: 'summarizeCompletedTask' , bogusKey: 1,`.
- Control: validate exit 0, compile exit 0, and the artifact has no
`bogusKey`.
- With `bogusKey`: validate **exit 1**, compile **exit 2**, and no
artifact is written. Both print the refusal at path `nodes, 1, config,
bogusKey`.
- The mutation was made with `scripts/ablation-replace.mjs`: anchor 1 →
0, then restored to the HEAD blob, `git diff HEAD` empty.
- **`registerFlow`** (real builtin executors):
- `script` / `subflow` with `bogusKey` are refused at
`nodes.1.config.bogusKey`;
  - both controls register;
- a `script` `functionName` alias registers: it is converted before the
parse;
  - `http` `bogusKey` is still refused by the descriptor walk.
- **Pinned in `flow-builtin-node-config-keys.test.ts`** (19 tests):
- the refusal at `FlowSchema` (also inside a region body), `defineStack`
(`STACK_SCHEMA_INVALID` 422 at `flows.1.nodes.1.config.bogusKey`),
`ObjectStackDefinitionSchema`, the save door's `flow` type schema and an
artifact parse;
- the controls: no extra key; a descriptor type's key still left to
registration (`http`, `create_record`, `screen`); `decision`; a retired
`script` key keeps its tombstone path.
- **Reverse verification** at round 0, `builtinKeysJudged` mutated to
`return false`: 12 of 19 went red and the 7 controls held. It was
restored to the HEAD blob.

## Cross-lane fixtures repaired (claim revision `6050287134`)

- **`service-automation`, test only:**
- The doubles in `engine.test.ts` ("should execute unconditional
branches in parallel", "should fail when parameter type is wrong") and
in `input-schema-retry-parity.test.ts` now register under the type
`probe_step`, executor and nodes alike, never the builtin `script`.
  - The `function: 'noop'` filler went with them.
- `inputSchema` reads top-level config keys, which a real `script`
executor refuses.
- **`lint`:** `validateStackExpressions` keeps the pre-conversion
tolerance it declares.
- The filter in `validate-expressions.ts` also hands the judge's
undeclared-key refusal for a `script` node's `functionName` alias to the
callable check, which already reads that alias.
- A new pin holds that every other undeclared `script` key is still
refused there (`bogusKey`, on a canonical and on an alias source).
  - The changeset gains `'@objectstack/lint': patch`.

**Red → green.** Round 0 at `f281d801f` had 4 red in
`service-automation` and 2 red in `lint`. All six now pass at
`ef0dfb44d`:

- `engine.test.ts` › "should execute unconditional branches in parallel"
✓
- `engine.test.ts` › "should fail when parameter type is wrong" ✓
- `input-schema-retry-parity.test.ts` › "never executes a node whose
config mis-types its declared inputSchema — on ANY attempt" ✓
- `input-schema-retry-parity.test.ts` › "still retries a VALID flow
normally …" ✓
- `validate-expressions.test.ts` › "accepts a script node that names a
callable via the functionName alias" ✓
- `validate-expressions.test.ts` › "a `script` with no `function` is ONE
finding, the callable check's …" ✓

## ADR-0087

- **D3 entry:**
`18.flow-script-subflow-config-undeclared-keys-refused.ts`.
- **Rationale fragment:** step 18 `order: 87`, re-read on `origin/main`
`8fc50b764` (the merged base): its highest order is 86, and open PRs
objectstack-ai#22103 and objectstack-ai#22094 hold 86 and 85 at their heads.
- **Registry:** `registry.ts` was regenerated by
`gen:migration-registry`.
- **Changeset:** `@objectstack/spec` `minor`, BREAKING, with the
`registered` marker, plus `@objectstack/lint` `patch`.
`.changeset/pre.json` is absent on `origin/main`.

## Merge

- `origin/main` `8fc50b764` was merged by `scripts/pm/os-regen-merge.sh`
as merge commit `521e16f1f`, with parents `f281d801f` and `8fc50b764`.
There were no conflicts.
- Step 2 took `main`'s side of the generated artifacts that `main`
moved, and there was nothing more to commit.
- After a spec build on the merged tree, `check:generated` reported all
15 artifacts up to date. The delta against `main` was exactly this PR's
5 round-0 files.
- `gen:schema` was not run.
- Round 1's edits are commit `ef0dfb44d` on top.

## Verification at `ef0dfb44d`

- **Spec:**
  - `check:generated`: all 15 artifacts up to date.
- The pin files `flow-builtin-node-config-keys.test.ts` and
`flow-builtin-node-config-values.test.ts`: 63/63.
- Round 0's whole spec suite at `f281d801f`: 624 files, 18628 tests
passed.
- **lint:** the whole suite, 123 files, 5689/5689.
- **service-automation:** the whole suite, 175 files, 2120/2120. The
first attempt collided with a concurrent gate run that left
`@objectstack/spec/automation` unresolvable for 17 files; it was re-run
alone.
- **Typecheck:** `@objectstack/lint` exit 0 and
`@objectstack/service-automation` exit 0, both including
`check:test-typecheck`, over a closure rebuilt with declarations.
- **eslint, narrowed** (`--no-inline-config --format json`) over the
diff's 10 `.ts` files: 10 files, 0 errors, 0 warnings. The population is
read from the json count. `parserOptions.project` and `projectService`
are null for each file, so there is no type-aware linting and no
untouched file's verdict can move.
- **Gates:** `dispatch-gates --commands --repo
objectstack-ai/objectstack` derives 92 commands from the merged head.
All ran, with exit codes captured before any pipe. The `--ran`
reconciliation reads 91 run, 1 NOT-MEASURED, 0 UNRUN
(`check:dts-closure` recorded at its re-run).
  - 91 exit 0.
- `check:dual-build-cjs-loads`: exit 3, PREREQUISITE NOT MET (packages
outside this worktree's build closure have no `dist`). It is read from
CI, as are round 0's `cli` published-subpath pins.
- `check:dts-closure` first exited 1, naming exactly the 19 closure
packages built with `OS_SKIP_DTS=1` for the test runs. Re-run after the
closure was rebuilt with declarations, it exits 0: 169/169 declaration
files across 71 built packages.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
… item's stored self (objectstack-ai#22133)

Fixes objectstack-ai#22118
Clause-②: no (the fix restores the gate's published contract:
`runtime-gate.ts` "the gate blocks new writes, never stored rows" and
`reference-integrity-suite.ts` "a stored object already in violation is
never charged to someone else's write"; the refusal it removes is one
that text already denies)

## What changed

The runtime publish gate judged a stored sibling's finding against a
baseline that had dropped the written object's stored self. A label-only
save of a master was refused (422) for a stored detail the author never
touched. The gate now also judges the stored universe on an update into
a context collection.

`packages/lint/src/runtime-gate.ts`:

- `buildRuntimeWriteSnapshotSet` (module-level, on neither package
entry) builds the baseline and candidate as before. On an UPDATE into a
context collection it also builds `stored`: the baseline with the
written item's stored self put back, at the slot the item takes in the
candidate. Every sibling sits at the same index in all three snapshots.
- `runRuntimeAuthoringRules` subtracts `baseline` findings, as before.
It also subtracts `stored` findings, but only those whose path
positively names another entry (`isLocatedOnAnotherEntry`). Findings
located on the written item are never read from that pass, so they are
judged as before.
- `isLocatedOnAnotherEntry` reads a location off the finding's path
spelling, never off its rule. It reads the three spellings the door's
rules use: positional `objects[3]…`, name-keyed `objects.acme_invoice…`,
and an object named in prose (`object 'fx_detail' · …`, the path the
expression and autonumber rules emit). A path it cannot locate keeps the
previous verdict. That direction can leave a sibling's finding charged
to a write; it can never wave the written item's own finding through.
- `buildRuntimeWriteSnapshots` is on both package entries, so its
signature is byte-identical to `main`. It returns the baseline/candidate
pair read off the new builder. A pin asserts it still returns exactly
those two keys.
- A create, and a write of a type that is not a context collection, run
exactly the two passes they ran before.

`.changeset/22118-gate-baseline-stored-self.md`: patch,
`@objectstack/lint`, with `Clause-②: no`.

## Readings (Zone 2)

All readings are taken at the door's own snapshot shape on this branch,
unless they say otherwise.

- **H1: holds.** On `main` `51290bca`, the gate charged a label-only
`fx_master` save with `object-field-ref-unknown @
objects.fx_detail2.fields.m.lookupColumns[0]` and with
`expression-invalid @ object 'fx_detail' · field 'qty' readonlyWhen`.
The baseline printed as `[fx_account, fx_detail2]`, with the master
absent. The fingerprint is `rule · where · path · message`. Sibling
slots are identical across the passes. What differs is presence:
`lookupColumns` against an absent target is unknowable
(`validate-object-field-refs`, objectstack-ai#20432), and the `parent` traversal
cannot resolve. So the finding is new against a baseline without the
master.
- **H2: holds.** On a create there is no stored self and no `stored`
snapshot, so the verdict is unchanged. It is pinned: creating the master
beside the stored detail is still charged with the detail's finding,
because the stored universe never held it.
- **H3: holds.** Two writes that newly break a sibling are still
refused, one per spelling:
- Positional: the write removes `code`, which the detail's
`lookupColumns` names.
- Prose: the write turns `status` into a lookup, so the detail's
`readonlyWhen: "parent.status.name == 'x'"` now reads through a
reference.
- At the door, the positional control answers `{ code:
'INVALID_METADATA', status: 422 }`.
- **H4, by type:**
- **permission: the same defect, at advisory tier.**
`security-master-detail-ungranted` is silent while no permission set is
authored. A label-only re-save of a tenant's only set was therefore
charged a stored detail's warning: `objects.fx_line.fields.hdr`,
measured red under the reversal below. With this change it carries none;
creating the same set still reports it. Covered, because the
construction is the one shared line.
- **book and dataset: no instance.** The book door runs
`validateSecurityPosture` and `validateSecurityRoleWord`. The dataset
door runs `validateDatasetMeasureAggregates` and
`validateReferenceIntegrity`. Each judges the written entry against
`permissions` or `objects` and resolves nothing into a sibling of its
own collection, so the stored pass cancels nothing there today. See the
Acceptance notes.

## The contract sentence (narrowed to what holds)

The module header now states what "added" means. The bare sentence "the
gate blocks new writes, never stored rows" used to sit in the builder
docblock. It now heads a precise list, every item of which the code
does:

- a finding located on another entry is the write's only when neither
the universe without the item nor the stored universe holds it;
- a finding located on the written item itself is the write's whenever
the universe without the item does not hold it, whatever the stored row
held ("re-saving a row is writing it");
- a finding whose path names no locatable entry is judged as one on the
written item.

Other changed lines:

- "runs the rules TWICE" became "on the context alone and again with the
item grafted in".
- The cost line now says "two passes … three on an update into a context
collection".
- The `restoredCredentialPaths` comment states that the stored pass can
match the item's slot, and why that is inert.

The `reference-integrity-suite.ts` sentence ("a stored object already in
violation is never charged to someone else's write") sits in a paragraph
about the FLOW snapshot, where it was and remains true. It is untouched.

## Tests

- `packages/lint/src/runtime-gate.stored-self-baseline.test.ts` (new; it
keeps off `runtime-gate.object-writes.test.ts`, which PR objectstack-ai#22103 edits):
25 cases.
- The two measured cases resolve. Each has a non-vacuity check: the
finding is absent from the baseline and present in the candidate and in
`stored`, at the same raw path.
  - Both H3 controls.
- The written-object control in all three spellings. Each check confirms
the stored self carries the identical finding.
  - Create, and permission relabel/create.
- Snapshot shape, and that the published builder returns only baseline
and candidate.
- Twelve location-reader cases, including five unlocatable spellings,
each answering `false`.
-
`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`,
new block `objectstack-ai#22118`: 4 cases through the real `saveMetaItem`, with the
registry holding the stored universe.
  - Both measured cases save, and the row lands.
- The sibling-breaking write and the written object's own finding are
each refused with `{ code: 'INVALID_METADATA', status: 422 }` and the
issue's path, and nothing lands.

## Reverse verification (one-off, at `8d2a3c3d`, no permanent ablation
file)

Both legs went through `scripts/ablation-replace.mjs`, with a literal
anchor that must hit (x1 to x0, blob `625a165b` to the mutated blob).
Each leg ran `pnpm --filter @objectstack/lint build` and
`scripts/ablation-dist-preflight.mjs` before measuring: the marker was
hit in `dist/index.{js,cjs}` and `dist/runtime.{js,cjs}`. The door suite
reads `@objectstack/lint` through `dist/`.

- **Leg 1, the reversal** (stored pass disabled, which is `main`'s
behaviour):
- Lint: 3 failed / 22 passed. The failures are the two measured cases
and the permission relabel.
- Door: 2 failed / 2 passed. Both cases answered the card's own
refusals: `object/fx_master failed author-time validation: 1 issue —
objects.fx_detail2.fields.m.lookupColumns[0] [object-field-ref-unknown]`
and `… object 'fx_detail' · field 'qty' readonlyWhen
[expression-invalid]`.
  - Both controls stayed green.
- **Leg 2, the location reader ablated** (every stored-pass finding
cancels):
- Lint: 3 failed / 22 passed. The failures are the written-object
control in all three spellings.
- Door: 1 failed / 3 passed. The failure is the written-object control.
- **Restore, after each leg:** blob equal to HEAD, `git diff HEAD`
empty, `dist/` rebuilt with the marker absent from all 14 built files,
and the tree clean.

## Local verification at `8d2a3c3d`

- `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2`: 124
files, 5705 tests passed.
- `pnpm --filter @objectstack/lint typecheck` (`tsc --noEmit` plus the
test layer): OK. No new test-typecheck signature.
- `pnpm --filter @objectstack/metadata-protocol exec vitest run
--maxWorkers=2`: 221 files passed, 3 skipped; 28243 tests passed, 19
skipped.
- `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0.
`--listFiles` includes the edited test file (1 hit; 224 test files in
the program).
- Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 63 commands from the merge base.
Reconciled with `--ran`: 63 derived, 62 run green, 1 NOT MEASURED.
- `check-plugin-teardown-shape --self-test` and
`check:lean-entry-closure` first answered PREREQUISITE NOT MET: a
fixture commit was outside the shallow clone, and `objectql` had no
`dist/`. Both were re-measured green after fetching the commit and
building `objectql`.
- **NOT MEASURED:** `check:dual-build-cjs-loads`, reason: it reads the
built output of every workspace package (about 68 had no `dist/` here).
That is CI's run. A narrowed direct reading:
`packages/lint/dist/runtime.cjs` and `index.cjs` load, `runtime.cjs`
exports the same six names, and neither entry exposes
`buildRuntimeWriteSnapshotSet` or `isLocatedOnAnotherEntry`.
- Lint, narrowed to the 3 touched TS files with `eslint
--no-inline-config --format json`: 3 files, 0 errors, 0 warnings.
`eslint.config.mjs` never enables type-aware linting (0 hits for
`parserOptions.project` or `projectService`, and the config says so in
prose). So this diff cannot move the verdict for any untouched file. The
full `pnpm lint` run is CI's.

## Acceptance notes

- **book / dataset (H4):** they share the construction because it is one
line. A per-type exception would be a second policy beside the one
differential, and the next rule that judged a sibling book or dataset
against the written one would re-create this defect silently. On those
updates the stored pass costs one more rule pass, with no measured
effect today.
- **Unlocatable spellings keep the previous verdict.** No door rule
emits a double-quoted `object "x"` path today (the double-quoted form
appears only in `where` beside a positional path). A rule that did would
not get this relief until the reader learns that spelling.
- **Observation, not filed:** in this branch's probe, a detail's
`readonlyWhen: "parent.status == 'x'"` drew no door finding when the
master lacked `status`. Whether any surface judges field existence
through `parent` was not measured. Carrier: none.
- **Overlap:** PR objectstack-ai#22103 edits `runtime-gate.object-writes.test.ts`.
This PR does not touch that file.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
… verdict (objectstack-ai#22032 pass 3) (objectstack-ai#22151)

Part of objectstack-ai#22032
Clause-②: no (narrowing)

This is pass 3 of objectstack-ai#22032: a field option's `visibleWhen`. Pass 4 (the
object's own action predicates) stays fenced, and the card stays open
for it.

## What changes

**The object save door gives the build's verdict on a field option's
`visibleWhen`.** `formulas.mdx` says "the same `validateExpression`
validator backs `os build` and metadata registration". After pass 2 the
object door ran the whole field walk except the per-option loop, which
kept its own guard. So an object whose option carried `visibleWhen:
'amount > 1'` (a bare field reference) still saved with a 200, while `os
build` refused it at error. The server's option check cannot evaluate
such a predicate and fails open (logged, allowed through), so the gate
it declares is never enforced (read from `evaluateOptionVisibility` in
`packages/objectql/src/validation/rule-validator.ts`).

- **The lift is the guard, nothing else (H1 held).** On `origin/main`
`8fc50b7647` the loop read `for (const [oi, opt] of (objectWrite ? [] :
recordsOf(f.options)).entries())` (`validate-expressions.ts:2072`),
under a `[objectstack-ai#22032] FENCED on an object write (pass 3 …)` comment. It now
reads `recordsOf(f.options)`. On an object write the loop runs at the
build's own position in the field walk, so the door gets both of the
option's checks:
  - `check(optionWhere, opt.visibleWhen, objectName, 'record')`;
- `refuseFieldTraversal(optionWhere, 'option visibleWhen', …)`, the
refusal of a read through a reference field on `record` or `previous`.
- **`current_user` keeps the build's two verdicts (H2).** An option's
evaluator binds the acting user (ADR-0068 D1), so the build accepts
`current_user` on an option and refuses it on the field-rule slots one
level up. The door now gives both verdicts as the build does. The
showcase's role gate, `'org_admin' in current_user.positions`, still
saves on an option, and the same text on the field's own `visibleWhen`
is refused at both doors. Both are pinned.
- **No registry change (H3 re-verified).** The
`validateStackExpressions` entry declares `runtimeTypes: ['flow',
'action', 'hook', 'object']` (`authoring-rules.ts`), and
`runtimeAuthoringRulesFor('object')` (`runtime-gate.ts`) dispatches it.
`runtime-gate.ts` is untouched.
- **Docblocks made true.** `StackExpressionOptions.runtimeWriteType` now
names four admitted passes and one fenced pass.
`AuthoringRuleContext.runtimeWriteType` in `authoring-rules.ts`, the one
line that reaches a built `.d.ts`, names the per-option pass. The
function-head comment and the field walk's two comments move with it. In
`authoring-rules.ts` the registry entry gains a `[objectstack-ai#22032, pass 3]`
measurement comment, as passes 1 and 2 added theirs. Comments in four
sibling test files are corrected so that none of them still says option
`visibleWhen` is fenced.
- **The door's verdict is the build's finding (H4).** The door's 422
issue and `runAuthoringRules('build', …)` give the same rule
(`expression-invalid`), location (`object 'fx_option' · field 'province'
option 'zj' visibleWhen`), path, message and hint. The pins compare
these key by key.
- **No code change in `packages/metadata-protocol`.** Only its test file
gains the door-level pins.

## Pins

- **Lint door:**
`packages/lint/src/runtime-gate.object-option-visibility-writes.test.ts`
(new, 14 tests).
- LIT: one refused body per finding the pass gives. These are a bare
`amount > 1`, an unregistered `sqrt(record.amount) > 1`, an unknown
field `record.amont > 1`, a syntax error `record.country ==`, and the
traversal refusal through `record.account` and through
`previous.account`. Each is located at the option and asserted on its
named subject.
- CONTROL (the still-accepted cases): the `record.country` cascade, the
showcase's `current_user.positions` role gate, a grant check plus role
gate (`current_user.can(…) && …`), and a reference compared as a value
(`record.account != null`). Each is clean at the door and at the build.
- CONTRAST: `current_user` on the option and on the field's own
`visibleWhen` in one body. The build and the door both give exactly one
finding, at the field slot.
- PARITY: for each refused body, the door's findings equal the build's.
- The differential: a stored sibling's broken options are not this
write's to answer for.
- **The fence (enumeration pin)** in
`packages/lint/src/runtime-gate.object-formula-writes.test.ts`. The
fenced site is now pass 4's alone (an action `visible`). The option
`visibleWhen` site moves to the lifted sites, beside the validation rule
and the `requiredWhen`. The build flags all four sites. The object door
flags the three lifted sites in the build's order, and
`runStackExpressionPasses` on an object write returns exactly the
build's findings for the admitted passes.
- **Protocol door:** a new pass-3 block in
`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`,
through the real `saveMetaItem`, `publishMetaItem` and
`publishPackageDrafts`.
- (a) A bare reference, an unregistered function and a read through a
reference field are each refused on an active save. The answer is a 422
`INVALID_METADATA` carrying the build's located finding, and nothing
lands.
- (a) The card-shaped body is refused on a draft's promotion and on a
package draft publish (`outcome: 'refused'`, `failed` naming the object
with `INVALID_METADATA`, the row left a draft). The draft saves
themselves still succeed.
- (b) The showcase's cascade and its `current_user` role gate still
save, and the row lands active. The role gate rides every refused body
too, which each yield exactly one finding.
- (d) For each refused body, the door and `os build` give the same
finding on rule, where, path, message and hint.

## Reverse verification (one-off, from committed HEAD `23ce6c494c`)

- **What was mutated.** `scripts/ablation-replace.mjs` (wrap mode) put
the guard back on the option loop. The new text was `const
ablationFence22032p3 = objectWrite;` followed by `for (const [oi, opt]
of (ablationFence22032p3 ? [] : recordsOf(f.options)).entries()) {`. The
anchor was hit once, 1 to 0, and the blob went `879fcb828037` to
`73bd026d1554`. The outer script carried `trap restore EXIT INT TERM` on
the absolute path.
- **Rebuild and dist proof.** `@objectstack/lint` was rebuilt, and
`ablation-dist-preflight` found the marker in 4 built files.
- **Lint suites (source): 9 failed and 14 passed, as predicted.**
- Red: the 6 LIT tests, PARITY, and the two fence tests that assert the
lifted sites.
- Green: the 4 CONTROL tests, CONTRAST, the differential, the registry
test, the build-flags-each-site test and the six formula-door tests.
- **Protocol pass-3 block (dist-mediated): 6 failed and 1 passed, as
predicted.** Red: the three (a) saves, (a) on promotion, (a) on package
publish, and (d). Green: (b).
- **Restore.** The tool restored the file: blob `879fcb828037` equals
HEAD, and `git diff HEAD` is empty. The whole tree had 0 changed paths.
Lint was rebuilt, and `--absent` found the marker gone from all 14 built
files. Both suites went green again: lint 23 of 23, and the protocol
file 99 of 99.

## Measurements

- **Corpus first: the stop condition was not met (H5).** Every object
this tree ships was judged before the door changed. That is every
`*.object.ts` under `packages/**` and `examples/**` (111 files) plus the
two `app-multi-package` sub-stacks: 118 objects in 18 groups. Each was
judged at the raw shape and at the `ObjectSchema.parse` shape (0 parse
failures), with its own group as context.
- 5 option predicates on 2 fields of 1 object, all on
`showcase_cascade`: `province`'s four `record.country` cascades (`zj`,
`gd`, `ca`, `tx`) and `tier`'s `restricted` role gate (`'org_admin' in
current_user.positions`).
- At base `8fc50b7647`: 0 build errors and 0 build warnings for the
option pass, through `validateStackExpressions` and through
`runAuthoringRules('build')`, at both shapes. There were 0 door
expression findings over all 118 objects.
- Non-vacuity: the 5 sites are judged. Mutating one cascade to a bare
`country` and the role gate to `sqrt(record.amount) > 1`, in a copy,
gave 2 build errors at those two options.
- At head `23ce6c494c`, and again at the merged heads `06d3cad963` and
`3c1d3262ee`: 0 door errors and 0 door advisories over every object,
through `runRuntimeAuthoringRules` with type `object`, at both shapes.
The harness passes each object's own group as context, so at
`3c1d3262ee` every one of those saves is an update and also runs the
stored-universe pass that objectstack-ai#22118 added.
- Positive control in the same harness (an option `visibleWhen: 'amount
> 1'`): 1 build error at every head. The door gave 0 at base and 1 at
head.
- **Which doors newly answer 422.** The active publish save, a draft's
promotion, and a package draft publish. Each was measured through the
real methods above. A draft save stays ungated, measured by the same
pins.

## Clause-② (measured)

- **Accept set: narrowing.** An object write in publish mode answered
200 for an option `visibleWhen` the validator refuses. It now answers
422 on the three doors above.
- **Built entry declarations.** In `@objectstack/lint` one doc comment
moves (`AuthoringRuleContext.runtimeWriteType`).
`StackExpressionOptions` and `runStackExpressionPasses` are not in the
built declarations. No exported signature moves.
- **Changeset.**
`.changeset/22032-object-save-door-option-visible-when.md` covers
`@objectstack/lint` and `@objectstack/metadata-protocol` as `minor`. It
carries `fix(lint)!`, the `Clause-②: no (narrowing)` line, a BREAKING
section with the remedy, and ADR-0087 `not-required
(no-migration-prescription)`. `@objectstack/metadata-protocol` is listed
as passes 1 and 2 listed it, although no code moves there: its save,
promotion and package-publish doors are where the BREAKING behaviour can
be seen. `.changeset/pre.json` is absent on `origin/main` (read at
`8fc50b7647`, 2026-10-08T01:54Z, at `ef1fcb26a2`, 2026-10-08T02:40Z, and
at `7ef50a4fbb`, 2026-10-08T03:39Z), so the bump is `minor` with the
BREAKING banner, as in passes 1 and 2. The changeset says it supersedes
the earlier objectstack-ai#22032 entries' line that option `visibleWhen` is not judged
at this door.

## Merges

- **`06d3cad963`** merges `origin/main` `ef1fcb26a2` (PR objectstack-ai#22103),
through `scripts/pm/os-regen-merge.sh`. It was clean.
- **`3c1d3262ee`** merges `origin/main` `7ef50a4fbb` (parents
`06d3cad963` and `7ef50a4fbb`), through `scripts/pm/os-regen-merge.sh`.
That brought PR objectstack-ai#22129 (objectstack-ai#21982), PR objectstack-ai#22094, PR objectstack-ai#22134, PR objectstack-ai#22133
(objectstack-ai#22118, the gate's object-write baseline keeps the written item's
stored self), PR objectstack-ai#22126 and PR objectstack-ai#22140.
- `validate-expressions.ts` auto-merged. PR objectstack-ai#22129's edit is in the flow
`script` / `subflow` region; the option loop's lift is unchanged.
- One conflict:
`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`,
one hunk. Both sides had added a new top-level `describe` block at the
same place, after the pass-2 block. It was resolved by stacking: the
pass-3 block first, then objectstack-ai#22118's stored-self block, and every line of
both was kept. Against `7ef50a4fbb` the file shows only this branch's
lines; against `06d3cad963` it shows only objectstack-ai#22118's 114 lines.
- No generated path was touched by this branch. The rerun of the script
took `origin/main`'s side of every generated path main moved, and that
left nothing to commit.
- This branch's own changes are the same before and after the merge: for
each of the 9 files, the added and removed lines against the merge base
are identical. The delta against `7ef50a4fbb` is 9 files, +467 / −45.
- **The interaction with objectstack-ai#22118, measured.** objectstack-ai#22118 adds a third,
stored-universe pass on an update into a context collection. A finding
whose path names no entry is judged as one on the written item. The
expression rule's paths are `where` strings, so an option finding is
always judged that way.
- A probe ran the real gate (`runRuntimeAuthoringRules`, type `object`)
over 20 cases, against this branch's lint source before the merge
(`06d3cad963`) and after it (`3c1d3262ee`). The cases are a create, an
update over a stored self that is broken and over one that is clean, and
a stored sibling that is broken, each for three refused bodies, plus two
still-accepted bodies.
- The verdicts are identical, case for case. Re-saving a broken option
is still refused, including over a broken stored self, because re-saving
a row is writing it. A clean save over a broken stored self passes. A
broken sibling is never charged.
- The pass-3 pins (differential, PARITY, LIT, CONTROL, CONTRAST) and
objectstack-ai#22118's pins all pass at `3c1d3262ee`: lint 48 of 48 across the three
files, and the protocol file 103 of 103.
- This matches the code. The option pass reads only the written object's
own field index, and binds `record` and `previous`, never `parent`, so
the stored universe has nothing extra to offer it.

## Tests and gates (all at `3c1d3262ee`, the merge of `origin/main`
`7ef50a4fbb`)

- **`main` moved during the run (H6).** PR objectstack-ai#22103 landed as `ef1fcb26a2`
and touched two files this pass edits, `authoring-rules.ts` and
`runtime-gate.object-writes.test.ts`, in other hunks. It was merged with
`scripts/pm/os-regen-merge.sh` as a merge commit. The merge was clean,
and no generated path was taken from either side. After the merge: `pnpm
install --frozen-lockfile`, a full turbo build (72 tasks), and
`@objectstack/spec check:generated` ("All 15 generated artifacts are up
to date"). objectstack-ai#22118 and objectstack-ai#21982 had not landed at that read
(2026-10-08T02:40Z). Both have since landed, and they are merged at
`3c1d3262ee` (see Merges). After that merge the same sequence ran: a
full turbo build (72 tasks) and `check:generated` ("All 15 generated
artifacts are up to date").
- **`@objectstack/lint`:** 125 files and 5729 tests passed. `typecheck`
exit 0, with its test-typecheck included (`--listFiles`: the five
touched or new lint test files are in the `tsconfig.test.json` program).
- **`@objectstack/metadata-protocol`:** 221 files passed and 3 skipped;
28260 tests passed and 19 skipped. `typecheck` exit 0 (`--listFiles`:
the door test file is in the program).
- **Consumer readings.** Every package was built at the head named.
- `@objectstack/objectql`, 8 files and 282 tests passed:
`publish-package-drafts-response-conformance`,
`save-meta-response-conformance`, `publish-meta-response-conformance`,
`plugin.integration`, `engine-field-predicate-fault`,
`engine-option-permission-predicate`,
`validation/rule-validator.option-visibility` and `engine`.
- `@objectstack/rest`, 11 files and 197 tests passed: every
`meta-object-*` file and `meta-publish-package-scope`.
- `@objectstack/cli`, 3 files and 16 tests passed, run as `--project
integration` because the tier predicate puts them there:
`validate-field-predicate-traversal` (which asserts an option
`visibleWhen` `expression-invalid` finding),
`authoring-rule-command-parity` and `verify-author-time-stage`. The
three nightly-tier `*.e2e` files that assert `expression-invalid` are
left to CI.
- The search for other consumers covered every test file in the
repository carrying `visibleWhen`, matched against the save-door entry
points. Only the two packages above save an option `visibleWhen` through
a door.
- **Gates.** `dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 63 commands, the same set at
`23ce6c494c`, `06d3cad963` and `3c1d3262ee`. At `3c1d3262ee` all 63 exit
0, each exit code captured before any pipe. `--ran` reconciles 63
derived, 63 run, 0 NOT-MEASURED and 0 UNRUN, with an exit code recorded
for each. The printed artifact-roster block names 51 families, and all
51 ran at `3c1d3262ee`, each with exit 0. That is 47 in the same
battery, one (`check:engine-double-contract`) already among the 63, and
the three PR-scoped ones (`check-partof-closing-keyword`,
`check-closing-target-claim`, `check-single-claim-paths`) run with this
PR's number and this body. The same 63 also ran at `06d3cad963`, all
exit 0. At `23ce6c494c`, before the merge, the same 63 ran: 61 exited 0
on the first run. `check:dual-build-cjs-loads` and
`check:lean-entry-closure` first exited 3 (PREREQUISITE NOT MET: no full
build) and exited 0 after the full build.
- **ESLint, narrowed to the 8 touched TypeScript files**
(`--no-inline-config --format json`): 8 files, 0 errors and 0 warnings.
Each file is matched by `eslint.config.mjs` (`--print-config`), and none
was ignored. The config enables no type-aware linting (no
`parserOptions.project`), so this diff cannot move the verdict on any
untouched file.

## Acceptance notes

- **Out of this pass, reported for the seat: an option `visibleWhen`
reading `parent` gets no verdict at the build, so none at the door
either.**
- Measured at `23ce6c494c` with scratch tests that were deleted
afterwards. Through the real `saveMetaItem`, an object whose option
carries `visibleWhen: "parent.status == 'closed'"` saved with success,
and the row landed `active`. `runAuthoringRules('build')` gave 0
findings.
- The server's option check (`evaluateValidationRules`, authenticated
caller, the option picked) then logged `failed to evaluate
(authenticated caller) — allowed through`, with `Unknown variable:
parent`, and admitted the value. The option evaluator binds `record`,
`previous`, the user and permissions only.
- This is a gap in the build, which the door now mirrors. This card's
contract is parity with the build, so it is not changed here.
- **A code comment names an old spelling.** The comment above the option
loop calls the showcase's legal usage `'admin' in
current_user.positions`. The showcase now writes `'org_admin' in
current_user.positions`, and the pins use that spelling. The comment is
not changed here: per the contract review, it rides pass 4.
- **The pending objectstack-ai#22032 changesets.** Pass 1's and pass 2's changesets
each list option `visibleWhen` under "Unchanged", which was true at
their heads. This pass's changeset says it supersedes that line rather
than editing them, the same way pass 2 left pass 1's changeset alone.
Per the contract review, reconciling those lines rides pass 4.
- `formulas.mdx` could name the object save door. That would be a docs
addition, not a correction of a false line.
- **Contract review.** Triage's grade asks for one per pass. A PASS is
on record for head `06d3cad963` (`6051573476`). The head has since moved
to `3c1d3262ee` by the merge above, so the review for this head is the
seat's.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…al rung moves to the tenant-less sys_platform_setting (ADR-0131 D7) (objectstack-ai#22166)

Part of objectstack-ai#15207

Clause-②: yes (widening: a new platform object `sys_platform_setting`,
and a new name if `packages/spec` exports one; the global rung's storage
narrows on `sys_setting`. The dev measures the built declaration
closure.)

The line above is the claim's (`6049595369`), copied verbatim. The
measured arm is in the changeset: `Clause-②: yes (narrowing)` — the diff
widens (a new object, `SysPlatformSetting` and
`CONFIG_CHANGE_GLOBAL_OBJECT_NAME` exported, a new name in
`PLATFORM_OBJECTS_BY_PACKAGE`) AND narrows (the `global` option of
`sys_setting.scope` retires, and the global rung's storage leaves
`sys_setting`). `@objectstack/spec`'s exported TYPE surface does not
move: the name list is data, and the new ADR-0087 entry and rationale
fragment live in the generated registry.

## Scope: item (3) only

This PR builds scope item (3) of objectstack-ai#15207: the `scope: 'global'` rung of
`sys_setting` leaves the tenant-scoped object (ADR-0131 D7, §6 Q3) for a
tenant-less `sys_platform_setting`, and the settings cascade reads the
new source. Item (1) landed as PR objectstack-ai#22107. Items (2) and (4) are not
here, so objectstack-ai#15207 remains open. No stored row moves in this PR: existing
global rows move in the v18 operator ceremony (C7, objectstack-ai#15211), per ADR-0131
D14.

## What changes

- **`sys_platform_setting`**
(`packages/platform-objects/src/system/sys-platform-setting.object.ts`),
registered by the settings service beside `sys_setting`:
- one row per `(namespace, key)` for the deployment: `unique: 'global'`
on `(namespace, key)`;
- the value and encryption columns of a `sys_setting` row: `value`,
`value_enc` (read-only), `encrypted`, `locked`, `locked_reason`,
`updated_by` (read-only);
- no `scope`, no `user_id`, and no organization column (`systemFields: {
tenant: false }`);
- governed by object permission (D7): `requiredPermissions:
['manage_platform_settings']`; generic API `get` / `list` only.
- **`SettingsService`** (`settings-service.ts`):
- a write at a key declared `scope: 'global'` lands in
`sys_platform_setting`, keyed `(namespace, key)`, never in
`sys_setting`;
- the cascade's global rung is read from `sys_platform_setting` alone.
Every `sys_setting` read now names its rungs (`$or` over `tenant` /
`user`, or `user_id` / `tenant`), so a `scope = 'global'` row a pre-v18
database still holds there is not a second source. There is no fallback
read and no boot-time move (D14);
- the rank table, the lock check, `SpecifierScope` and `source:
'global'` are unchanged;
- the global rung does not depend on the user, so `getMany` reads it
once per call: `sys_setting` keeps its objectstack-ai#10826 bound (at most two reads),
plus one `sys_platform_setting` read.
- **`config_change` audit row**: a global-scope change now names
`sys_platform_setting` (`CONFIG_CHANGE_GLOBAL_OBJECT_NAME`, exported
beside `CONFIG_CHANGE_OBJECT_NAME`). A tenant- or user-scope change
still names `sys_setting`. Without this, the row would name a table the
value is not in.
- **`sys_setting.scope`** no longer declares `global` (H6).
`sys_setting_audit.scope` keeps it, because the audit writer records the
changed key's scope and a global change is still a change. The
translation bundles drop the retired leaf, and the es-ES echo ledger
drops its row (47 to 46 echoes).
- **`os secret orphans` / `os secret rewrap`**
(`packages/cli/src/utils/secret-reference-union.ts`): the settings
family of the `sys_secret` reference union reads BOTH holders,
`sys_setting.value_enc` and `sys_platform_setting.value_enc`. If either
cannot be read, the whole family gaps (H3).
- **ADR-0087**: one D3 semantic entry, `sys-setting-global-rung-moved`,
and one step-18 rationale fragment. The registry is regenerated.
`spec-changes.json` and the upgrade guide do not move, because step 18
is not projected yet.
- **Regenerated census artefacts**:
- platform-object tenancy census: 83 to 84 registered objects, out of
reach 33 to 34, `systemFields.tenant: false` 8 to 9;
- tenant-audit census: two sites moved from unreadable options to
readable, because the `as any` spread of `bypass` is gone (171 to 173
threading a tenant context);
- query-options erasure baseline: `settings-service.ts` 2 to 1, a
ratchet down.

## Readings (measured at `51290bca2c`, re-checked after merging `main`)

**H1 holds: nothing moves to configuration.** A census of every
registered manifest (the built `builtinSettingsManifests` plus
objectql's `lifecycleSettingsManifest`) found 109 keys at the global
rung in seven namespaces: `auth` 29, `ai` 35, `storage` 11, `mail` 10,
`sms` 10, `knowledge` 10 and `lifecycle` 4.
`lifecycle.retention_overrides` is the one tenant key in a global
manifest. Every one of the seven requires `manage_platform_settings` to
read and to write through the door, so every one is edited live in
Setup. Their in-tree consumers re-read on change:
- `plugin-auth` and `organizations` use `getNamespace('auth')`, and
plugin-auth re-applies on `subscribe('auth')`;
- `plugin-email` (`mail`), `service-sms` (`sms`) and `service-storage`
(`storage`) each re-apply on `subscribe`;
- `lifecycle` is read on every sweep;
- `ai` and `knowledge` have no in-tree value reader beyond their `test`
actions, which `runAction` resolves live.

No key is boot-read only, so there is no `open_questions` entry for a
configuration move.

**No writer attributes a global row to an organization (the stop
condition did not fire).**
- `SettingsService.setMany` is the only writer of a settings row. It
writes under `{ isSystem: true }`, with no `tenantId` and a row that
names no organization.
- `sys_setting` is `unclassified` in the platform-object tenancy
inventory, so `resolveSystemInsertOrganization` derives nothing.
- The new pin writes a global key with a writer context of `tenantId:
'org_1'`, and the stored row carries no organization.
- Two organization signals do sit on the global WRITE PATH, but neither
attributes the settings ROW:
- the `CryptoContext` passed to `encrypt` carries `tenantId:
ctx.tenantId` for every rung. The only in-tree provider
(`LocalCryptoProvider`) binds no tenant into the AAD, and
`materialiseRow` decrypts with no `tenantId` at all;
- the `config_change` audit row stamps the writer's organization (item
(2)'s object).

**H2: global-rung readers outside the service.** Census of every
non-test source naming `sys_setting`. The procedure fires: it flags the
union and the orphan command.

| reader | reads the global rung? | disposition |
|---|---|---|
| `cli/src/utils/secret-reference-union.ts` (settings collector) | yes:
every row, no scope filter | **fixed here** (reads both holders) |
| `cli/src/commands/secret/orphans.ts`, lines 300 to 309 (legacy-inline
guard rows) | yes: every row | **outside the claim's surface, not
edited** (see Acceptance notes) |
| `cli/src/utils/sys-secret-orphan-sweep.ts` | no read: pure, consumes
the rows `orphans.ts` hands it | none |
| `core/src/security/resolve-authz-context.ts`, line 1696 | no: the
direct read pins `scope: 'tenant'`; the service leg goes through
`getMany` | none |
| `metadata-protocol/src/migrations/sys-setting-identity-index.ts` |
index maintenance over all rows, pre-v18 global rows included; it reads
no value | none (see Acceptance notes) |
| `mcp/src/plugin.ts`,
`plugin-hono-server/src/current-user-endpoints.ts` | no: comments only;
they read through `resolveLocalizationContext` | none |
| `service-settings/src/sys-secret-orphan-report.ts` | no read: a pure
classifier over caller-supplied rows, with no in-tree caller | none |

**H3 holds, and is closed here.** Before the union fix, a credential
held only in `sys_platform_setting.value_enc` is attributable (its
`(namespace, key)` is a declared encrypted specifier) and unreferenced
by `sys_setting`. That is exactly the deletable shape, so the sweep
would delete the credential in force. Ablation B below turns the three
pins red, including the sweep's `referenced` → deletable.

**H4: the AAD binds no holder object and no organization.**
`LocalCryptoProvider`'s version-2 AAD is the 0xFF lead byte, a version
label, then `lp(scope) || lp(namespace) || lp(key)`. `scope` is the
producer vocabulary (`settings`). It binds no object name, no
organization and no tenant (`aadForVersion2`,
`local-crypto-provider.ts`). So **C7's row move needs no
re-encryption**: copy `value_enc` (the `sys_secret` handle) unchanged
into the new row with the same `(namespace, key)`. The `sys_secret` row
does not move. A pin seals a handle with an organization in the context,
places it in a `sys_platform_setting` row, and resolves it.

**H5: object permission holds, and the settings door is unaffected.**
This was measured with a scratch harness, not committed: the real
`SecurityPlugin` middleware, the registry-processed
`sys_platform_setting` and the shipped permission sets, running a
`find`.

| posture | organization admin | platform admin | member | system
context (the service) |
|---|---|---|---|---|
| none registered | 403 `PERMISSION_DENIED` | admitted, no filter | 403
| admitted |
| `single` | 403 `PERMISSION_DENIED` | admitted, no filter | 403 |
admitted |
| `isolated` | 403 `PERMISSION_DENIED` | admitted, no filter | 403 |
admitted |
| control: same object without `requiredPermissions`, any posture |
**admitted, no filter** | admitted | 403 | admitted |

The control row is why the gate ships with the object: with no column
there is no wall. The settings door is unchanged.
`settings-admission-tenancy-posture.test.ts` and
`config-change-audit.test.ts` drive the plugin's own routes over a real
`ObjectQL` with a `scope: 'global'` manifest, and the row lands in
`sys_platform_setting` (200).

**H6: half holds.** After this PR no writer writes `scope: 'global'`
into `sys_setting`, so its `global` option is retired, with the ADR-0087
entry. The `sys_setting_audit.scope` mirror is still written (the audit
writer records `entry.scope`), so it stays. The parity pin now reads:
`sys_setting.scope` = `SpecifierScopeSchema` minus `global`, and
`sys_setting_audit.scope` = `SpecifierScopeSchema`.

**Zone 3's suggested pin "a tenant and a user value still override it"
is falsified by the unchanged rank table.** `scopeRank` gives `global`
rank 1 and `resolveKeyFromRows` takes the first non-null rung, so a
global value OUTRANKS the tenant and user rungs. It did before this PR
and does after it. The pin asserts what the rulings keep: global from
the new store outranks both, and the tenant and user rungs answer once
it is empty.

## What C7 (objectstack-ai#15211) must do with the rows (recorded, not built)

- For every `sys_setting` row at `scope = 'global'`, write one
`sys_platform_setting` row with the same `namespace` and `key`, and copy
`value`, `value_enc`, `encrypted`, `locked`, `locked_reason` and
`updated_by`. Then remove the source row.
- `value_enc` is copied **verbatim**: re-encryption is neither needed
nor wanted (H4). The `sys_secret` row stays where it is, and its handle
id is unchanged.
- A `(namespace, key)` with more than one global row is possible on a
pre-objectstack-ai#8629 database, because NULL-distinct unique let duplicates in. The
ceremony has to pick one. `sys_platform_setting`'s `(namespace, key)`
unique refuses the second.
- Until the ceremony runs, a moved key answers from its next rung or the
manifest default. The v18 boot refusal (D10) is what stops a deployment
from running in that state.

## Tests

All suite counts below were read at the merged head `d0477879af`, unless
a line says otherwise.

- `pnpm --filter @objectstack/service-settings exec vitest run`: 37
files, 638 tests passed. This includes the new
`settings-global-rung.test.ts` (8 cases, a real `ObjectQL`) and the
fixtures re-premised so global rows sit in `sys_platform_setting`.
- `pnpm --filter @objectstack/platform-objects exec vitest run`: 65
files, 1036 tests passed. Before the echo-ledger fix, the run had 4 red,
all in `objects-es-es-echo-decisions.test.ts`, for the retired leaf.
- `pnpm --filter @objectstack/spec exec vitest run src/system
src/migrations src/data/api-methods-batch-conformance.test.ts`: 55
files, 1980 tests passed.
- `pnpm --filter @objectstack/cli exec vitest run --project integration`
over the touched files (union, sweep, rewrap, `src/commands/secret`): 7
files, 91 tests passed. The `unit` layer: 263 files, 3874 tests passed,
at the pre-merge head `1137107352`.
- Typecheck green for `service-settings`, `platform-objects`, `spec` and
`cli`, at `1137107352`.
- **Ablations** were one-off, through `scripts/ablation-replace.mjs` in
WRAP mode. Each anchor hit 1 → 0 and the blob changed. Each restore read
blob == HEAD with `git diff HEAD` empty, and `git status --porcelain`
read 0 lines after all four. The subjects are imported by relative
source path, so no `dist/` is in the resolution path.
- A, the null-user `sys_setting` read readmits `scope: 'global'`: red,
`a global-scope key never consults sys_setting at all`.
- A2, the user-keyed read readmits `scope: 'global'`: red, `a
scope=global row still in sys_setting is NOT read`.
  - D, the global rung read from `sys_setting`: 7 of 8 red.
- B, the union reads `sys_setting` alone: red, `names a handle held ONLY
by sys_platform_setting.value_enc`, `an unreadable sys_platform_setting
gaps the WHOLE settings family`, and the sweep's `REFERENCED, never
deletable`.

## Gates

- **Derivation.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `d0477879af` derived 125 families. All
125 were run, each exit code captured before any pipe, and all 125
exited 0. `--ran` reconciles 125 derived, 125 run, 0 NOT-MEASURED, 0
UNRUN.
- **What the battery covers.** It includes the claim-time list. It adds
`check:engine-double-contract`, `check:objectql-double-limit`,
`check:where-matcher`, `check:i18n-coverage`, `check:i18n-walk-parity`,
`check:type-check-coverage`, `check:type-check-debt` and
`check:empty-changeset`, which the diff touches.
- **The generated-artifact gates are green:** `check:api-surface`
("unchanged"), `check:migration-registry`, `check:spec-changes`,
`check:upgrade-guide`, `check:platform-object-tenancy-census`,
`check-tenant-audit-census`, `check:query-options-erasure`, `check:i18n`
and `check:nul-bytes`.
- **The merge.** `main` was merged twice, the second time through
`scripts/pm/os-regen-merge.sh`, because PR objectstack-ai#22103 also writes
`packages/spec/src/migrations/registry.ts`. After
`gen:migration-registry`, the regenerated registry is byte-identical to
the merge, and it holds both this PR's entry and objectstack-ai#22103's
`declared-index-bare-unique-true-retired`.
- **Lint, a proven narrowing.** `eslint --no-inline-config --format
json` at `d0477879af` read 37 results, the 37 changed `.ts` files: 0
errors and 0 warnings, so none was ignored by the config.
`eslint.config.mjs` enables no type-aware linting (no
`parserOptions.project`, per its own comment), so this diff cannot move
the verdict on any untouched file. The full `pnpm lint` is CI's.

## Acceptance notes (observed, not changed here)

- `cli/src/commands/secret/orphans.ts`, lines 300 to 309, builds the
sweep's legacy-inline guard from `sys_setting` rows only. Today nothing
writes legacy inline ciphertext into `sys_platform_setting`: the plugin
always wires `LocalCryptoProvider` plus the `sys_secret` store. After C7
moves pre-Phase-3 global rows, though, an inline value could sit there.
The guard is withhold-only, and the union, which decides deletion,
already reads both holders. Natural carrier: C7 (objectstack-ai#15211).
- `metadata-protocol/src/migrations/sys-setting-identity-index.ts`: two
operator texts on its degraded arms still say global-scope settings rows
"can still be created" in `sys_setting`. No writer creates one after
this PR. The text goes fully stale when C7 empties the layer. Carrier:
C7 (objectstack-ai#15211).
- `packages/spec/src/system/settings-manifest.zod.ts`: the module TSDoc
says values persist in `sys_setting`, and its resolution list was
already missing the global rung. No `.describe()` names the store, so it
is left untouched per the claim.
- `SettingsService.setMany` passes `tenantId: ctx.tenantId` into the
`CryptoContext` on every rung, while `materialiseRow` decrypts with
none. No in-tree provider reads `tenantId`, so this pulls nothing today.
A per-tenant-key KMS provider would seal and open under different keys
on every rung.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…its it can prove, you apply the rest (objectstack-ai#22142)

Fixes objectstack-ai#9591
Clause-②: no (prescription text only; no input's accept or reject result
changes)

This is the spec-lane half of the card: the shared retirement sentence
names `--write`, and the class-wide pin moves in the same PR. The
codemod itself landed in PR objectstack-ai#22108 (`a959493cdf`).

## The sentence

Before (the objectstack-ai#9529 wording):

```text
Run `os migrate meta --from N` to list the mechanical edits for existing sources; apply them by hand.
```

After:

```text
Run `os migrate meta --from N` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.
```

The one allowed two-clause variant (a conversion that covers only part
of a value) carries the same clause: `… to list the mechanical edits for
the X case; --write applies the ones it can prove, and
WHAT-HAPPENS-TO-THE-REST.` Its two members are dashboard
`compareTo.offset` and the script node's `config.actionType`.

**Checked against the tool on `main` (`51290bca`).**
`packages/cli/src/commands/migrate/meta.ts` declares `write:
Flags.boolean({ … default: false, exclusive: ['stored'] })`. Its help
text says it rewrites the authored sources in place "for each mechanical
change traced to one literal in one project file; every other change is
listed with the reason it was not written". Without the flag the run
writes only the `--out` snapshot. The wording satisfies every ruling
that binds it:

- **Triage `6045697201`.** The sentence never says "rewrite existing
sources automatically" unqualified ("the ones it can prove"), and it
names `--write` because the default run still only lists.
- **"It must be TRUE of the tool."** Every clause is a property of the
command, read from `meta.ts`.
- **"One antecedent."** "existing sources" still names one thing. "The
ones" can only be edits, because an edit is what gets applied. The key's
fate stays in the body prose.
- **Vocabulary.** The wording matches PR objectstack-ai#22122's skill text ("lists the
mechanical edits"; `--write` "rewrites in place each edit it can trace
to one literal in one project file, lists every other with the reason it
was not written").

## Where it moved (counted at `017761f0`; the merge of `main` added no
site)

- **161 sentences the pin judges.** `packages/spec/src`: 157 (155 house
form, 2 two-clause). `packages/lint/src`: 1.
`packages/drivers/driver-turso/src`: 3. Every one passes the new
anchors; `apply them by hand` survives only in the pin's own RED
fixtures.
- **Not judged by the pin, moved anyway:**
- `migrations/registry.ts`: 3 sentences, regenerated from the moved
`entries/semantic/18.*.ts` by `gen:migration-registry`.
- The lint `chartConfig.xAxis.field` hint in
`validate-widget-bindings.ts`: a template literal with interpolation
after the sentence, so the pin cannot see it (Acceptance notes).
  - The `retiredKey()` docblock example.
- **Mechanical replacement.** A script replaced the tail `apply them by
hand` in 63 files (188 occurrences; old tail left: 0) and printed
per-file before/after counts. Two seams split mid-phrase
(`translation.zod.ts`) and the two two-clause sites were rewritten by
anchored edits that had to hit exactly once.
- **Pins in other test files.** 23 test files asserted the old sentence
verbatim, as string or regex. Each now asserts the new sentence
verbatim, so a revert reds them. The ones that assert only the unchanged
prefix (`form-layout-inline-grid-retired.test.ts`, the turso /
driver-memory `toContain('os migrate meta --from 17')`) are untouched,
because they stay true.
- **Changeset.** `.changeset/9591-retirement-sentence-write.md`: `patch`
for `@objectstack/spec`, `@objectstack/lint` and
`@objectstack/driver-turso`, the three packages whose shipped text
moves.
- **Generated.** `content/docs/references/**`: 32 files (+268/−268) from
`pnpm --filter @objectstack/spec check:generated --fix`; `check:docs`
was the only stale artifact. `check:generated` then exited 0.

## The class pin (`retired-key-migrate-sentence.test.ts`)

- **Anchors.** `HOUSE_AT_MARKER` and `MIXED_AT_MARKER`, and their
markdown twins, require the new clause. The objectstack-ai#9529 sentence, which does
not name `--write`, is now RED. Two further spellings are RED: one that
names `--write` without the qualification, and one that qualifies it but
leaves the rest unowned.
- **Withdrawn claim.** `WITHDRAWN_CLAIM` is unchanged: the unqualified
automatic-rewrite claim stays a hard RED everywhere. A new non-vacuity
case proves neither legal shape trips it.
- **Truth anchor (new).** The pin reads `os migrate meta`'s own flag
table. A `write` boolean flag must exist and must have `default: false`,
the two facts the sentence rests on. The read is covered by
`@objectstack/spec`'s existing `packages/**/*.ts` cross-package
declaration.
- **Corpus widened by one root.** `packages/drivers/driver-turso/src`
joins, on objectstack-ai#7030's terms. Its three `turso` config tombstones carry the
house sentence, and their docblock defers to `retired-key.ts`, but the
pin never walked them. Without this, a rewording leaves them behind with
every assertion green. The lint-only anti-vacuity case now covers each
widened corpus.
- **Header and docblock.** The pin header and the `retired-key.ts`
module docblock record the new sentence and why. The "the claim may be
restored" note is gone, replaced by what was restored and how far.

**Reverse verification**, run from committed HEAD `017761f0` through
`scripts/ablation-replace.mjs` (each anchor hit as declared and was
restored to a blob equal to HEAD with `git diff HEAD` empty). Expected
direction: red.

| Mutation | Result |
|:--|:--|
| A. the three `turso.zod.ts` sentences back to the objectstack-ai#9529 wording
(anchor ×3→0, blob `e25cca4d5508`→`a05fe3f09733`) | 3 failed / 12
passed, naming `driver-turso:spec/turso.zod.ts:63`, `:75`, `:82` |
| B. `meta.ts` `write` flag `default: false` → `true` (blob
`c036012c63c9`→`71acff21ef8c`) | 1 failed / 14 passed: "the sentence is
TRUE of the command it names" |
| C. `meta.ts` flag renamed `write` → `inPlace` (blob
`c036012c63c9`→`29a8a9402284`) | 1 failed / 14 passed: "os migrate meta
declares no `write` boolean flag" |

Under the old corpora, mutation A would have stayed green, because
driver-turso was in no corpus.

## One bounded fix on the same sentences: `CHATTER_POSITION_RETIRED`

The three `record:chatter` / `record:discussion` `position` value
prescriptions (`'sidebar'`, `'inline'`, `'drawer'`, in
`ui/component.zod.ts`) told the author to run a bare `os migrate meta`.
The command refuses that with `Missing required flag --from` (`meta.ts`
`run()`, the `flags.from === undefined` branch). The conversion is
`record-chatter-position-vocabulary`, `toMajor: 18`, so they now name
`--from 17`. They therefore join the pin's judged set in house form, and
the "(registered under protocol major 18)" aside goes. The fix qualifies
as bounded: the same sentence class, a mechanical change to an
already-pinned form, a file inside this claim's surface, and the same
gate family. No test pinned the old text.

## Governed surface: `.claude/skills/spec-property-retirement/SKILL.md`
(Tier S)

The pin requires the retirement playbook to teach both shapes
(`SKILL_HOUSE_TEMPLATE` and `SKILL_MIXED_TEMPLATE` must match its
convention 5). So changing the sentence forces the playbook edit, and
this PR lands as Tier S. Convention 5 now carries the two new templates.
Its note that the command "never writes a source file" was made false by
PR objectstack-ai#22108, so it is deleted. Line count 337 → 337 (ceiling 337), with
every line within the 120-byte budget: `node
scripts/pm/check-skill-line-ratchet.mjs` exits 0. ⛔ No published
`skills/**` file changes: PR objectstack-ai#22122 owns
`skills/objectstack-upgrade/SKILL.md`, and no published skill carries
the sentence (`git grep` count 0).

## 维护者速读(草稿)

**改了什么**:所有退役键报错末尾那句统一提示,从「运行 `os migrate meta --from N`
列出机械修改,然后手工改」改为「……列出机械修改;`--write` 会写入它能证明的那些,其余你手工改」。共 161 处被 pin
判定的报错文案(含 2 处两从句变体),外加生成的 registry 3 处、lint 模板字符串 1 处;其中 3 处原先写成不带
`--from` 的命令(该命令会直接拒绝),一并改正。守这句话的 pin 同步更新,并新增一条断言:CLI 必须真有 `--write`
且默认不写。

**为什么改**:`--write` 已随 PR objectstack-ai#22108 落地,旧句只说「手工改」,低估了工具;但 `--write`
只写能证明的站点,所以不能说「自动重写源文件」。新句两头都如实。

**风险与代价(含回滚)**:纯文案,不改任何 schema、键、类型、导出或错误码;解析结果不变。依赖旧整句原文匹配的调用方会失配(仓内 23
个测试已同步);前缀「…for existing sources;」不变。回滚即 revert 本 PR。在途的兄弟 PR
若新增处方仍用旧句,会被 pin 打红,后落地者改用新句。

**席位意见**:

**你要做的**:无需操作;本 PR 触 `.claude/**`(Tier S),由席位按合同审查记录落地。

## Verification (branch base `51290bca`; final head `f9ca14d548`)

- `pnpm --filter @objectstack/spec build`: VERDICT command-exit 0.
`check:generated --fix` regenerated the one stale artifact;
`check:generated` then exited 0 (15 of 15 current), and again in the
gate run at `f9ca14d548`.
- spec `vitest run --project local`: Test Files 623 passed (623), Tests
18613 passed, 1 todo, at `017761f0`.
- spec `vitest run --project repo` (53 files incl. the class pin): 53
passed (53), Tests 903 passed (903), at `017761f0`.
- `@objectstack/driver-turso` `vitest run`: Test Files 88 passed (88),
Tests 2373 passed, 33 skipped, at `017761f0` (after `pnpm
--workspace-concurrency=2 --filter '@objectstack/lint...' --filter
'@objectstack/driver-turso...' build`; the first run, before that build,
could not resolve unbuilt dependencies and is NOT MEASURED, not red).
- `typecheck` for spec (`tsc --noEmit` + `check:scripts-typecheck` +
`check:test-typecheck`), lint and driver-turso: exit 0 at `017761f0`.
- **After merging `main`** (`033e5c536d`: objectstack-ai#22122, objectstack-ai#22127, objectstack-ai#22106) as
`f9ca14d548`, with no conflict (objectstack-ai#22127 also edits
`validate-expressions.ts`): the class pin 15 passed (15);
`@objectstack/lint` `vitest run`: Test Files 123 passed (123), Tests
5688 passed (5688); lint `typecheck` exit 0.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `f9ca14d548` derives 124 commands (the
claim-time 79 plus 45). All 124 exit 0 at `f9ca14d548`. `--ran`
reconciliation: 124 derived, 124 run, 0 NOT-MEASURED, a zero derived
from the recorded exit codes. (At `017761f0`, five gates first answered
exit 3, PREREQUISITE NOT MET: one shallow-clone fixture and four that
need unbuilt dists. The clone was deepened as the gate asked, and all
five are green in the `f9ca14d548` run.)
- `node scripts/pm/check-skill-line-ratchet.mjs`: exit 0; the playbook
is 337 lines (ceiling 337), and no line is over 120 bytes.
- eslint, narrowed: `pnpm exec eslint --no-inline-config --format json`
over the 66 changed `.ts` files reports 66 files, 0 errors and 0
warnings. The population is `eslint.config.mjs`'s
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob, which excludes the changed
`.md` / `.mdx` files. The config never enables type-aware linting (its
own comment at `:326`–`:328`), so this diff cannot move any untouched
file's verdict. The repo-wide `pnpm lint` is CI's.

## Siblings in flight

objectstack-ai#21982, PR objectstack-ai#22094 (objectstack-ai#13458) and PR objectstack-ai#22103 (objectstack-ai#5082) each add prescriptions
with today's sentence. Whichever lands after this one carries the new
sentence; the class pin reds it at that merge otherwise. Whichever of
those lands first, this branch merges `main` before landing.

## Acceptance notes

- **Hand-written docs still use the old sentence**
(`content/docs/automation/flows.mdx` ×2,
`protocol/objectql/query-syntax.mdx`, `data-modeling/queries.mdx`,
`protocol/objectui/actions.mdx`, `ui/apps.mdx` ×2). Each is the page's
own advice, not a quoted error, and still true of the default run; each
undersells `--write`. They are `domain:devx` pages outside this claim,
so they are not touched here.
- **QA checklist item `cli.migrate-meta-codemod`**
(`docs/qa/platform-checklist/areas/cli.json`). Its RESTART CHECK fired
when PR objectstack-ai#22108 added `--write`, and the item still asserts a print-only
command. Its step 1 greps for the objectstack-ai#9529 sentence verbatim and now finds
none. Re-authoring the item belongs to the checklist author, not this
PR.
- **Comments that say the default run "lists the mechanical edits"**
stay as they are, because they are still true: the
`migrations/registry.ts` migration notes (outside the pin's scope by
design) and the `conversions/registry.ts` comments.
- **The lint `chartConfig.xAxis.field` hint**
(`validate-widget-bindings.ts`) moved, but it remains invisible to the
class pin: a template literal, with `suggestName(…)` and the suppress
hint interpolated after the sentence.
- **A published skill still claims an automatic strip.**
`skills/objectstack-data/rules/indexing.md:31` says "run `os migrate
meta --from 16` to strip them automatically", and
`skills/objectstack-data/SKILL.md:377` says the command "strips them".
The default run strips nothing from sources, and `--write` strips only
what it can prove. `WITHDRAWN_CLAIM` has no strip spelling, so the pin
cannot see this. Widening it here would red `main` on a Tier H file this
PR may not touch, so it is reported to the PM for the skills lane.
- **The `config.actionType` two-clause tail** ("the stub and marker
values are removed") is unchanged in substance; only the `--write`
clause was inserted before it.


## Patch round 1 (written by the PM seat from the dev's report
`6052088494`)

- **Merge:** `origin/main` `ef1fcb26a2` (PR objectstack-ai#22103) was merged through
`os-regen-merge.sh` as `feca6b5ace`. The three reference pages both
sides had changed (`api/metadata`, `data/object`, `system/migration`)
were regenerated from the merged tree as `98e2f6e373`. That brings back
objectstack-ai#22103's `unique?: false | 'global' | 'organization'` rows, which the
driver had dropped.
- **objectstack-ai#22103's sites:** the merge brought two non-test sites with the old
tail and one test that asserts it verbatim. All three carry the new
sentence at `b9d6e82619`:
- `packages/spec/src/data/object.zod.ts`
(`DECLARED_INDEX_BARE_TRUE_RETIRED`);
- `packages/lint/src/data-model-rules.ts` (the
`unique-unscoped-declared-index` fix text);
  - `unique-scope-message.test.ts`.

The pin now judges 163 sentences: `spec` 158 (156 house + 2 two-clause),
`lint` 2, `driver-turso` 3.
- **The pin's blind spot:** the `data-model-rules.ts` sentence sat in a
template literal, which the judge cannot read (escaped backticks), so it
was never judged. It is now plain-quoted, as in
`validate-expressions.ts`, and the pin's Mechanism paragraph records
that template literals are invisible to the scan. Ablation D (that
sentence back to the old tail) gives 3 failed / 12 passed, naming
`lint:data-model-rules.ts:463`. `validate-widget-bindings.ts` stays the
one template-literal site the pin cannot judge (an Acceptance note).
- **Verification at `b9d6e82619`:**
  - spec `--project local`: 623 files / 18,619 tests;
  - spec `--project repo`: 53 / 903;
  - lint: 123 / 5,689;
  - driver-turso: 88 / 2,373;
  - typecheck: exit 0 for all three packages;
  - `dispatch-gates --ran`: 124 derived / 124 run / 0 NOT-MEASURED;
  - CI: 33 success, 2 expected skips.


## Patch round 2 (written by the PM seat from the dev's report
`6053397952`; claim revised `6052335087`)

- **Why:** PR objectstack-ai#22094 (objectstack-ai#13458, `fec87e7e07`) landed first with a
two-clause prescription lacking the `--write` clause, which this PR's
class pin refuses. The sibling rule here ("whichever lands later carries
the new sentence") puts the edit in this PR.
- **Merge:** `origin/main` `959c209d56` was merged through
`os-regen-merge.sh` as `3b6335b9be`, with no hand-written conflict.
`content/docs/references/api/protocol.mdx` was regenerated as
`c40b3babd7`.
- **The edit** (`fe3af5642c`, 4 files beyond the merge):
- `packages/spec/src/kernel/manifest.zod.ts`
`PLUGIN_PERMISSIONS_LIST_FORM` now closes with "Run `os migrate meta
--from 17` to list the mechanical edits for the package manifest case;
`--write` applies the ones it can prove, and a granted-permission record
is not a source it reads." It keeps objectstack-ai#13458's own second clause and adds
the house `--write` clause, the seat's wording.
- Its verbatim pin `manifest-permissions-string-list.test.ts` moved with
it.
- The changeset's two-clause bullet now names three members and says
"before their second clause".
- **The two-clause variant now has three members:** dashboard
`compareTo.offset`, the script node's `config.actionType`, and the
package manifest `permissions` case. The pin judges 164 sentences (spec
159 = 156 house + 3 two-clause; lint 2; driver-turso 3) with 0 bad
sites.
- **Verification at `fe3af5642c`:**
  - spec `--project local`: 625 files / 18,661 tests;
  - spec `--project repo`: 53 / 903;
  - class pin: 15 / 15, and the manifest pin: 17 / 17;
  - `dispatch-gates --ran`: 124 / 124 / 0 NOT-MEASURED;
  - CI: 33 success, 2 expected skips.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…re declared-index unique (objectstack-ai#22242)

Fixes objectstack-ai#22208
Clause-②: no

Moves the two `@objectstack/cli` nightly-tier fixtures off the spelling
that protocol 18 refuses. The refusal itself (PR objectstack-ai#22103, `ef1fcb26a2`)
is correct and is not touched here. Test-only: two files, +23 / -11, no
assertion changed, and no test skipped, renamed or moved between tiers.

## What changed

Both `--json` failure-warnings suites plant one advisory of each class
and assert that each failure exit carries exactly the classes the run
had computed. Their authoring-RULE advisory was a bare `unique: true` on
a declared index. Since PR objectstack-ai#22103 the schema parse refuses that spelling
(`invalid_union` at `objects.0.indexes.0.unique`), so every fixture
stopped at the parse exit and the payload carried none of the fields the
tests read.

| site (on `7b926f76`) | before | after |
|:--|:--|:--|
| `build-json-failure-warnings.e2e.test.ts:165` (shared `stack()`) |
index `unique: true` | index `unique: 'global'` + field `title` `unique:
true` |
| `build-json-failure-warnings.e2e.test.ts:300` (3b `rulefail`) | index
`unique: true` | index `unique: 'global'` + field `title` `unique: true`
|
| `validate-json-failure-warnings.e2e.test.ts:192` (shared `stack()`) |
index `unique: true` | index `unique: 'global'` + field `title` `unique:
true` |

The new advisory is `unique/double-declaration` (ADR-0120 D5b): a
field-level `unique: true` (per organization) beside a single-column
declared index `unique: 'global'` on the same column. It is the shape PR
objectstack-ai#22103 already moved the two sibling parity suites to
(`build-json-advisory-parity`, `build-json-undeclared-key-parity`).

Why it is an authoring-RULE advisory and not a structural one: it is
produced by the `lintUniqueDeclarations` entry of the author-time rule
registry (`packages/lint/src/authoring-rules.ts`, tier `advisory`,
commands `validate` + `build`). Its finding is `severity: 'warning'`, so
`splitBySeverity` puts it in `ruleAdvisories` and never in the gating
errors. It is a record with a non-`docs/` `rule` and no `token`, which
is exactly what the suites' `ruleAdvisories` classifier selects. The
structural advisory is a different list (`structuralWarnings`, a string
computed last in `validate.ts`).

Docblocks: the two fixture docblocks (`build :141`, `validate :168`)
name the new advisory and say in one sentence why the old spelling left.
The two `rulefail` comments (`build :291`, `validate :295`) name it too.

No assertion named the bare-`unique` rule: every assertion reads the
class COUNT (`rule: 1`), never the rule id. So no assertion line moves
(`git diff -U0 | grep -c 'expect('` = 0).

## Readings (all on `origin/main` at the time; base `7b926f76`, then
merged with `a87d8be2`)

- **H1, reproduced before any edit.** `OS_TEST_TIERS=nightly`,
`integration` project, the two files only, on `7b926f76`: `Tests 11
failed | 10 passed (21)`. The total is the same 11 as the card. Per
file: build 6 failed / 5 passed (3f, 3e, 3c, 4b, catch-all, 3b red);
validate 5 failed / 5 passed (rule errors, capability errors, doc
errors, catch-all late, structural control red).
- **H2, confirmed.** The validate structural control's own output quotes
`` `indexes[].unique: true` was retired at protocol 18 (ADR-0120 D1) ``
at path `objects / 0 / indexes / 0 / unique`. A direct `os build --json`
probe of the 3b fixture on the base returned `{ success: false, errors:
[invalid_union at objects.0.indexes.0.unique], warnings: [], conversions
}`. That payload has no `error`, no `issues` and no advisory lists,
which are the fields the ten other failures read as `undefined`.
- **H3, used.** A probe on the new 3b fixture returned `error:
'author-time rules failed'`, `issues: ['expression-invalid']` and
`warnings: [unique/double-declaration (warning)]`. The new validate
control fixture reached `valid: true` with the rule record, the key
string, the cap record and the structural string ("No apps or plugins
defined").
- **H4, re-counted.** 80 nightly-tier files (`*.e2e.test.*`,
`*.live.test.*`) on `7b926f76` have exactly three declared-index
`unique: true` sites, the three above. The other three code hits are
field-level `unique: true` (valid under ADR-0120 D1):
`build-json-advisory-parity:149`, `build-json-undeclared-key-parity:141`
and `:170`. The control spelling `unique: 'global'` hits 4 sites, so the
grep sees. The card's own log names only `@objectstack/cli#test` red (59
of 60 tasks green). No other site found; nothing else is red for this
cause.
- **H5, done** as described above.

## Proof

- **Fixed, nightly tier.** Same command on `e8d64c30` (fix) and again on
`89d4dc47` (fix merged with `origin/main` `a87d8be2`): `Test Files 2
passed (2)` / `Tests 21 passed (21)`. All 21 tests are named in the
verbose run, including the 11 that were red and the structural control.
- **Per-PR tier untouched.** `vitest list --filesOnly` in
`packages/cli`: with `OS_TEST_TIERS` unset and with `=queue`, 356 files
and neither of these two; with `=nightly`, 80 files (the card's "(80)")
and both, in `[integration]`. The diff touches no tier config, no file
name and no `vitest-tiers.ts` input.
- **Ablation** (`scripts/ablation-replace.mjs`, wrap mode, plus an outer
EXIT/INT/TERM restore trap). The 3b `rulefail` fixture was put back to
its base spelling: anchor x1 -> x0, replacement x0 -> x1, blob
`7dfddae7` -> `bd478edf`, in-flight count base-spelling = 1. The build
file then read `Tests 1 failed | 10 passed (11)`. The one red was 3b,
`expected undefined to be 'author-time rules failed'`, the payload
missing `error` again. Restore: blob `7dfddae7` == HEAD blob, `git diff
HEAD` empty, index blob equal, porcelain empty. The direction matched
the prediction written before the run.

## Local verification (final head `89d4dc47`)

- `pnpm --filter @objectstack/cli typecheck`: exit 0.
`check:test-typecheck` OK; `tsconfig.test.json` puts both files in its
1970-file program (`--listFiles`), and the 28 raw errors are the
ledgered 28 in three other files, 0 in these two.
- `pnpm --filter @objectstack/cli exec vitest run --project unit
--maxWorkers=2`: `Test Files 263 passed (263)`, `Tests 3874 passed
(3874)`.
- Gates: the 50 commands `node scripts/pm/dispatch-gates.mjs --commands`
derives for this change set are the same set as the dispatch order's
list. 50 of 50 exit 0. `check:dual-build-cjs-loads` first exited 3
(PREREQUISITE NOT MET: nine packages outside the cli closure had no
`dist/`); after a full `pnpm build` (72/72) it exits 0. `dispatch-gates
--ran`: `50 derived, 50 run, 0 NOT-MEASURED, 0 UNRUN`.
- `pnpm lint` (full, `eslint . --no-inline-config`): exit 0. A JSON run
over the two files reports 2 results, 0 errors, 0 warnings, and neither
is ignored.
- No changeset: test-only, nothing in any package's `files[]` moves.
`skip-changeset` is the seat's to apply.
- Not run locally, declared to CI: the rest of the `integration` project
and the other nightly-tier files. Done for this card is the next
`Nightly Tiers` run on a `main` that contains this change.

## Acceptance notes

- The build file's fixture docblock still says "via `plantDocs`", a
helper that does not exist in this file (docs are passed through
`make(…, docs)`). This is pre-existing wording, kept as is because it is
outside this card's surface. Noted, not filed.
- `check-issue-citations` read 0 files for this diff:
`packages/cli/test/**` is outside its declared surface, so it judged
nothing about the `PR objectstack-ai#22103` citation added in these docblocks, in
either direction.
- My first full `pnpm lint` crashed (exit 2, `ENOENT` on a transient
`tsup.config.bundled_*.mjs`) because it ran beside my own concurrent
`pnpm build`. That is a local race and not a lint verdict. The re-run
after the build finished is the reading above.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_

Co-authored-by: Claude <noreply@anthropic.com>
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