Repository navigation
feat(spec)!: refuse bare unique: true on a declared index at protocol 18 — stated scope, zero-drift conversion (ADR-0120 D2/D5a/D7) - #22103
Conversation
… 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>
… 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>
…global' scope 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>
📓 Docs Drift CheckThis PR changes 11 package(s): 39 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 149 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: Inputs read: card #5082 (body and all 10 comments; triage release ① Derived judgmentsAccept set. Lint R11. Conversion Ledger. D3 entries Respelling. Counted from the diff: 48 Synonym pin. The " Public surface. Written surfaces. Governed skill edit (Tier H). ② Semver level
③ Boundary flagsDev report
Out-of-scope findings:
Implemented-by: VERDICT: PASS Generated by Claude Code |
✅ ACCEPT: PR #22103 at
|
…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>
维护者速读(终稿):PR #22103 · #5082 协议 18 的唯一范围收口
改了什么:声明索引( 为什么改:ADR-0120 已裁定(D1 / D7):裸 风险与代价(含回滚):
席位意见:建议批准。复审 PASS;CI 在 你要做的:在本 PR 上给出 APPROVED 审核( Generated by Claude Code |
ACCEPT re-head: PR #22103 at
|
…; 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
…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>
… 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>
… 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>
…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>
…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>
…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>
Fixes #5082
Clause-②: no (narrowing: bare
unique: trueon a declared index stops being accepted at validate / publish, andVISIBILITY_STRICT_OPTIONSleaves@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: trueis 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.uniqueis nowfalse | 'global' | 'organization'. Baretrueis refused (invalid_union, pathunique) with its own prescription. The prescription names'global'(installation-wide, the exact index baretruebuilt) and'organization'(one holder per organization), says field-levelunique: trueis unaffected, and ends with the houseos migrate meta --from 17sentence.tscrefuses it too, because the input type no longer admitstrue(ServiceObjectandObjectSchema.createinputs included).unique/unscoped-declared-index(R11) moves fromwarningtoerror, and from advisory to gating on all three commands.lintDataModelstops calling it, soos lintreports it once, through the registry. It stays off the runtime door. The surface reason is new and measured: the save door's ownObjectSchemaparse 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 baretrueto'global'onobjects[]andobjectExtensions[]. Field-levelunique: trueis never touched. It is inserted where its identifier sorts inMAJOR_18_CONVERSIONS, atorder: 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, andos migrate meta --from 17.The ledger.
declared-index-bare-unique-true-retired(judging the conversion's applied edits: keep'global', or move to'organization') andvisibility-strict-options-unexported.STEP18_RATIONALEfragment atorder: 86. Order 85 is held by an in-flight PR, so this takes the next free number.spec-changes.jsonand the upgrade guide do not move.PROTOCOL_VERSIONis still17.0.0, so no step-18 entry projects there yet.check:spec-changesandcheck:upgrade-guideare green on that reading.The synonym pin retires. In
sql-driver-unique-tenancy.test.ts:unique: 'global'on a declared index as a synonym of true" pin and its header note are gone.'global'(ADR-0120 D6.6).applyConversionsToStoredItem. Their expected-index output is byte-identical before and after. On a SQLite database built from the bare spelling,detectManagedDriftfor the converted metadata is[]. A lit control (one key moved to'organization') shows drift.The in-repo respelling. Every declared index with a literal
unique: truebecomes'global'. That is 48 indexes in 39 source files acrossplatform-objects,metadata-core,plugin-security,plugin-sharing,service-messaging,service-automationandservice-realtime. Nothing becomes'organization', and no field-leveluniquemoves. The prose that quotes those declarations is respelled with them:metadata-protocoloverlay-index.tsandview-definition-active-index.ts;plugin-authaccount-identity-preflight.tsandREADME.md;18.sys-account-issuer-retiredentry.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_OPTIONSmoves, unchanged, to the unbarrelledshared/visibility-strict-options.ts, beside its typeStrictObjectOptions.check:api-surfacereadsshared.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
indexes: [{ fields: ['code'], unique: true }]ObjectSchema.parse/.create,defineStackinvalid_unionatindexes.0.unique, with the prescriptionunique-scope-message.test.tsandunique-scope.test.ts. A respelled object reverted totruefails to compile (3 TS2322 inobject.test.ts, seen before its fixtures were respelled)os validate/os build✗ objects.0.indexes.0.unique invalid_union: …retired at protocol 18…. Control:'global'exits 0os lintunique/unscoped-declared-indexerror atobjects[0].indexes[0]. Control:'global'exits 0saveMetaItem)INVALID_METADATA/ 422 with the prescription, nothing stored. Control:'global'stores one rowObjectStackProtocolImplementation(deleted, not committed)sys_metadatarow carrying itunique: 'global'getMetaItemObjectSchema.createcalls, so they are refused at module load and bytsc. All 39 respelled objects import and parseregistry.registerObject/ driver inputtrueexactly as'global'tscWhy the schema. It is the one contract every parsing door shares, and it is the only place the refusal reaches
ObjectSchema.createand the save door. Against the four-axis framework:tscmakes the spelling hard to write.Lint R11 is kept as the second channel because
os lintnever parses.Zero drift (H3)
driver-sql's ownexpectedIndexesandnormalizeDeclaredIndexover the'global'declarations and over the same declarations with'global'set back totrue, which is exactly the base tree: none of these files carried'global'ate67ba80049, 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:truevs'organization'differs.Census (H2)
Run against
e67ba80049, with an AST walk (TypeScript compiler API). It finds an object literal withunique: trueinside an array that initialisesindexes, 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-levelunique: true,'global'andfalsewere seen 0/3.'global'indexing.mdx(legacy composite example), plugin-authREADME.md, the skill's refused exampleunique/double-declaration), or R12 for the nested-index controlCHANGELOG.mda58626c8EmbeddedItemEditortestsexamples/**,apps/**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-internalExpectedIndexboolean. Unchanged (driver-sql takes the respelling only).overlay-index.ts,view-definition-active-index.ts,account-identity-preflight.tsand18.sys-account-issuer-retired.ts: quotes of respelled declarations, respelled with them.migrations/registry.ts:10855: field-level prose, unchanged.Verification at
0cb065b48fTests, each run through the shared verify lock. They ran at
ded6c918f6. The only commit after it touchesscripts/adr-anchors/*.jsonand no package source.@objectstack/spec(localproject)@objectstack/spec(repoproject: the step-18 rationale and major-18 conversion merge tests)@objectstack/lint@objectstack/driver-sql@objectstack/cli,unitproject@objectstack/cli, the two edited*.e2efiles (OS_TEST_TIERS=nightly,integrationproject)platform-objects·metadata-core·metadata-protocolplugin-security·plugin-auth·plugin-sharingservice-messaging·service-automation·service-realtimeobjectql·rest·types·cloud-connectiondriver-memory·driver-mongodb·driver-tursotypecheckexits 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 --commandsat0cb065b48fderives 134 families. All 134 ran and exited 0.--ranreconciliation: 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 committedapi-surface/shared.json−1 (VISIBILITY_STRICT_OPTIONS) andexport-origins/shared.json−1.check:spec-changesandcheck: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✓, registeringdeclared-index-bare-unique-true-retiredandvisibility-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.md2183 of 3241 tokens).check:i18n✓.ESLint, narrowed and proven. The population comes from
eslint.config.mjs:**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}minusNEVER_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 22103reads 1390 changed lines (+1015 / −375 over 81 files, generated files included) atccfbfbaa58, 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.SKILL.md: 4411 → 4411 lines. NoSKILL.mdis touched.skills/tree: 13366 → 13366 lines.Ablation, on the edited dedup control.
per-package-dedup-positional-echo.test.tsnow builds its nested-index control on R12. Its header asks for its ablation to be re-run on edit. WideningfindingKey'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 (0868281before and after) and an emptygit diff HEAD.Acceptance notes (not filed, nothing changed for them)
EmbeddedItemEditor.indexFallback.test.tsxassertsIndexSchema.safeParse({ fields: ['c'], unique: true }).success === trueas a "still ACCEPTED" control, and names this card. It turns red on objectui's next@objectstack/specbump. 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.driver-sqlschema-drift.tsnear:100("PARKED on ADR-0120 协议 18 项:D2 conversion(声明索引 unique: true → 'global')+ 裸 true 硬拒 + synonym pin 退役 —— 挂 18 列车,现在勿动工 #5082"), outside this card's driver-sql allowance;true"is" the positional spelling (sys-email-template,notification-preference,notification-subscription).isolatedinstall gate.packages/typesunique-scope-install-gate.tsstill treats baretrueas'global'. That is now reachable only from unparsed input, and it reads the spelling correctly. Carrier: none.docs/adrowner. This PR does not touchdocs/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