Repository navigation
chore(release): enter Changesets pre mode (next) with one major marker, so v18 opens at 18.0.0-next.0 - #22084
Merged
Merged
Conversation
`pnpm changeset pre enter next` writes `.changeset/pre.json` as
`{"mode": "pre", "tag": "next"}`. From this commit, `changeset version`
computes prerelease versions and `changeset publish` publishes them under
the npm dist-tag `next`.
Claude-Session: https://claude.ai/code/session_01VF48aw8RPG6wzDnMgp6rtw
Co-authored-by: Claude <noreply@anthropic.com>
…xt.0 Every publishable package is in the Changesets `fixed` group, which takes the highest pending bump. Every changeset pending on main is `minor` or `patch`, so pre mode alone would version 17.8.0-next.0. One `major` on `@objectstack/spec` takes the group to 18; `sync-protocol-version.mjs` reads PROTOCOL_VERSION from that package's major. The changeset states its ADR-0087 disposition (not-required, no-migration-prescription): the marker moves no authorable key, export, type or stored shape. Claude-Session: https://claude.ai/code/session_01VF48aw8RPG6wzDnMgp6rtw Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Oct 7, 2026
…` constant `check-adr-0087-registration.mjs` filters `.changeset/pre/` (consumed prerelease changesets) out of its audit surfaces. The literal now sits in a top-level `CONSUMED_PRERELEASE_EXCLUDED` constant that `isConsumedPrerelease` reads; the literal and the predicate's match are unchanged. The constant's name meets dispatch-gates' exclusion-declaration predicate, so the watch-hint extractor reads the literal as the excluded surface it is. Without it, once `.changeset/pre.json` is tracked the extracted hint `.changeset/pre` resolves to `pre.json` under the PM self-test's loose rule and not under its strict rule, and `check:pm-dispatch-gates` fails its "extension narrowing costs no lead" case. Claude-Session: https://claude.ai/code/session_01VF48aw8RPG6wzDnMgp6rtw Co-authored-by: Claude <noreply@anthropic.com>
Contributor
Author
This was referenced Oct 7, 2026
This was referenced Oct 8, 2026
os-zhuang
approved these changes
Oct 8, 2026
This was referenced Oct 8, 2026
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Oct 9, 2026
…od and Temporal build steps (objectstack-ai#22086) Fixes objectstack-ai#22077 Clause-②: no The turbo remote cache that objectstack-ai#21186 wired into Build Core is now read by every `turbo run build` step of the three Lint `Type Check` build lanes, the Test Core shards, the dogfood shards and Temporal Conformance. Each carrier gets Build Core's four env lines verbatim: the same write rule (writes only on `merge_group` and on `push` / `workflow_dispatch` against `main`, everything else reads), the same signing, and no new secret. Two files: `.github/workflows/ci.yml` and `.github/workflows/lint.yml`. ## Route that landed: the remote cache, not the artifact fallback Nothing in the remote route failed, so the `dist/` upload-artifact alternative was not needed: - **Hashes.** Each carrier's plan is a subset of Build Core's `turbo run build --filter=!@objectstack/docs` plan, with identical task hashes and identical `globalCacheInputs`. Measured with `--dry=json` on `3d9188502e`, turbo 2.11.5, `CI=true`: | carrier | build nodes (with a command) | same hash as Build Core | |:--|--:|--:| | Type Check `Build workspace packages` (3 lanes) | 70 (67) | 70/70 | | `Build the ledgered packages' dependencies` / `Build the nested packages ...` | 77 (71) | 77/77 | | dogfood `Build the dogfood package's dependency closure` | 66 (63) | 66/66 | | Temporal: driver-sql / non-SQL backends / metadata-protocol / runtime | 8 / 18 / 14 / 31 | all | | Test Core `Build this shard's dependency closure`, shards 1-6 | 60 / 62 / 63 / 62 / 32 / 62 | all | Every plan is 100% `#build` tasks. Filters and `--concurrency` are not part of a task hash. - **Signing and permissions.** These are the same secrets and the same expressions as Build Core. A same-repo PR receives them; a fork PR receives none, so `TURBO_TOKEN` evaluates to `''` and turbo reports `Remote caching disabled` (objectstack-ai#21186 measured this). `TURBO_CACHE` never evaluates to `''`. - **The cache already serves.** On the `main` push run at this PR's base (CI `37627942232`), Build Core's build step took 6 s, because the queue entry for the same tree wrote it. In the same run, the jobs that did not read the remote rebuilt: dogfood took 200 / 201 / 336 s, and in Lint `37627942309` the Type Check lanes took 297 / 330 / 335 s. ## Test Core: a new guarded build step Test Core's existing build step, `Build the sliced package's dependency closure`, runs zero iterations, because no package has been sliced since objectstack-ai#21487. The closure was built inside `Run this shard's tests`. That run cannot read the remote, because `test` / `test:repo` stay off it. Merge-queue run `37623284168`, `Test Core (3/6)`, shows the cost: one turbo run of the tests and their closure printed `Cached: 1 cached, 72 total` / `Time: 11m43.373s`. Each shard now runs a new step, `Build this shard's dependency closure`, before the slice step. It is one guarded `turbo run build`, with `--filter=PKG^...` for every package on the shard (PKG's dependencies without PKG), and it carries the env block. The test step then replays that closure from the local cache. Measured on all six shards of the local partition: this plan equals the test plan's build tasks, with identical hashes, 0 extra and 0 missing. One exception is `cli#build` on shard 1/6: `cli#test` has `dependsOn: ["build"]`, so the test step still builds that one, as before. Other properties of the new step: - An empty `FILTERS` exits before turbo runs, because a bare `turbo run build` would build the whole workspace. - It has no `--summarize`, so `.turbo/runs/` stays the test step's alone, for the drift check and the timings capture. - `check:stall-guard-budget` judges it at window 10m, cap 20m, budget 45m, slack 25m. ## The two pins Both pins are written into Build Core's `Turbo remote cache (objectstack-ai#21186)` comment as a PINS paragraph, and every carrier points back to it. They are not a new gate. **objectstack-ai#19086: a step that writes artifacts from a cache-served dist must not be among those served.** - The env goes on build-only steps. Checked mechanically on the final tree: all 12 steps carrying `TURBO_TOKEN` hold Build Core's four lines byte-identical and run only `turbo run build` / `pnpm build`, and no job sets `TURBO_*` at job level. The dry-run plans above contain no `test`, `test:repo`, `typecheck` or `gen:*` task. - `gen:schema` and `gen:skill-refs` are cacheable turbo tasks, but no workflow runs them through turbo (`turbo run gen`: 0 hits). - Grepping the wired jobs for `gen:`, `--fix` and `--write` gives 0 hits, so no step there writes tracked artifacts. - The typecheck step in `Type Check · workspace` stays off the remote. Every build task its `^build` closure schedules is already in the wired build step's plan (66 of 66, 0 extra), so it replays them locally. **objectstack-ai#21193: the build hash covers every root input the builds read.** I re-ran objectstack-ai#21193's probe on `3d9188502e`: append one comment line to a file, re-derive the 72-task Build Core plan, restore from `HEAD`, and prove the restore by blob hash. | file | build hashes moved | |:--|--:| | `tsup-drop-sources-content.mjs`, `check-dts-emitted.mjs`, `invoked-as.mjs` | 72/72 each | | `check-dts-references`, `ts-parse`, `check-regen-pending`, `regen-artifacts`, `git-env`, `import-prerequisite`, `cli-build-prerequisite`, `check-dev-prereqs`, `build-input-hash`, `workspace-enumerator`, `js-comment-mask` | 71/72 each | | `sync-scaffold-emission-policy.mjs`, `sync-template-versions.mjs`, `packages/cli/src/commands/init.ts` | 6/72 each | | control: root `tsup.config.ts` | 72/72 | | controls: `scripts/check-turbo-task-graph.mjs`, `ci.yml`, `lint.yml` | 0/72 each | These are objectstack-ai#21193's own numbers. The last row also shows that this PR's own diff moves no build hash. A static check agrees: the import closure of every root script that a build command or a tsup config names lies inside the declared `$TURBO_ROOT$` inputs. The spec generators' closure (450 files) reaches 10 root scripts, all declared. ## Measurement (before / after) **Before.** Build-step seconds, from the jobs API: | step | `main` push at base `3d9188502e` (CI `37627942232` / Lint `37627942309`) | merge-queue entry for the same tree (CI `37623284168` / Lint `37623284080`) | |:--|--:|--:| | Build Core `Build packages (excluding docs)` (reads the remote already) | 6 s | 193 s | | Type Check · workspace `Build workspace packages` | 297 s | 323 s | | Type Check · debt ledger: build, then ledgered build | 335 s, then 31 s | 340 s, then 32 s | | Type Check · consumer gates: build, then nested build | 330 s, then 30 s | 342 s, then 31 s | | dogfood 1/3, 2/3, 3/3 closure build | 200, 336, 201 s | 322, 340, 287 s | | Temporal: driver-sql, non-SQL, metadata-protocol, runtime | 1, 35, 0, 79 s | 95, 47, 1, 91 s | | Test Core `Run this shard's tests` (closure plus tests), shards 1-6 | 1181, 835, 616, 752, 663, 767 s | not split out; see the 3/6 reading above | **After** (seat-appended from the dev's report `6040381091` on objectstack-ai#22077; this PR's `pull_request` run, CI `37635720375` / Lint `37635720510`, head `cbb948b926`). Step seconds from the jobs API: | step | before (`main` push, base `3d9188502e`) | after (this PR) | |:--|--:|--:| | Type Check · workspace `Build workspace packages` | 297 s | 1 s | | Type Check · debt ledger: build, then ledgered build | 335 s, then 31 s | 1 s, then 1 s | | Type Check · consumer gates: build, then nested build | 330 s, then 30 s | 1 s, then 1 s | | dogfood 1/3, 2/3, 3/3 closure build | 200, 336, 201 s | 1, 2, 1 s | | Temporal: driver-sql, non-SQL, metadata-protocol, runtime | 1, 35, 0, 79 s | 3, 3, 1, 3 s | | Build Core (wiring unchanged) | 6 s | 7 s | | Test Core new `Build this shard's dependency closure`, shards 1-6 | — | 1, 0, 0, 0, 0, 0 s | Most of that main-to-PR delta is the local `actions/cache` seed, which this run restored from the base push. The **attributable** reading is the same-base control, Lint `37632788013` (PR objectstack-ai#22084: no remote, same seed), against Lint `37635720510`: - debt `Build the ledgered packages' dependencies`: control `Remote caching disabled` / `Cached: 66 cached, 71 total` / `Time: 29.447s`; this PR `Remote caching enabled` / `Cached: 71 cached, 71 total` / `Time: 527ms >>> FULL TURBO`; - consumers nested build: control `Cached: 66 cached, 71 total` / `Time: 29.819s`; this PR `Cached: 71 cached, 71 total` / `Time: 558ms >>> FULL TURBO`. The 5 tasks the control executed lie outside the plan the typecheck seed is saved from, so the remote served them here. Turbo's lines for the dogfood, workspace, driver-sql and non-SQL steps are NOT MEASURED: they fall outside the last 5000 log lines the read path returns. The **merge-queue reading is pending**: the seat takes it from this PR's own queue entry at landing and records it. How to read the after numbers: a PR's restored `actions/cache` seed comes from the latest `main` push. A cache hit does not say whether the local seed or the remote served it. So the clean signal is the next `main` push after landing: today Build Core takes 6 s there while the Type Check lanes take about 300 s. ## Kept as ruled - ⛔ No job removed, no required context changed, no new gate. `check:required-contexts` is green. - ⛔ The local `actions/cache` restore and save steps are untouched. They stay until the remote hit rate is measured. - ⛔ No new secret, and nothing written from a PR context. - `turbo.json` and `scripts/**` are not touched. ## Local gates The gates come from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this diff: 52 commands. Each exit code was written to disk before it was read. The full table, with exit codes and the `--ran` reconciliation, is in the `os-dev-report` on objectstack-ai#22077. The workflow readers were green before this PR opened: `check-ci-filter-parity`, `check-self-test-workflow-commands`, `check-step-collectors`, `check-self-test-wired`, `check-aggregator-roster`, `check:stall-guard-budget`, `check:workflow-step-name-quoting`, `check:required-contexts`, `check:pnpm-filter-targets` and `check:pm-expected-skips`. ## Acceptance notes - `Dogfood Verify CLI` and `Console Pin Gate` also run turbo builds. The card does not name them, so they are not wired here. - The `Type Check · source gates` lane runs no turbo build, so it has nothing to wire. - This PR changes only `.github/workflows/**`, which publishes nothing. `skip-changeset` applies. --- _Generated by [Claude Code](https://claude.ai/code/session_01VF48aw8RPG6wzDnMgp6rtw)_ Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Oct 9, 2026
…-pr lane, and stop prerelease cuts re-dating the last GA (objectstack-ai#22095) Part of objectstack-ai#22085. This PR carries the half of the card that holds on measurement. The half it does not carry needs a decision first (see "What still stops the refresh"). Clause-②: no ## What this changes 1. **`sync-release-index-currency` no longer re-dates the newest GA on a prerelease cut.** `rewriteStatusField` re-stamped today's date into a `current series:` field that already named the newest GA. The gate it serves (`indexCurrencyFindings`) judges the version only. The date it holds is the release date of the version the run moves the field TO, so a run that moves nothing (every `next` or `rc` cut) is not that version's version commit. A same-version field is now left alone. Battery B's case that pinned the re-date is inverted, and a new battery, `Control H: a prerelease cut never re-dates the newest GA` (7 cases), pins the `next` cut, the `rc` cut, a positive control (a stale entry on the same later day still gets the version and that day), and `syncIndex` end to end on a temp checkout (no write, bytes identical). The roster floor goes from 7 to 8. 2. **`release.yml` `version-pr` › `Validate the post-version tree` validates the blank template's three major-boundary paths instead of refusing them.** It reuses gates the repo already runs, with no build: - `pnpm --filter create-objectstack test`: the template rendered by the scaffolder's own copy and identity rewrite, from a template packed the way npm ships it, plus the ratchets in `template-consistency.test.ts` that judge all three stamps against create-objectstack's NEW major; - `pnpm --filter @objectstack/spec check:template-manifests`. `protocol-version.ts` gets its gates too: - `protocol-version.test.ts` as ONE file (5 s). It does not need the whole spec suite, which is what the step's comment used to claim; - `check:spec-changes` and `check:upgrade-guide`, the two artifacts derived from `PROTOCOL_MAJOR`. It **stays refused**, for the measured reason below. The refusal is now collected instead of exiting first, so a boundary run reports every gate's verdict together. Every pnpm filter carries `--fail-if-no-match`, because a filter that matches nothing exits 0 having run nothing (measured: 0 without it, 1 with it). ## Pin: the boundary train, replayed (throwaway tree, never committed) Tree: this branch at `3079e4aef0` plus PR objectstack-ai#22084's `.changeset/pre.json` and `.changeset/22080-v18-line-opens.md`, carried by one local commit that was never pushed. The steps were extracted from this branch's `release.yml` with a YAML parser and run verbatim with `RUNNER_TEMP` / `GITHUB_OUTPUT` / `GITHUB_STEP_SUMMARY` set. | step | exit | reading | |---|---|---| | `Render the post-version tree` (the full root `version` script) | 0 | 69 of 69 `fixed` members at `18.0.0-next.0`, `pre.json` unchanged (`mode: pre`, `tag: next`). `PROTOCOL_VERSION 17.0.0 → 18.0.0`. Template stamps at `^18.0.0` / `'^18'`. 9 docs pins moved to `18.0.0-next.0`. Release index: "already names the newest GA … nothing rewritten" | | release index v17 entry | unchanged | `(current series: 17.7.0, released 2026-10-06)`; `git diff HEAD -- content/docs/releases/index.mdx` empty. Before this PR, the same replay wrote `released 2026-10-07` (reproduced on PR objectstack-ai#22084's head `52f7a509c6`) | | shape assertion | pass | 235 paths moved, 0 outside the reviewed surface. It was 236 before this PR; the difference is the release index | | `Validate the post-version tree` | **1** | 11 gates green: the 9 existing content gates except `check:release-notes`, plus the create-objectstack suite, `check:template-manifests` and the lockstep test. 3 gates red: `check:release-notes`, `check:spec-changes`, `check:upgrade-guide`. 1 unvalidated: `protocol-version.ts` | | `Restore the pre-version tree` | 0 | 37 pending changesets, tree clean | **Control on an ordinary train.** This branch without the two opening files is the 17.8.0 refresh objectstack-ai#21988 gets today. `Render` exits 0 (194 paths, and the release index stamps `17.8.0, released 2026-10-07`, version and date together). `Validate` exits **0** with all 14 gates green, holding 44 s. `Restore` exits 0. **Negative control for the template gate.** On the boundary tree, `specVersion` in the blank manifest was set back to `^17.0.0`. `template-consistency.test.ts` then exits 1 with 2 failed, and the file was restored by hash (`0956082cfd8e` both sides). **Baseline.** `main`'s own step, replayed on PR objectstack-ai#22084's head, exits 1 at the old blanket refusal and names all 4 paths. This reproduces the measurement the card rests on. ## Why `protocol-version.ts` is still refused (measured, not guessed) Moving the protocol major at version time does more than move a constant: - **Two derived artifacts go stale.** On the boundary tree, `check:spec-changes` exits 1 ("spec-changes.json is stale") and `check:upgrade-guide` exits 1. Both pass on the pre-version tree (control). Regenerating them in the throwaway changes `packages/spec/spec-changes.json` by 6342 lines, and adds a 1024-line `Protocol 17 → 18` section to `docs/protocol-upgrade-guide.md`. The version pass regenerates neither. Both gates run in the required `TypeScript Type Check` job, so a version PR let through as things stand would turn `main` red on its next ordinary PR. - **The handshake refuses this repository's own example apps.** `assertProtocolCompat` runs on the app load seam (`packages/runtime/src/app-plugin.ts:421`). Probed with the post-version constant, `checkProtocolCompat` gives `^17` → `incompatible` (`OS_PROTOCOL_INCOMPATIBLE`) and `^18` → `ok`. On the pre-version tree it is the reverse. `examples/app-crm`, `app-showcase` and `app-todo`, plus the two packages in `app-multi-package`, declare `engines: { protocol: '^17' }`. No gate this lane can afford boots them. So the refusal stays, as Done-when 1's last clause provides ("The refusal stays for any major-only path still unvalidated"), with its message rewritten to say exactly this. ## What still stops objectstack-ai#21988 refreshing into 18.0.0-next.0 These are three decisions, set out with options and a recommendation in the card's `os-dev-report`. None of them is made here: 1. **How the protocol major moves at the opening.** Either the version pass regenerates the two artifacts and restamps in-repo `engines.protocol` (the version chain grows), or an ordinary PR with CI moves the protocol major ahead of the version PR (the lockstep test's definition changes). 2. **`check:release-notes` is red at the boundary** (it is already in this step). It counts `## 18.0.0-next.0` as "shipped a 18.x release" and asks for `content/docs/releases/v18.mdx` plus its `meta.json` entry. That content is release-owned, and this PR does not write it. 3. **Docs image pins during the `next` line.** These were measured and not edited (Done-when 4). On the first prerelease, `sync-docs-image-tags` moves 9 pins from `17.7.0` to `18.0.0-next.0`: - `docker/README.md`: 3 image tags and 1 build-arg; - `content/docs/deployment/self-hosting.mdx`: 3 image tags and 1 npm pin; - `content/docs/upgrading.mdx`: 1 image tag. Meanwhile `npm view` gives `latest: 17.7.0` (and `rc: 17.0.0-rc.6`, no `next` yet) for `@objectstack/cli`, `@objectstack/spec` and `create-objectstack`. The ghcr `latest` tag does not move for a prerelease (`docker-publish.yml:83`). ## Verification - Derived gates (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `a4cbcfdb16`): 50 commands, all exit 0, exit codes written to disk before any pipe. `--ran`: `50 derived, 50 run, 0 NOT-MEASURED, 0 UNRUN`. The battery `pnpm check:pm-dispatch-gates` passed 1976 cases in 997.8 s. - `node scripts/sync-release-index-currency.mjs --self-test`: exit 0, 42 cases. - **Ablation, committed first.** With `ablation-replace.mjs`, the pre-fix logic (`const rewritten = …; return rewritten === field ? null : rewritten;`) was put back in place of the fix. The self-test exits 1 with exactly 5 failures: battery B's inverted case and 4 of Control H's 7. H's three controls stay green, as they should. The file was restored with blob equal to HEAD and `git diff HEAD` empty. A first, cruder ablation that only deleted the guard line also reddened B and C. That mutation was too strong, so the faithful one above is the reading. - Not run locally: the whole spec suite (the step no longer needs it), and the repo-wide lint farm, which belongs to CI. ## Acceptance notes (noted, not fixed here) - The comment above `Create or update the "chore: version packages" PR` in `release.yml` still says `pnpm run version` is FOUR rewriters. It is five (`sync-release-index-currency.mjs` joined). - `cut-rc.yml` says "On an RC cut this rewriter writes NOTHING". That was false for the date before this PR (an rc cut on a later day re-dated the entry) and is true after it. `cut-rc.yml` is not edited, by the card. - `release.yml`'s step comment said the lockstep test was "reachable only through the whole @objectstack/spec suite". That is corrected in place, because it is the step this card owns. Changeset: none. The diff touches `.github/workflows/release.yml` and `scripts/sync-release-index-currency.mjs`, and neither is in any package's `files[]`. Commits carry this repository's model-free trailer pair (AGENTS.md). --- _Generated by [Claude Code](https://claude.ai/code/session_01VF48aw8RPG6wzDnMgp6rtw)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Oct 9, 2026
…ollow the newest GA, not the prerelease (objectstack-ai#22134) Fixes objectstack-ai#22131 Clause-②: no Refs objectstack-ai#22085 (the parent; this PR carries its Q3 half). Ruling record: comment 6049734955 on objectstack-ai#22085, quoted as the dispatch carried it: > **Q3 → B. During the `next` line, the documented image and install versions follow the newest GA, not the prerelease.** In pre mode `sync-docs-image-tags` and `check-docs-image-tag` pin the newest GA (read from the CHANGELOG, the same way the GA is already judged); outside pre mode nothing changes. ⛔ Not taken: A (self-hosting and upgrade documents reading `18.0.0-next.N` while npm `latest` and the published image stay 17.7.0, so a production user copying the documents is led into the breaking prerelease line). ## What changed Two files, both under `scripts/`, nothing published: - `scripts/check-docs-image-tag.mjs` gains `expectedVersion(root)`, the ONE function that answers which version the doc surfaces must pin. - No `.changeset/pre.json`: `packages/cli/package.json`'s version (`VERSION_SOURCE`, kept with its name), as before. - `.changeset/pre.json` in mode `pre` or `exit`: the newest GA in `packages/spec/CHANGELOG.md`, overall (not of the CLI's major). - It reads through the existing readers only: `SPEC_CHANGELOG`, `gaVersions` and `newestGaOfMajor` from `check-release-section-coverage.mjs` (unchanged), and `readPre` from `check-changeset-no-major.mjs` (unchanged). No second GA reader and no third `pre.json` reader. - Every pre-mode state it cannot read throws instead of falling back: a `pre.json` that is present but does not parse, a mode Changesets never writes, no spec CHANGELOG, or a CHANGELOG with no GA heading. - `main()` judges against it. The STALE detail, the scope line and the red footer name the source used. A pre-mode red also prints why the expectation is not `packages/cli`'s prerelease. Outside pre mode the gate's output is byte-identical to `main`'s. - `scripts/sync-docs-image-tags.mjs` gains `syncRepo({ root })`, which `main()` runs: the target comes from the gate's `expectedVersion()`, followed by the existing `syncSurfaces()`. The rewriter and the gate therefore read one value. A pre-mode run logs why the docs did not follow `packages/cli`. Not touched: `content/docs/**`, `release.yml`, `cut-rc.yml`, `lint.yml`, the root `version` script, `.changeset/**`, objectstack-ai#21988. ## Premises measured on `origin/main` `8fc50b7647` 1. The root `version` script (`package.json:20`) is `changeset version && sync-protocol-version && sync-template-versions && sync-docs-image-tags && sync-release-index-currency`. Holds. 2. `VERSION_SOURCE`, `SURFACES` and `PROSE_CLAIMS` are as the card states, and the rewriter imports them from the gate. Holds. 3. `check-release-section-coverage.mjs` exports `SPEC_CHANGELOG`, `gaVersions` (ascending, GA-only, end-anchored) and `newestGaOfMajor`. Holds. - On a pre-mode tree the answer has to be the newest GA overall. After the first `next` cut the CHANGELOG's top heading is `## 18.0.0-next.0` and there is no GA of 18, so `newestGaOfMajor(versions, 18)` is `null`. - `@objectstack/spec` and `@objectstack/cli` are in one `fixed` group in `.changeset/config.json`, so the spec CHANGELOG's newest GA is also the CLI's. 4. Pre mode is `.changeset/pre.json`. **Refined:** Changesets writes it in two modes, and both are the prerelease line. - `changeset pre exit` only rewrites the mode to `"exit"`, so the tree stays on `18.0.0-next.N`. - The next `changeset version` then computes the GA and deletes `pre.json`. Measured in `@changesets/apply-release-plan` 8.1.1, which removes the file when the mode is `exit`; replayed below. - That `exit` commit reaches `main` before the Version Packages PR does. Keying on `"pre"` alone would expect `18.0.0-next.N` in that window while the docs read the GA, which reds this required gate on every PR in between, the exiting PR included. - So `exit` counts as pre mode here. The self-tests pin this case. 5. Other consumers: no change in meaning except the rc lane below. - `release.yml:569` and `cut-rc.yml:605` import `SURFACES` only. Re-run after the change, the import prints the same 3 paths and exits 0; the two new imports are side-effect-free, and `check:entry-guard` is green. - `lint.yml:2649` and `lint.yml:2672` run the two gates. Outside pre mode the output is identical. - **Correction to the dispatch's reading of `cut-rc.yml`:** it does not refuse pre mode. It refuses unless `pre.json` is mode `pre` **with tag `rc`** (`cut-rc.yml:232-236`). So it never runs on the `next` line, but it does run in pre mode on an rc line. - Under this change, an rc cut's version pass leaves the doc surfaces at the newest GA instead of stamping `X.Y.Z-rc.N`. That follows the ruling's text ("in pre mode") and its reason, a production reader led into a prerelease, which applies to an rc as well. - Its allowlist permits the doc surfaces to change and does not require it (`git add -A -- … "${DOCS_SURFACES[@]}"`), so a cut that leaves them unchanged passes. The self-test pins the rc case: CLI `17.8.0-rc.0`, expected `17.7.0`. ## The pin, replayed Each run used a throwaway `git worktree add --detach` under the session scratch directory, then `pnpm install --frozen-lockfile --offline --ignore-scripts` and `pnpm run version`. All trees were removed afterwards. PR objectstack-ai#22084's two files were copied from its head `a630de657d`: `.changeset/pre.json` (`{"mode": "pre", "tag": "next"}`) and `.changeset/22080-v18-line-opens.md`. **Tree 1: this branch (`a4b1b5106d`) plus PR objectstack-ai#22084's two `.changeset` files.** `pnpm run version` exit 0. `packages/cli` went from `17.7.0` to `18.0.0-next.0`, and the spec CHANGELOG's top heading is now `## 18.0.0-next.0`, above `## 17.7.0`. ```text ✓ sync-docs-image-tags: all 9 concrete pin(s) across 3 surface(s) already at the newest GA in packages/spec/CHANGELOG.md (pre mode) 17.7.0 — nothing rewritten. Pre mode: .changeset/pre.json is in mode "pre" (tag "next"), so the documented versions follow the newest GA, 17.7.0, and not packages/cli/package.json's 18.0.0-next.0. ... $ git diff -- docker/README.md content/docs/deployment/self-hosting.mdx content/docs/upgrading.mdx | wc -l 0 pins on the 3 surfaces: 7 x ghcr.io/objectstack-ai/objectstack:17.7.0, 1 x OS_CLI_VERSION=17.7.0, 1 x @objectstack/cli@17.7.0 $ pnpm check:docs-image-tag # exit 0 check-docs-image-tag: OK (3/3 enumerated surface(s) read, 9 concrete pin(s) compared against the newest GA in packages/spec/CHANGELOG.md (pre mode) 17.7.0, ...) ``` **Tree 1, continued: the exit window and the GA pass.** These are the states premise 4 is about. ```text $ pnpm exec changeset pre exit # .changeset/pre.json → {"mode": "exit", "tag": "next"}; packages/cli still 18.0.0-next.0 $ pnpm check:docs-image-tag # exit 0, compared against the newest GA in packages/spec/CHANGELOG.md (pre mode) 17.7.0 $ pnpm run version # exit 0; .changeset/pre.json deleted; packages/cli 18.0.0 ✓ sync-docs-image-tags: 9 pin(s) across 3 surface(s) → 18.0.0 (lockstep with packages/cli/package.json). $ pnpm check:docs-image-tag # exit 0, compared against packages/cli/package.json 18.0.0 ``` **Tree 2: this branch on plain `main`, without `pre.json`.** `pnpm run version` exit 0. `packages/cli` went from `17.7.0` to `17.8.0` (the 58 pending changesets), and the surfaces move to the CLI's version, as they do today. ```text ✓ sync-docs-image-tags: 9 pin(s) across 3 surface(s) → 17.8.0 (lockstep with packages/cli/package.json). pins on the 3 surfaces: 7 x ghcr.io/objectstack-ai/objectstack:17.8.0, 1 x OS_CLI_VERSION=17.8.0, 1 x @objectstack/cli@17.8.0 $ pnpm check:docs-image-tag # exit 0, compared against packages/cli/package.json 17.8.0 ``` **Control tree: `main` at `8fc50b7647`, without this change, plus PR objectstack-ai#22084's two files.** This is the state the ruling rejects (⛔ A): ```text ✓ sync-docs-image-tags: 9 pin(s) across 3 surface(s) → 18.0.0-next.0 (lockstep with packages/cli/package.json). pins on the 3 surfaces: 7 x ghcr.io/objectstack-ai/objectstack:18.0.0-next.0, 1 x OS_CLI_VERSION=18.0.0-next.0, 1 x @objectstack/cli@18.0.0-next.0 ``` ## Self-tests and the ablation New batteries, each case with its positive control: - In the gate, `Pre mode: the expectation is the newest GA (objectstack-ai#22131)` has 18 cases. The roster floor goes from 11 to 12, and the run has 112 assertions. - In the rewriter, `Control I: in pre mode the target is the newest GA, from the gate's one function` has 10 cases and runs `syncRepo()`. The roster floor goes from 8 to 9, and the run has 45 assertions. The three Done-when cases, and what each is controlled against: | Case | Expected result | Positive control | |---|---|---| | Pre mode, CLI `18.0.0-next.0`, newest GA `17.7.0` | expects `17.7.0` | The same tree's CLI reads `18.0.0-next.0`, and the CHANGELOG has no GA of 18 | | The same tree without `pre.json` | expects the CLI's `18.0.0-next.0` | Case 1 answers differently, and `pre.json` is the only difference between the two trees | | Pre mode, docs already at the GA | gate green, rewriter writes nothing (byte-identical, mtime unchanged) | The same docs against the CLI's version are 3 STALE in the gate and 3 rewrites in the rewriter | Further cases cover mode `exit` and an rc pre mode, which both expect the GA. Pins moved onto the prerelease in pre mode are STALE, and the finding names the pre-mode source. Each of the four unreadable states is refused, and the rewriter refuses before any write. **Ablation, at `a4b1b5106d`.** The change was committed first. `node scripts/ablation-replace.mjs` rewrote the pre-mode test in `expectedVersion()` (`if (!existsSync(join(root, PRE_STATE))) {` became `if (true) {`), which is the old logic: `pre.json` ignored. - The mutation landed: the anchor count went 1 → 0, the marker count during the mutation was 1, and the blob went `e27045098c80` → `97307ba4e54a`. - `node scripts/check-docs-image-tag.mjs --self-test` exited 1 with **13 failures**, every one a new pre-mode case. - `node scripts/sync-docs-image-tags.mjs --self-test` exited 1 with **6 failures**, every one a new pre-mode case. - The controls that do not depend on the branch stayed green. - The restore was proven by blob hash: after the restore the blob is `e27045098c80`, equal to `HEAD:scripts/check-docs-image-tag.mjs`, and `git diff HEAD` is empty. ## Gates, at `a4b1b5106d` `node scripts/pm/dispatch-gates.mjs --commands` was derived from this worktree over the actual diff (2 paths, +548/-39) and gave 31 commands, the same 31 the dispatch named. All 31 were run and every one exited 0. - `pnpm check:pm-dispatch-gates` passed 1976 cases (856s). - `pnpm check:docs-image-tag` (112 assertions, then OK on the live tree) and `pnpm check:docs-image-tag-sync` (45 assertions) passed. - `check:entry-guard`, `check:nul-bytes`, `check:scripts-symbol-anchors` and `check:declaration-mirrors` passed. - `dispatch-gates --ran`: 31 derived, 31 run, 0 NOT-MEASURED, 0 UNRUN. Lint was a proven narrowing to the changed files, not the repo-wide `pnpm lint`, which CI runs: - Population, read from eslint's own config: `isPathIgnored` is `false` for both files. - Count, from `--format json`: 2 results, 0 errors and 0 warnings, with `--no-inline-config`. - Invariance: this repo's eslint config never enables type-aware linting (no `parserOptions.project`), so this diff cannot change the verdict on any untouched file. No changeset is needed. The root package is private and no package's `files[]` ships `scripts/`. Labelled `skip-changeset`. ## Acceptance notes None of these blocks this PR, and none is a defect. Each is wording that this change makes slightly inaccurate, in a file outside this PR's declared surface. Carrier for each: none. - `scripts/check-changeset-no-major.mjs`'s entry-guard comment says "Nothing imports this file today". `check-docs-image-tag.mjs` now imports `readPre`. The guard already makes the import inert, as `check:entry-guard` confirms. - `release.yml`'s post-version comment ("check:docs-image-tag — the 3 doc surfaces against packages/cli's NEW version") and `lint.yml`'s step name "Docs image tags track packages/cli's version" are true outside pre mode only. In pre mode the expectation is the newest GA. - `cut-rc.yml`'s staging comment (around line 497) lists "the 3 doc surfaces" among a cut's paths. An rc cut now leaves them unchanged (premise 5). - dispatch-gates now routes `.changeset/pre.json` to `check:docs-image-tag`, because the gate spells it. It does not route `packages/spec/CHANGELOG.md`, which is read only through an import from a gate module. That file is release-owned and only the version pass writes it. --- _Generated by [Claude Code](https://claude.ai/code/session_01VF48aw8RPG6wzDnMgp6rtw)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Oct 9, 2026
…ng the CEL spelling of each token (objectstack-ai#19939, C half) (objectstack-ai#22259) Part of objectstack-ai#19939 — this lands the C half for every spelling CEL can write today, and measures the B half empty. What stays open on the card: refusing the two spellings this PR deliberately keeps (`{NOW()}` / `{TODAY() ± N}` and `{$User.*}`), each once CEL can spell it — see "Kept, and why" and the report's open question. Clause-②: yes (narrowing) ## The rulings this executes (quoted, not paraphrased) - objectstack-ai#11182 ruling D (record `5805777944`, maintainer 「11182 D 其他同意」), item 3: "**v18 carrier.** C (refuse the template dialect at registration, per-spelling remedies: `/ 100.0`, `has()` guards, a string form for `NOW()` / `TODAY()`) and whatever of B is lossless ride the v18 train"; governing text: "ADR-0087 D2 (only lossless mappings ride an automatic conversion — the 12 DIFF spellings the round measured are not lossless)". - The card body: "B, only where lossless. An ADR-0087 D2 conversion for the spellings the objectstack-ai#11182 round measured as SAME under both engines (13 of 25); the 12 DIFF spellings are ⛔ never auto-converted (a semantic rewrite is not a conversion)." and "this repo's 21 sites migrate in the same wave". - Triage `6051196407`: the card is `domain:spec` whole; the `service-automation` and `lint` files are declared cross-lane on the claim `6052247536`. The text-slot interpolation in `builtin/template.ts` is objectstack-ai#22110's and is untouched (one docblock paragraph names the value-slot retirement; no code there changed). - Release state, re-read at push on `origin/main` `f4bed5834` (merged into this branch): `.changeset/pre.json` is **present** (`"mode": "pre"`, `"tag": "next"` — objectstack-ai#22084 entered pre mode at 07:03Z today), so per triage `6038868940` and the dispatch this is graded `major`, with a BREAKING section and an ADR-0087 `registered` disposition. It was absent at the claim (`959c209d5`) and at this branch's first merge of `main` (`7d7943dd0`); the changeset moved from `minor` to `major` in the commit after the second merge. ## What changes A flow VALUE slot no longer reads the single-brace `{…}` template dialect. In every value slot — `create_record.fields.*`, `update_record.fields.*`, the `assignment` node's `assignments` map, and the two legacy `assignment` shapes the executor still reads (the `assignments: [{ variable, value }]` array and the bare config) — a string, or a string at any depth of an array or object value, that carries a `{…}` token the interpolator would resolve is refused, with the CEL spelling of each token. A string with no token is the literal text it spells; a computed value is a CEL value envelope. **One judge, every door** — `packages/spec/src/automation/flow-value-slot-template.ts`: - `valueSlotTemplateRefusals(value)` judges one value; `flowNodeValueTemplateRefusals(nodeType, config)` locates every refusal in a node's config (the ledger's `value` slots through `resolveFlowNodeValueSlots`, plus the two legacy `assignment` shapes normalised exactly as the executor normalises them); `VALUE_SLOT_TEMPLATE_REFUSAL` is the sentence every refusal leads with. - The contract says it: `celValueSlotSchema` composes the judge, so `FlowValueSlotSchema`, `AssignmentValueSchema`, `CreateRecordConfigSchema` and `UpdateRecordConfigSchema` refuse it at the value's path, and `AssignmentConfigSchema`'s catchall (the bare legacy shape) does too — one new dropped-refinement site, ledgered (`automation/AssignmentConfig` `out.catchall`, totals 678 → 679). - **Registration** — `AutomationEngine.registerFlow` (`validateFlowExpressions`) pushes one located failure per refused string: `node 'w' (create_record) create_record field value at config.fields.total: …`. - **Build door** — `@objectstack/lint` `validateStackExpressions` reports the same refusal as `expression-invalid` at `error` (the existing rule family; it gates `os validate`, `os compile` and the metadata save door). This replaces the `warning` hint ruling D point 1 put there for 17.x. - **Run time** — the `create_record` / `update_record` executors refuse it at their own `parseNodeConfig` (through `FlowValueSlotSchema`), and the `assignment` executor calls `flowNodeValueTemplateRefusals` before it assigns anything; both return a guard refusal, so a fault edge cannot route it. **The remedies, per spelling** (the refusal names them for the authored token): | you wrote | the refusal prescribes | what changes | |:--|:--|:--| | `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an absent variable or key where the template wrote nothing — the `has()` guard: `has(record.owner) ? record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`) | | `'{list.0}'` | `source: 'list[0]'` | an empty list fails the run | | `'{$error.message}'` | `source: 'vars["$error"].message'` | a `$`-named variable is read through `vars` | | `'{round(x * 100) / 100}'` | `source: 'round(x * 100) / 100.0'` | `/ 100.0`: CEL divides two integers as integers (`123.46` becomes `123`) — every integer divisor is rewritten in the prescription | | `'Renewal — {contract.number}'` | `source: "'Renewal — ' + contract.number"` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` | | `'{"a": 1}'` (braces meant literally) | `source: "'{\"a\": 1}'"` | a CEL string literal | ## B — measured empty: no spelling is lossless (ADR-0087 D2) Re-measured on this branch, not copied: every spelling was evaluated through the shipped interpolator (`interpolateString`) and through the shipped CEL value path (`AutomationEngine.evaluateValueEnvelope`, the real `celScope`) over the same variables. The 25-row grid reproduces the objectstack-ai#11182 round exactly — **13 SAME / 12 DIFF**: | # | spelling and input | template | CEL | verdict | |:--|:--|:--|:--|:--| | S1–S4 | `{name}`, `{amount}`, `{oppRecord.name}`, `{oppRecord.amount}` — key present | the value | the value | SAME | | S5 | `{userList.0}` → `userList[0]`, list non-empty | `"u1"` | `"u1"` | SAME | | S6 | `{$error.message}` → `vars["$error"].message`, present | `"boom"` | `"boom"` | SAME | | S7, S8 | `Hello {o.name}`, `Total: {amount}` → concatenation, holes present | the text | the text | SAME | | S9 | `{o.owner}`, key present with `null` | `null` | `null` | SAME | | S10 | token-free `converted` → `'converted'` | the text | the text | SAME | | S11 | `{round(… / 100 * 100) / 100}`, integer-valued amount | `54000` | `54000` | SAME | | S12, S13 | `{rows}` (a list), `{flag}` (a boolean) | the value | the value | SAME | | D1, D2 | the money spelling, `amount` `1234.56` | `123.46`, `1111.1` | `123`, `1111` | DIFF | | D3 | `{10 / 4}` | `2.5` | `2` | DIFF | | D4, D5 | `{NOW()}` → `now()`, `{TODAY()}` → `today()` | ISO text | a `Date` (Timestamp) | DIFF | | D6 | `string(now())` | ISO text | refused: no `string(Timestamp)` overload | DIFF | | D7 | `{missing}` — variable absent | `undefined` | fault: unknown variable | DIFF | | D8 | `{oppRecord.owner}` — key absent | `undefined` | fault: no such key | DIFF | | D9 | `{userList.0}` — empty list | `undefined` | fault: index out of bounds | DIFF | | D10 | `Hello {o.owner}` — hole `null` | `"Hello "` | fault: no overload for string plus null | DIFF | | D11 | `{$User.Id}` → `current_user.id` | the run user id | fault: unknown variable `current_user` | DIFF | | D12 | token-free `converted` read as CEL source | the text | fault: unknown variable | DIFF | The "13 of 25" in the card counted **probes**, not spellings: S1–S13 are the present-key / integer-valued scenarios of the same spellings whose absent-key, null-hole, decimal and Timestamp scenarios are D1–D12. Read per spelling — the unit a conversion rewrites — every authored spelling has a DIFF input: a path faults where the template wrote nothing (D7–D9), text with holes faults on a null hole (D10), arithmetic truncates (D1–D3), the date macros change type (D4–D6), `$User` has no binding (D11). Only a token with no variable in it (`{1.5}`, `{100}`) maps losslessly, and none is authored in either repository. Controls beyond the 25: a `has()` guard writes `null` where the template wrote nothing (X2–X4), so it is a semantic rewrite, not a conversion. So, by D2's letter and the card's own "a semantic rewrite is not a conversion", **no D2 conversion is registered**; the retirement is the D3 semantic entry `flow-value-slot-template-dialect-refused` (step 18, rationale fragment order 88). A pin replays the whole conversion chain, retired entries included, over every measured spelling and asserts each value comes out as authored. ## Kept, and why — two spellings CEL cannot write yet A refusal must name what to write instead. For two spellings there is nothing to name, measured: - **The date macros** — `{NOW()}`, `{TODAY()}`, `± N` days. CEL's `now()` / `today()` / `daysFromNow()` / `addDays()` yield a Timestamp, which reaches the data engine as a `Date` object (measured through ObjectQL with a recording driver: `today()` into a `date` field arrives as `Date(2026-10-08T00:00:00.000Z)` where the macro wrote `"2026-10-08"`), and `string(today())` is refused for want of an overload. This is the card's own contingency ("a `packages/formula` sub-card if v18 needs it") — it does. - **The run user** — `{$User.*}`. The flow CEL scope binds no user (`current_user.id` faults, D11). Binding ADR-0068's canonical `current_user` there is a contract decision this PR does not take (open question in the report). A string whose tokens include one of these keeps its 17.x meaning; everything else in it would be refused if moved alone, so the whole string is kept. Their refusal is the remaining half of objectstack-ai#19939, after the two prerequisites. ## This repository's sites (census at `959c209d5`) A TypeScript-AST walk over `git ls-files` (every `create_record` / `update_record` `fields` value and `assignment` value, all three shapes, same-file spreads): **21 authored sites**, 20 migrated here, 1 kept. | file:line (base) | before | after | |:--|:--|:--| | `examples/app-crm/src/flows/convert-lead.flow.ts:148` | `'{account_id}'` | `source: 'account_id'` | | `examples/app-crm/src/flows/convert-lead.flow.ts:149` | `'{opportunity_id}'` | `source: 'opportunity_id'` | | `examples/app-showcase/src/automation/flows/index.ts:115` | `'{new_assignee}'` | `source: 'new_assignee'` | | `examples/app-showcase/src/automation/flows/index.ts:1235` | `'{$error.message}'` | `source: 'vars["$error"].message'` | | `examples/app-showcase/src/automation/flows/index.ts:1602` | `'{record.title}'` | `source: 'record.title'` | | `examples/app-showcase/src/automation/flows/index.ts:1603` | `'{record.assignee}'` | `source: 'has(record.assignee) ? record.assignee : null'` | | `examples/app-showcase/src/automation/flows/index.ts:1604` | `'{record.project}'` | `source: 'has(record.project) ? record.project : null'` | | `examples/app-todo/src/flows/task.flow.ts:377` | `'{completedTask.subject}'` | `source: 'completedTask.subject'` | | `examples/app-todo/src/flows/task.flow.ts:377` | `'{completedTask.description}'` | guarded `has(completedTask.description) ? … : null` | | `examples/app-todo/src/flows/task.flow.ts:378` | `'{completedTask.priority}'` | guarded | | `examples/app-todo/src/flows/task.flow.ts:378` | `'{completedTask.category}'` | guarded | | `examples/app-todo/src/flows/task.flow.ts:379` | `'{completedTask.owner}'` | guarded | | `examples/app-todo/src/flows/task.flow.ts:380` | `'{completedTask.recurrence_type}'` | guarded | | `examples/app-todo/src/flows/task.flow.ts:381` | `'{completedTask.recurrence_interval}'` | guarded | | `examples/app-todo/src/flows/task.flow.ts:384` | `'{nextDueDate}'` | `source: 'nextDueDate'` | | `examples/app-todo/src/flows/task.flow.ts:457` | `'{subject}'` | `source: 'subject'` | | `examples/app-todo/src/flows/task.flow.ts:457` | `'{priority}'` | `source: 'has(vars.priority) ? vars.priority : null'` | | `examples/app-todo/src/flows/task.flow.ts:457` | `'{dueDate}'` | `source: 'has(vars.dueDate) ? vars.dueDate : null'` | | `examples/app-todo/src/flows/task.flow.ts:457` | `'{category}'` | `source: 'has(vars.category) ? vars.category : null'` | | `examples/app-todo/src/flows/task.flow.ts:457` | `'{$User.Id}'` | **kept** (the run user — see above) | | `packages/verify/src/handle.fixture.ts:191` | `'{resolution}'` | `source: 'resolution'` | Each guard is a judgment the template made silently: a field a screen may leave empty, or a key a row may not carry, writes `null` (on insert a field default still applies). Docs code samples migrated too: `content/docs/automation/flows.mdx` (the assignment and create-record examples, an inline comment, the hot-lead example), `content/docs/kernel/runtime-services/examples.mdx` (two fields), `packages/services/service-automation/README.md` (one field), and the test fixture `packages/qa/dogfood/test/fixtures/flow-durable-suspend-fixture.ts:81`. **hotcrm** (public, read-only clone at `c529de2`, not touched): 91 authored sites (2 of them through the same-file `MEMBERSHIP_FIELDS` spread) — **71 refused** (31 bare references, 36 dotted paths, 4 text with holes) and **20 kept** (15 date macros — 8 `{NOW()}`, 3 `{TODAY()}`, 3 `{TODAY() + N}`, 1 `{TODAY() + var}` — and 5 `{$User.Id}`, all `owner_id` on `create_record`); plus 5 in tests (4 refused, 1 kept). Its two money sites already use the CEL envelope with `/ 100.0`. ## H4 — where `{var}` is still read after this change - **In the three value slots:** the interpolator call stays (`crud-nodes.ts` `resolveFieldValues`, `logic-nodes.ts`), reached only by the two kept spellings; on every other literal it is the identity. It is removed when the kept spellings are refused. Pinned: the kept spellings resolve through the executors (`logic-nodes.test.ts`, `crud-fields-value-envelope.test.ts`), and `value-slot-template-grammar.test.ts` drives the interpolator over every kept and refused spelling so the spec's copy of the token grammar cannot drift from `resolveToken`. - **Outside them, unchanged by this card:** text slots (objectstack-ai#22110's: `notify` title / message, screen text, `end` message), `filter` values (`interpolateFilter`, the filter-placeholder hand-off), `loop.collection` / `map.collection` (the ledger's `flow-template` role), and the value-like positions `subflow.input`, `map.input`, `script.inputs`, `screen.defaults`, a screen field's `defaultValue`, and `http` (interpolated whole before its parse). ## Wrong guidance (ruling D item 2) `builtin/template.ts` and `content/docs/automation/flows.mdx` already carried `/ 100.0` on `main` (objectstack-ai#20205). This PR removes the last `round(x * 100) / 100` claim in its surface: the comment in `flow-field-expression-scale.integration.test.ts` that called it "the CEL-identical authoring pattern" (the oracle now runs as a CEL envelope with `/ 100.0`, and the docblock states integer division). `skills/objectstack-automation/SKILL.md:232` still reads `{round(x * 100) / 100}` and teaches `{token}` in `fields`; it is Tier H and not in this PR. ## BREAKING An accept-set narrowing on a published authoring surface (`Clause-②: yes (narrowing)`, copied from the claim), graded `major` on the v18 `next` pre line; the changeset carries the BREAKING banner, the FROM → TO table above and the ADR-0087 disposition `registered flow-value-slot-template-dialect-refused`. A stored flow carrying a refused value is refused at registration (skipped at boot with a warn naming it); `os validate` names each one with its CEL spelling. ## Tests and gates This PR opens at `b073d92de`. The package suites, typechecks and consumer runs below were read at `31c52e59c` (or the commit named); the second merge of `main` after it changed nothing under `packages/spec/src/automation`, `packages/lint`, `packages/services/service-automation`, `packages/triggers`, `packages/qa`, `packages/verify` or `examples` (in `packages/cli`, only its secret-rewrap files), and the spec build, generated-artifact check, spec migration / conversion / automation suites and every gate were re-run at `b073d92de`. The box is shared, so durations are not quoted. Builds, tests and typechecks ran through `scripts/pm/os-verify-lock.sh`, each read off its `VERDICT command-exit` line. - **Package suites** (vitest, `--maxWorkers=2`): `@objectstack/spec` 679 files, 19605 passed + 1 todo; `@objectstack/lint` 125 files, 5730 passed; `@objectstack/service-automation` 176 files, 2157 passed. - **Typecheck** (`pnpm --filter … run typecheck`, exit 0 each): spec, lint, service-automation, trigger-record-change, dogfood, cli, example-todo, example-showcase, example-crm, verify. - **Consumers** (after a full `turbo run build --concurrency=2`, exit 0): `trigger-record-change` 11 files / 114; `trigger-schedule` 8 / 174; `plugin-approvals` 61 / 899; `verify` 18 / 133; `example-todo` 7 / 238 (at `0d7eb274c`); `example-showcase` 33 / 408; `example-crm` 5 / 45; `mcp` 7 / 74, `metadata-protocol` 6 / 127 and `runtime` 20 / 548 — each the files of that package that author these node types or load an example (a narrowing, declared: the rest of those suites is CI's); `cli` unit 2 / 14 and integration 2 / 14 (the integration project run because this diff edits `package-install-local-boot-steps.integration.test.ts`; four more cli files I named are integration-tier and declared to CI); `dogfood` 3 / 25 (`flow-trigger-record-credential-mask`, `flow-durable-suspend`, `expression-conformance`). - **The new pins**: `packages/spec/src/automation/flow-value-slot-template.test.ts` (every refused class with its remedy, the kept spellings, the controls, every value-slot contract, every value position of a node, and the no-conversion replay); `packages/lint/src/validate-expressions.fields-value-slot.test.ts` (the build door: `expression-invalid` at `error`, located, in all three value slots and both legacy shapes; kept spellings and `filter` clean); `packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts`, `logic-nodes.test.ts` and `assignment-value-envelope.test.ts` (registration and the run-time twin, nothing written, kept spellings resolve); `value-slot-template-grammar.test.ts` (the spec judge against the interpolator, kept and refused spellings and the dispatch-order edges). - **Generated artifacts**: `pnpm --filter @objectstack/spec check:generated` — 15 of 15 current after `--fix` regenerated `api-surface/`, `export-origins/` and `content/docs/references/**` on the merged tree, re-read exit 0 at `b073d92de` after `pnpm --filter @objectstack/spec build` (exit 0); `dropped-refinements.baseline.json` hand-edited (one site, totals 678 → 679). At `b073d92de` the spec's `src/migrations`, `src/conversions`, `src/automation` and `scripts` suites: 115 files, 3251 passed. - **Gates**: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `b073d92de` (47 paths vs merge base `f4bed5834`) derived 120 commands; all 120 ran with their exit codes captured before any pipe. 119 answered exit 0 on the first pass; `check:skill-examples` answered exit 3 PREREQUISITE NOT MET (`packages/client/dist` older than `src` after the merge — nothing measured), and after `pnpm --filter @objectstack/client build` (exit 0) it answered exit 0, "262 prose examples type-check across 3 surface(s)". `--ran` reconciliation: "120 derived famil(ies) accounted for — 120 run, 0 NOT-MEASURED". The printed artifact-roster block (35 roster, 14 checker-health self-tests) and the 11 declared wide-population families ran too: 57 exit 0; `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths` answered exit 2 "NOT WIRED" (they need a PR number / body) — NOT MEASURED there; `check-partof-closing-keyword` with this body as `PR_BODY` is in the report. - **Merged `main`** twice through `scripts/pm/os-regen-merge.sh` (each merge committed first, regeneration as its own commit): at `7d7943dd0` (`pnpm install --frozen-lockfile` after it), and at `f4bed5834`, which brought `.changeset/pre.json` and objectstack-ai#22166's step-18 entry (`sys-setting-global-rung-moved`, rationale order 87 — this PR's fragment keeps 88, the next free one; `gen:migration-registry` re-run on the merged tree changed nothing). Seven later `main` commits (to `73a0a6bf1`) are not merged: `git merge-tree` answers clean, their three overlapping files (`packages/lint/src/validate-expressions.ts` / `.test.ts`, `packages/spec/src/migrations/registry.ts`) change other regions, and none adds a `{…}` value-slot string. ## Acceptance notes - **`current_user` in a flow's CEL** — the build doors and the run disagree today, independently of this PR: `validateExpression('predicate', "current_user.id == 'u1'", { scope: 'flattened', … })` (the check `registerFlow` and `os validate` run on a flow condition) answers `ok`, and `ExpressionEngine.evaluate` over the flow's scope shape answers "Unknown variable: current_user" (measured at those two primitives; a door-level run is not part of this PR). Binding ADR-0068's `current_user` in the flow CEL scope would close that and give `{$User.*}` its remedy — the open question in the report. - **Value-like `{var}` positions outside this card's three slots** still read the dialect: `subflow.input`, `map.input`, `script.inputs`, `screen.defaults`, a screen field's `defaultValue`, and `http`. Text slots are objectstack-ai#22110's; these have no carrier. - **Two findings on one value**: `validate-flow-template-paths` still checks a `{record.…}` path inside a value-slot string that is now refused anyway, so such a value draws both its path finding and the refusal. Harmless; no carrier. - **The save door**: the refusal is a `validateStackExpressions` finding, the rule the runtime publish gate runs on a flow write, so a Studio / REST / MCP save of such a flow answers `422 INVALID_METADATA` by construction — not separately measured in this PR (objectstack-ai#19938 measured that door for the envelope arm of the same rule). - **Kept-spelling fixtures**: spec and lint test fixtures that only go through `FlowSchema.parse` or a non-expression rule (`flow.test.ts`, `validate-field-consumers.test.ts`, `validate-flow-template-paths.test.ts`, `validate-readonly-flow-writes.test.ts`) still spell a value-slot template; they pass, because `FlowSchema` judges no value slot and those rules filter by their own id. --- _Generated by [Claude Code](https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Oct 9, 2026
… name per deployment — a second holder is refused at registration, naming both (objectstack-ai#22197) Fixes objectstack-ai#22135 Clause-②: no Executes the maintainer's ruling Q4 = A on objectstack-ai#15196 (ruling record 6050490870): positions, permission sets and capabilities each hold one name per deployment. A package registering a name that an installed package, the environment catalog or a built-in already holds is refused, and the error names both holders. ADR-0048 §3.4's coexistence stands for every other metadata type. The ADR-0048 §3.4 narrowing note was Tier H and rode its own PR, objectstack-ai#22198, now on `main`. This PR carries no `docs/adr/**` file. ## What changed - **`packages/objectql/src/security-catalog-namespace.ts` (new).** The rule in one place: the three types, the built-in names, the holder vocabulary (`package` / `environment` / `built-in`), and the reader of a manifest's declared names. It reads the same sources the engine's registration seams read: the manifest's own `positions` / `permissions` / `capabilities` and each nested `plugins[]` entry's, arrays only. A manifest-stage `permissions` grant block is never read as permission sets. - **`SchemaRegistry.installPackage` — the package door.** It refuses ahead of every mutation, beside the namespace gate, so a refused package leaves no record, no namespace ownership and no claim. Every conflict is listed in one refusal. The package's claims are recorded after a successful install and released by `uninstallPackage`. The claims are what the door reads for names no registered item records, and `installPackage` itself registers no items. Through `ObjectQL.registerApp` (every boot and hot-install door), a package's permission sets and capabilities are also registered items under the package, and so are its positions since objectstack-ai#22262 landed on `main`. There the claims agree with the items. With the claims ablated, the `registerApp` doors still refuse and the direct `installPackage` door does not (Patch round 3), so the claims stay. - **`SchemaRegistry.registerItem` — the item seam.** A package-bound registration of a catalog type over a name another holder holds is refused before anything is stamped or stored. Built-ins are not asked here: the platform registers its own built-in positions at this seam, under its own package id (the S2 stage, now on `main`), and that registration is the built-in holder's own. For a built-in name the environment holder is not asked either; see Patch round 2. A registration with no package is the bare slot, which is what every `sys_metadata` hydration and metadata write-through writes. It is never judged: an environment save over a package-held name is outside the ruling. - **The envelope** reuses the namespace gate's shape and registered code: `code: 'NAMESPACE_CONFLICT'` (`NAMESPACE_CONFLICT_CODE`, already exported), `status: 422`, `httpStatus: 422`. The condition is the same one, a name in a deployment-wide namespace already taken, and so is the remedy: rename, or uninstall the other holder. The class (`SecurityCatalogNameConflictError`) stays unexported, as the registry's other refusal classes are. It is not the namespace gate's class, whose message names a `manifest.namespace` and offers the `OS_METADATA_COLLISION=warn` downgrade. Neither is true here, and `collisionPolicy: 'warn'` does not downgrade this refusal (pinned). - **No new error code; no `packages/spec` change.** ### Where the doors are, measured The card names three doors. What this PR measured is that all three are reached through ONE: `ObjectQL.registerApp` → `SchemaRegistry.installPackage`, which every package registration hits in the kernel's Phase 1, before any `start()`. - `AppPlugin`'s security registrar (`registerInMemory`, the `'app-plugin'` registrar) and the artifact door (`MetadataPlugin._registerArtifactBodyCollections`, the `'artifact-door'` registrar) both run in Phase 2. - Neither runs for a package the engine has not installed: `AppPlugin.init` registers every package of its bundle through the `manifest` service first, a multi-package artifact package by package. - So the producer-side fix is the package door, and `packages/metadata/src/plugin.ts` and `packages/runtime/src/app-plugin.ts` are unchanged. The runtime pins boot both registrars' real compositions and see the boot refused before either runs. ## Door table: base vs head "Base" is the same tree with both gates ablated, at 1604e09 (rows 1, 2 and 6 were also measured on the untouched base 7ef50a4, with the same answers). "Head" is 8ad6385. Boots go through `@objectstack/verify`'s `bootStack`; the artifact rows go through `createStandaloneStack`. | Door | Base | Head | |---|---|---| | Boot, door-less (`new AppPlugin(stack)`): two stacks sharing a position, a permission set and a capability name | boots. The by-name read answers the position from the LAST stack (metadata-service slot) and the set and the capability from the FIRST (registry order) | boot refused: `422 NAMESPACE_CONFLICT`, 3 conflicts, second stack vs first stack | | Boot: an app declaring `everyone` / `manage_users` / `admin_full_access` | boots. The by-name read of `admin_full_access` answers the APP's set | refused. Holder `built-in` for the first two. For `admin_full_access` the app registers before `plugin-security` in `bootStack`, so the platform's registration is the one stopped, naming the app | | Artifact boot: two packages of one artifact sharing names; a package declaring `everyone` | boots (runtime pins red under ablation) | refused in Phase 1, before the artifact door registers anything | | Hot install: post-boot `manifest.register` over a held name | accepted, package record written | refused, no record | | Hot install: `POST /api/v1/marketplace/install-local`, inline manifest | `200`, installed | `422 PLUGIN_REGISTER_FAILED`, the route's own code, with this refusal's message in `error.message`; no record | | `POST /api/v1/packages` | `400`: the strict body refuses `positions`, the retired `capabilities` and a flat `permissions` list | unchanged. No catalog collection can arrive here | | Environment catalog holds a permission set, then a package declaring it is hot-installed | accepted | refused, holder `environment` | | Same-package hot reload | accepted | accepted | | Environment save over a package-held permission set (`PUT /api/v1/meta/permission/NAME`, with or without `?package=`) — not covered by the ruling | `403 NOT_OVERRIDABLE` (the packaged permission-set lock) | unchanged | | Environment save of a position over a package-held position name (`PUT /api/v1/meta/position/NAME`) — not covered by the ruling | `200`, and the saved position then answers the by-name read ahead of the package's | unchanged by this PR. Since objectstack-ai#22262 landed on `main`: `403 NOT_OVERRIDABLE` (see Acceptance notes) | Named but not measured: - **The artifact door's HMR reload** (`MetadataPlugin._reloadAndAnnounce`). It re-registers into the metadata service without `registerApp`, so a dev-loop edit giving a package a held name is served until restart. The restart's boot refuses it. - **`install-local`'s cloud-sourced install.** Its existing code tolerates a register failure: it warns, persists the ledger entry, and answers success. The next boot's rehydrate logs the refusal at `error` and skips the package. That is code reading only (it needs a control plane). ## In-repo collision census (M2) **Instrument.** A tsx census over `examples/app-crm`, `examples/app-showcase`, `examples/app-todo` and `examples/app-multi-package`: each config's top level, its `packages[]` bodies and its nested `plugins[]`. Against those it reads the built-ins: `BUILTIN_IDENTITY_NAMES` + `AUDIENCE_ANCHOR_POSITIONS`, `PLATFORM_CAPABILITY_NAMES`, and `plugin-security`'s `securityDefaultPermissionSets`. **Result at 1604e09:** 50 declarations — crm 3 positions / 2 sets; showcase 10 / 9 / 2 capabilities; todo 0; multi-package 0; built-ins 6 positions / 10 capabilities / 8 sets. Names with more than one holder: **0**. Same-holder repeats: 0. **The guard, measured with the gate in place at 8ad6385:** `crm`, `showcase` and `multi-package` boot through `bootStack`, and `security-catalog-showcase.dogfood.test.ts` (3 postures) and `multi-package-artifact.dogfood.test.ts` are green. `app-todo` declares no catalog name. Deployed and marketplace packages are NOT MEASURED. ## The P1.2 pin, flipped S1's shared-name pin is the `security catalog read — a name two packages ship` describe in `packages/objectql/src/protocol-boot-hydration-scoped.test.ts`. It added no `P1.2` label, which is why a `git grep` misses it. It now pins the ruled answer at the same seams: - a second package registering the name is refused (envelope + both holders), and every reader answers the one holder; - an override the holder stored for itself answers for every caller; - a position two packages declare is refused at the package door, so one stack's declaration reaches the metadata service. `core`'s `security-catalog.test.ts` pointed at a non-existent `security-catalog-shared-name.test.ts`. It now names that describe and the new door pins. `security-catalog.ts`'s module doc said the shared-name answer was "pinned until it is ruled", and is rewritten to the ruled answer. `engine-capability-provenance.test.ts` pinned two packages' same-named capabilities coexisting, the exact behaviour the ruling removes. It flips to the refusal. ## Tests (at 8ad6385) - `@objectstack/objectql`: `registry-security-catalog-namespace.test.ts` (new, 28 cases), `protocol-boot-hydration-scoped.test.ts`, `engine-capability-provenance.test.ts`, `registry-collision-order.test.ts` and `registry-artifact-co-ownership.test.ts`: 5 files, 64 passed. Full objectql suite before the merges: 382 files, 7553 tests. The one red was the coexistence pin flipped above; it is green after the flip. - `@objectstack/runtime`: `standalone-stack-security-catalog-one-holder.test.ts` (new, 4) and `standalone-stack-security-registrar.test.ts`: 2 files, 6 passed. - `@objectstack/core` `security-catalog.test.ts`: 14 passed. `@objectstack/plugin-security` `builtin-positions.boot.test.ts` + `builtin-positions.test.ts` (S2's): 18 passed. - dogfood: `security-catalog-showcase`, `multi-package-artifact`, plus a local door probe that is not committed: 3 files, 44 passed. - Downstream sweep before the merges, against the rebuilt `objectql` dist: runtime 337 files / 5465, plugin-security 172 / 3663, rest 266 / 5120, verify 18 / 133, cloud-connection 41 / 505. All green. - `typecheck` for objectql, core and runtime (with `check:test-typecheck`): exit 0. No new test-typecheck debt. ## Ablation Both gates were ablated together through `scripts/ablation-replace.mjs`, which wraps the run and restores on exit: - the package-door call became a `globalThis` marker write; - the item-seam condition gained an always-false marker conjunct. Both mutations landed on disk: anchor 1 → 0, blob `b96099a12688` → `12c018d406ab`. `objectql` was rebuilt and `ablation-dist-preflight` found both markers in `dist/`. The DTS step failed on the now-unused private method, and the JS bundle the suites read was emitted. - **objectql pins (read from `src`):** 22 failed / 21 passed of 43. Every refusal pin went red, including both flipped P1.2 cases and the flipped capability pin. The controls stayed green: same-package reload, uninstall releases the name, the environment-registration carve-out, non-catalog coexistence, the grant-block reader, and the platform's own built-in registration. - **runtime boot pins (read from `dist`):** 3 failed / 1 passed. The control stayed green. **Restore:** the blob is back to `b96099a12688` and `git diff HEAD` is empty. After a rebuild, `ablation-dist-preflight --absent` is green for both markers (dist and whole tree). ## Gates `node scripts/pm/dispatch-gates.mjs --commands` derived 78 commands on 8ad6385; all 78 were run, and `--ran` reconciles 78/78 with exit codes recorded. All 78 exited 0. On the pre-merge tree 053cc2e two needed a prerequisite first: `check-engine-split-ratio` refused the shallow clone (deepened with `git fetch --shallow-since=2026-07-03`), and `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET until eight unrelated packages were built. On 8ad6385 both ran green with the rest. CI's own lanes (Test Core shards, Temporal Conformance, the Dogfood shards, Build Core, the workspace type-check) are declared to CI and are NOT MEASURED here. After the run, `origin/main` moved 6 commits, none of which touches a file in this PR. ## Acceptance notes - **Environment save of a position over a package-held position name.** Before objectstack-ai#22262 it was accepted (`200`), and the saved position then won the by-name read; permission sets were protected on the same door by the packaged permission-set lock (`403`). Since objectstack-ai#22262 landed on `main`, a package's positions are registered items under the package, and the same save answers `403 NOT_OVERRIDABLE` ("'position' is not allowOrgOverride in the registry"), measured on f16fcd0 with `PUT /api/v1/meta/position/shared_pos?package=w`. Outside this ruling either way; this PR changes nothing there. - **Cold boot vs the environment-catalog holder.** A package registers through `ObjectQL.registerApp` in Phase 1, and `sys_metadata` hydrates in Phase 2 (`ObjectQLPlugin.start`). A package added to a deployment whose environment catalog already holds one of its names is therefore NOT refused at cold boot: the env row hydrates over it, with the registry's existing collision warning. It is refused on a hot install. From the registry's seat, that arrival is indistinguishable from an environment save over a package-held name, which the ruling leaves out. The `CONTROL` case in `registry-security-catalog-namespace.test.ts` pins that the bare slot is not judged. A plugin's own `start()` is different: every plugin that depends on the engine starts after that hydration, so a package-bound registration it makes at the item seam DOES meet the environment holder, and is refused (holder `environment`). The exception is a built-in name, which the platform declares there itself (Patch round 2). A `git grep` for literal catalog-type `registerItem` calls in production source finds one such registration: `plugin-security`'s built-in positions. Carrier: objectstack-ai#22307 (ruled A: the cold boot refuses too; it lands separately). - **Order and the platform's permission sets.** `plugin-security` declares the platform's sets on its own manifest (configurable through `defaultPermissionSets`), so they are package-held. When an app registers before it, as `bootStack` composes, the platform's registration is the one refused, naming the app. The boot fails either way, and both holders are named. - **`install-local` inline import** answers its own `PLUGIN_REGISTER_FAILED` for any register refusal (this one and the namespace gate's alike), so `error.code` does not carry `NAMESPACE_CONFLICT` there. The refusal's text is in `error.message`. Not changed here. - **The ledger row comment for `NAMESPACE_CONFLICT`** in `packages/spec/src/api/error-code-ledger.zod.ts` describes the manifest-namespace condition only. The spelling, owner key and face are unchanged, and the provenance gate is green. A one-line comment noting the second condition is a spec-lane follow-up, not made here. - **Files outside the engine lane:** `packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts` (new test; `runtime` is `domain:cli`'s package); `scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json` (new ADR anchor); and, from patch round 1, five `domain:cli` dogfood files: `packages/qa/dogfood/test/showcase-security.ts`, `showcase-d7-default-profile.dogfood.test.ts`, `authored-row-write-scope.dogfood.test.ts`, `bulk-widener-probe.dogfood.test.ts` and `owd-public-read-write-write-floor.dogfood.test.ts` (each: the `SecurityPlugin` construction, with its comment and imports). ## Patch round 1 — the dogfood fixtures declared one permission set twice The Dogfood Regression Gate (all 3 shards) was red on 8ad6385. In every failing boot, `plugin-security` registered a permission set that the app package already held. The fixtures handed an app-declared set to `SecurityPlugin`'s `defaultPermissionSets`, which plugin-security declares on its own manifest, while the app declared the same set too: - `showcase_member_default`, through `showcaseAppDefaultSecurity()` and the D7 test; - `wscope_*`, `probe_widener` and `owdw_*`, in three fixtures. Measured: - `os serve` / `objectstack dev` never composes this. It hands the plugin only the default's NAME (`appSecurityPluginOptions`), and the app registers the set. A real `objectstack dev --fresh` boot of `examples/app-showcase` came up with the gate in place: health 200. - The two copies were the same definition: 101 of 101 leaves equal. Fixed at the producer: each fixture declares the set once, as the app's, and wires the default by name, as the CLI does. A runtime pin holds the refused composition. All three dogfood shards are green locally on 089b1c8 (74 + 74 + 74 files) and in CI. ## Patch round 2 — the platform's built-in positions met an environment row at boot The merge queue removed this PR (record 6056019838). `plugin-security`'s `registerBuiltinPositions` was refused at the item seam: position `org_admin`, incoming `com.objectstack.plugin-security`, holder `environment`. That refusal failed `SecurityPlugin.start`, and with it the boot. S2b's pins went red: `builtin-positions.boot.test.ts`, "a stored definition under a built-in name" (3 postures), and `bootstrap-declared-positions.test.ts`, "a stored definition shadowing a built-in name is neither seeded nor restamped". Measured on 19c86b7 (this branch with `main` merged, before the fix): - **Boot order.** `SecurityPlugin` depends on the engine. So `ObjectQLPlugin.start` hydrates `sys_metadata` into the bare slot BEFORE `SecurityPlugin.start` declares the built-in positions. Through a real door: with `OS_METADATA_WRITABLE=position`, `PUT /api/v1/meta/position/org_admin` answered `200`, and the cold restart failed ("Plugin com.objectstack.security failed to start", with this refusal). Without that setting the save answers `403 NOT_OVERRIDABLE`. - **Who registers.** `registerBuiltinPositions` registers exactly the six static built-in names (`BUILTIN_IDENTITY_NAMES` + `AUDIENCE_ANCHOR_POSITIONS`), under the platform's own package id. That is the built-in holder declaring its own names, not a second holder. - **What S2b needs.** The stored definition keeps answering first from the bare slot (ADR-0005), and the platform's declaration sits beside it. Fixed at the producer, the item seam in `SchemaRegistry.registerItem`: for a built-in name, it no longer asks the environment holder. An environment item under a built-in name exists only because an environment save went over the platform's name, which is outside the ruling. Unchanged: - a second PACKAGE registering a built-in name at the item seam is refused, in either order; - the package door refuses a package declaring a built-in name (holder `built-in`); - for any other name, an environment item still refuses a package-bound registration at the item seam (holder `environment`). No same-definition exception, no `collisionPolicy` change, and S2b's pins are untouched. `registry-security-catalog-namespace.test.ts` gained three cases, one per behaviour above (the third is a `CONTROL`). **Reverse verification.** The new condition was mutated through `scripts/ablation-replace.mjs` to ask the environment holder again (blob `c60d9bad21bc` → `eeca074e4f80`). `objectql` was rebuilt, and `ablation-dist-preflight` found the marker in `dist/`. The queue's signature came back: S2b's 3 boot postures and the bootstrap-declared-positions case went red with `SecurityCatalogNameConflictError` (`org_admin` held by the environment catalog), and so did the new admit case. Restored: blob == HEAD, and `git diff HEAD` is empty. After a rebuild, `ablation-dist-preflight --absent` is green. All suites, the three dogfood shards and the 82 derived gates were green at ffa6d51, and so was CI. ## Patch round 3 — objectstack-ai#22262 landed on `main` first objectstack-ai#22262 (squash 0b997ea) adds `positions` to the engine's `METADATA_ARRAY_KEYS`, so `ObjectQL.registerApp` now registers a package's positions under the package. This branch merged `main` at fbcbcf1. The merge touched none of this PR's files, and `registry.ts`'s logic is unchanged. **Comments only.** Four comments this PR added said a package's positions never reach the engine registry's item store. Each now reads true on `main`: the `securityCatalogClaims` doc and the `installPackage` comment in `registry.ts`, the declared-names reader's note in `security-catalog-namespace.ts`, and the header of `standalone-stack-security-catalog-one-holder.test.ts`. No behaviour changed, so no reverse leg was re-run. **Measured with objectstack-ai#22262 in the tree** (f16fcd0, this branch with `main` merged; through `bootStack`; local probes, not committed): - A package's own positions arrive both as claims and as registry items under the same package, and stay one holder. The showcase boots with 10 positions under `com.example.showcase` and 6 under `com.objectstack.plugin-security`, and 10 claims. Re-registering the showcase is not refused. A second package declaring `contributor` is refused, holder `com.example.showcase`. - The built-in case is unchanged. With environment saves under `org_admin` and `everyone` (`OS_METADATA_WRITABLE=position`), the cold restart boots, and both names resolve to the environment's saved definitions. - `PUT /api/v1/meta/position/shared_pos?package=w` over a package-held position answers `403 NOT_OVERRIDABLE`. - The door probes behind the table above answer as before: crm, showcase and multi-package boot; the two-stack boot, the built-in names, the hot install and `install-local` are refused, each naming both holders; the same-package reload is accepted. **The claims, ablated.** This was measured on a throwaway local merge of objectstack-ai#22262's head 7ed88a6, never pushed. All seven files objectstack-ai#22262 landed are byte-identical to that head's. The claim recording was replaced by a no-op (`scripts/ablation-replace.mjs`), `objectql` was rebuilt, and the marker was proven in `dist/`. The runtime boot pins stayed green (5 of 5): every `registerApp` door refuses through the registered items alone. Two objectql pins went red: the P1.2 position case and the `collisionPolicy: 'warn'` pin. Both reach the package door through a direct `installPackage` call, which registers no items. So the claims stay. Restored: blob == HEAD; after a rebuild, `ablation-dist-preflight --absent` is green. **Tests at f16fcd0**, all under `os-verify-lock`: - `@objectstack/objectql`, whole suite: 383 files / 7572 passed. - `@objectstack/plugin-security`, whole suite: 179 files / 3775 passed, 45 skipped. - `@objectstack/runtime`, whole suite: 340 files / 5505 passed, 19 skipped. - Dogfood, the CI split: shard 1/3, 74 files / 557 passed; 2/3, 74 files / 535 passed, 1 skipped; 3/3, 73 passed + 1 skipped files / 661 passed, 8 skipped. - `typecheck` for `objectql` and `runtime` (`tsc --noEmit` + `check:test-typecheck`): exit 0. - Gates: `dispatch-gates --commands` derived 82 on f16fcd0. All 82 ran and exited 0, and `--ran` reconciles 82/82, 0 NOT MEASURED. CI on f16fcd0: 32 checks success, including Dogfood Regression Gate 1/3 to 3/3 and Test Core 1/6 to 6/6. Three were skipped (Build Docs, Console Pin Gate, Packed-tarball smoke). ## Patch round 4 — the changeset level, one comment, the cold-boot carrier The contract review on f16fcd0 (record 6061710772) failed two texts and one missing carrier. The code stands; this round changes text only. - **The changeset level.** `.changeset/22135-security-catalog-one-holder.md` graded `@objectstack/objectql` `minor`, on the premise that Changesets pre mode was not yet on `main`. It is: `.changeset/pre.json` (mode `pre`, tag `next`) landed with objectstack-ai#22084 (a87d8be), an ancestor of this branch's merge base. The changeset now grades `major`, and its BREAKING sentence says the change ships as `major` on the v18 pre-release line. The ADR-0087 marker and `Clause-②: no` stay. `pnpm changeset status` resolves `@objectstack/objectql` to `18.0.0-next.0`. - **The cold-boot carrier.** One sentence in the changeset states the boundary: at cold boot, packages register before the environment catalog loads from `sys_metadata`, so a package newly added over a permission-set or position name the environment catalog already holds is not refused at cold boot, and the registry's existing collision warning fires. A hot install of the same package is refused. objectstack-ai#22307 has since been ruled A (the cold boot refuses too); see Patch round 5. Measured on 9e4ed5d with a local probe (not committed): the cold boot with the package added came up with no refusal and two `[Registry] Collision` warnings, one for the permission set and one for the position. The hot install answered `422 NAMESPACE_CONFLICT`, both names held by `environment`, and left no package record. A capability cannot be saved in the environment (`403`, a code-only type), so the sentence names the two types an environment can hold. - **One comment.** The runtime pin's comment called the position one "no registry slot holds". That stopped being true when objectstack-ai#22262 put a package's positions into the registry under the package. The comment now says the refusal reports the position, the permission set and the capability the first package holds. A sweep of this PR's added lines finds no other sentence saying positions do not reach the registry. - **The `start()`-time half** of the round-2 question is settled as A (keep): the ruling names the environment catalog as a holder and does not distinguish phase. No change. **Checks at 9e4ed5d:** - The changeset gates: `check-changeset-no-major` `--self-test` and `--base`, exit 0 (pre mode, tag `next`, so the no-major guard stands aside; given this PR's body, the level axis reads `Clause-②: no`); `check-empty-changeset` `--self-test` and `--base`, exit 0; `check-changeset-fixed`, exit 0; `check:adr-0087-registration`, exit 0 (1 declared-breaking changeset, carrying its ADR-0087 disposition); `check:changeset-gate-self-tests`, exit 0. - `pnpm --filter @objectstack/runtime exec vitest run src/standalone-stack-security-catalog-one-holder.test.ts`: 1 file / 5 passed. `pnpm --filter @objectstack/runtime typecheck`: exit 0. - Gates: `dispatch-gates --commands` for the two touched paths derived 61 commands. All 61 ran and exited 0, and `--ran` reconciles 61/61, 0 NOT MEASURED. `git merge-tree` against `origin/main` 4e4111c is clean, so `main` was not merged. ## Patch round 5 — objectstack-ai#22307 was ruled The maintainer ruled objectstack-ai#22307 A while round 4 ran: a cold boot is to refuse too. That work lands in its own PR, not in this one. The changeset's cold-boot sentence keeps its measured clauses and now ends "a cold-boot refusal is ruled and tracked on objectstack-ai#22307, which lands separately", which is true on this PR's merge and stays true after objectstack-ai#22307 lands. Nothing else changed. **Checks at 99fba80:** `check-changeset-no-major --base`, exit 0 (pre mode, tag `next`; given this PR's body, the level axis reads `Clause-②: no`); `check-empty-changeset --base`, exit 0; `check:adr-0087-registration`, exit 0. `dispatch-gates --commands` for the one touched path derived 20 commands; all 20 exited 0, and `--ran` reconciles 20/20, 0 NOT MEASURED. `git merge-tree` against `origin/main` 4e4111c is clean, so `main` was not merged. --- _Generated by [Claude Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #22080
Clause-②: no
Opens the v18 line on
main, as ruled B on #22050 (record6037890422). Changesets enters pre mode with the tagnext, and onemajorchangeset takes thefixedgroup to 18, so the first version cut is18.0.0-next.0. File surface:.changeset/plus one line inscripts/check-adr-0087-registration.mjs(the seat's surface amendment6040954045on #22080; see the patch round below). ⛔ No edit to.changeset/config.json, to either changeset guard's logic, or to.github/workflows/release.yml. ⛔ Nochangeset versionoutput is committed, and nothing was published.The change
.changeset/pre.json, written bypnpm changeset pre enter next(@changesets/cli3.0.3) and committed as the tool wrote it (commitd3d35a1029):{ "mode": "pre", "tag": "next" }Under v3 the file holds
modeandtagonly. There is noinitialVersionsand nochangesetslist..changeset/22080-v18-line-opens.md, front matter"@objectstack/spec": major(commit52f7a509c6). Its body is the v18 line's opening note (ADR-0131; decision: open v18 now and ship it in stages — release the last 17.x from main first without waiting for #21908's deny (A), skip the last 17.x (B), or keep #22009's order (C)? #22050). It carriesClause-②: noand its ADR-0087 disposition (see Gates).3d9188502e, prints17 : minorand32 : patch, and nomajor. Nomajorwas pending, so without this marker pre mode would version17.8.0-next.0.@objectstack/spec. Anyfixed-group member moves all 69 to the same version, so the choice decides only where the note lands and what the major means.@objectstack/specis the protocol package.scripts/sync-protocol-version.mjs, which the rootversionscript runs, derivesPROTOCOL_VERSIONfrom this package's major, and ADR-0131 C8 calls the line "protocol 18". So the major sits on the package whose major is the protocol version, and the note lands where an upgrading reader looks first:packages/spec/CHANGELOG.md, under### Major Changes.The no-major guard reads
pre.jsonfrom the checkout it runs in (the head), so steps 1 and 2 ship togetherscripts/check-changeset-no-major.mjs:396setsREPO_ROOTto the checkout the script runs in.readPre(root)(:2018) readsjoin(root, '.changeset', 'pre.json')from that working tree,:2098hands the result tojudge, andjudgereturnsexemptwhenpre?.mode === 'pre'(:662).--basesets only where the diff starts. It is never wherepre.jsonis read.Check Changesetjob in.github/workflows/pr-automation.ymlchecks out the PR merge ref (the default for apull_requestevent) and runsnode scripts/check-changeset-no-major.mjs --base "$MERGE_BASE"(:1130). The merge ref carries this PR'spre.json.52f7a509c6:✓ Changesets is in pre-release mode (tag: next) — ... skipping the no-major guard., plus a notice naming@objectstack/spec..changeset/pre.jsondeleted from the working tree only, exit 1,⛔ This PR introduces changeset(s) that declare a major bump.The base3d9188502ehas nopre.jsonat all, so a guard that read the base could never have answeredexempt. Restored withgit checkout HEAD -- .changeset/pre.json:git diff HEADis empty, and the guard exits 0 again.Pins
changeset versionputs thefixedgroup at18.0.0-next.052f7a509c6:pnpm changeset versionexits 0. All 69fixed-group members read18.0.0-next.0, andpre.jsonis unchanged. The 37 consumed changesets (36 pending plus this one) moved to.changeset/pre/.packages/spec/CHANGELOG.mdopens## 18.0.0-next.0/### Major Changeswith this note. The worktree was then removed.check-changeset-no-majorandcheck-changeset-fixedpass on the PRnode scripts/check-changeset-no-major.mjs --base origin/mainexits 0 on the pre-release exemption above. Driven with apull_requestpayload carrying this body (--event), it also exits 0 with the level axis read.node scripts/check-changeset-fixed.mjsexits 0:✓ .changeset/config.json "fixed" group is in sync with 69 public workspace packages..changeset/pre.jsonis present at the merge, with"mode": "pre"and"tag": "next"d3d35a1029with the content above. No later commit touches it. It is in the merge ref CI checks out, which is the file the guard's exemption reads.release.yml: in pre mode,changeset publishpublishes undernext, andlateststays 17.7.0This is a reading of the file. Nothing was edited, and no publish was run.
.github/workflows/release.yml:1762is jobpublish, stepPublish to npm + push version tags. At:1772it runspnpm run release.package.jsonreleasescript ispnpm run build && bash scripts/build-console.sh && bash scripts/release-publish.sh.scripts/release-publish.sh:64runschangeset publishwith no--tag.@changesets/cli3.0.3,dist/getPublishPlan.mjs:599-603getReleaseTagworks like this: with no--tagand a pre state present, the tag ispreState.tag, which isnext. The one exception is a package whose every published version is already anextprerelease. Separately,dist/publish.mjs:61-63refuses a custom--tagin pre mode.npm viewread today: all 69fixed-group packages havelatest=17.7.0and nonexttag. So each publishes tonext, andlateststays17.7.0.Gates (run on
52f7a509c6, exit codes captured before any pipe)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 20 commands for this diff. All 20 exit 0, and--ranreconciles:20 derived, 20 run, 0 NOT-MEASURED, 0 UNRUN.Patch round, head
a630de657d(seat-appended from addendum6041313497). At52f7a509c6the requiredLint & Repo Gateswas red atPM dispatch-gates self-test, casethe extension narrowing costs no lead … (refused: .changeset/pre): withpre.jsontracked, the battery's loose and strict hint rules disagreed on the literal.changeset/pre/atscripts/check-adr-0087-registration.mjs:536. That line now names the existing exclusion asCONSUMED_PRERELEASE_EXCLUDED(the predicate's match is unchanged), which the extractor reads as an excluded surface. Ata630de657d: 41 derived commands, 41 run, all exit 0;pnpm check:pm-dispatch-gates→1976 cases pass(965.7 s), the case reads(refused: none);check-adr-0087-registration --self-test441 assertions and--base origin/mainexit 0.check-adr-0087-registration --base origin/mainexits 0. The gate requires a disposition for amajor: a probe commit without one was refused, exit 1, withdeclares a breaking change (major) but no adr-0087: disposition marker. The probe was never pushed. The changeset now carries thenot-required (no-migration-prescription)marker in the ADR-0087 HTML-comment form, because the marker moves no authorable key, export, type or stored shape.check-changeset-no-major --base origin/mainandcheck-empty-changeset --base origin/mainexit 0.check:changeset-gate-self-tests, exit 0.check-closing-keyword-parityand its self-test,check-comment-mask-corpus,release-rehearsal-clone --self-test,release-pending-publish --self-test,check:driver-memory-census,check:gitlink-declared,check:nul-bytes,check:objectui-changeset,check:pm-changeset-deadline-census,check:published-files,check:refd-timer-probeandcheck:watch-hint-literal.check-changeset-fixed(an artifact roster under.changeset/),check:future-spec-major,check:lockstep-package-countandcheck:docs-image-tag.Read before merging: after this lands, the version-PR refresh goes red until its major-boundary gates are wired
The card expects changesets/action to refresh #21988 into the
18.0.0-next.0version PR once this lands. On the files as they stand, it will not.version-prjob inrelease.yml(cron0 */6 * * *) runspnpm run versionin the stepRender the post-version tree(:450). The stepValidate the post-version tree(:518) then exits 1 at:610-613when the pass wrote any major-boundary path: "this version pass crossed a MAJOR boundary ... Wire them in before letting this refresh through." The stepCreate or update the "chore: version packages" PRhas noif:, so it does not run after that failure.changeset version, the other three rewriters of the rootversionscript exit 0. Replaying the step's shape assertion gives 0 unexpected paths and 4 major-only paths:packages/spec/src/kernel/protocol-version.ts(PROTOCOL_VERSION 17.0.0to18.0.0) and the blank template'sobjectstack.config.ts,objectstack.manifest.jsonandpackage.json.cut-rc.yml, refuses a pre tag other thanrc(:232-237) and a version not shapedX.Y.Z-rc.N(:156-159). The ruled tag isnext.18.0.0-next.0without a workflow change. That change is outside this card, which forbids anyrelease.ymledit. The red is the lane's own designed refusal. It publishes nothing and queues nothing. chore: version packages #21988 stays at its current 17.8.0 content and, as the card says, ⛔ is not merged.Acceptance notes
pnpm run versionshows two more things that the lane wiring has to account for.scripts/sync-release-index-currency.mjsre-dates the v17 entry incontent/docs/releases/index.mdxfrom17.7.0, released 2026-10-06toreleased 2026-10-07. That is the prerelease cut's date. 17.7.0's version commit4e4e881427is dated 2026-10-06. Reported to the seat as a finding.scripts/sync-docs-image-tags.mjsmoves the 9 pins indocker/README.md,content/docs/deployment/self-hosting.mdxandcontent/docs/upgrading.mdxfrom17.7.0to18.0.0-next.0. The self-hosting docs onmainwould then name a prerelease whilelatestis 17.7.0. Noted only.lateststays on 17.x until GA. The pre-mode publish path above is what makes that true.维护者速读(草稿)
改了什么:在
main上进入 Changesets 预发布模式(标签next),并加一条把@objectstack/spec标为major的 changeset。合并后,下一次切版本得到的是18.0.0-next.0,而不是17.8.0。改动是.changeset/下的两个文件,加上门禁脚本里的一行重命名:scripts/check-adr-0087-registration.mjs把已有的排除路径.changeset/pre/写成_EXCLUDED命名常量(匹配逻辑不变),这样pre.json存在后 PM 门禁自检能正确识别它。没有代码或工作流改动。为什么改:按 #22050 的裁决 B,v18 直接在
main上开发。v18 的破坏性改动要以18.0.0-next.N预发布版发到 npm 的next标签上;latest保持 17.7.0,普通安装的用户不受影响,直到 18.0 正式版。风险与代价(含回滚):合并后,版本 PR 的自动刷新(
release.yml的version-pr,每 6 小时一次)会在 "Validate the post-version tree" 一步变红。原因是这一版跨了大版本,会改写PROTOCOL_VERSION和模板文件,而这条通道对这些文件还没有门禁,按设计会拒绝。它不发布,也不排队任何东西,但在另开卡把这些门禁接上之前,#21988 不会变成18.0.0-next.0。cut-rc.yml只接受rc标签,也走不通。#21988 无论如何都不要合并。回滚办法:删除.changeset/pre.json和.changeset/22080-v18-line-opens.md两个文件(在切出任何预发布版之前,这一步没有副作用)。席位意见:
你要做的:确认后合并本 PR。合并后需要另开一张卡,让版本通道能切出
18.0.0-next.0;这张卡由席位提出。Generated by Claude Code