Skip to content

chore(release): enter Changesets pre mode (next) with one major marker, so v18 opens at 18.0.0-next.0 - #22084

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-22080-v18-pre-mode
Oct 8, 2026
Merged

os-zhuang merged 3 commits into
mainfrom
claude/issue-22080-v18-pre-mode

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #22080

Clause-②: no

Opens the v18 line on main, as ruled B on #22050 (record 6037890422). Changesets enters pre mode with the tag next, and one major changeset takes the fixed group to 18, so the first version cut is 18.0.0-next.0. File surface: .changeset/ plus one line in scripts/check-adr-0087-registration.mjs (the seat's surface amendment 6040954045 on #22080; see the patch round below). ⛔ No edit to .changeset/config.json, to either changeset guard's logic, or to .github/workflows/release.yml. ⛔ No changeset version output is committed, and nothing was published.

The change

  1. .changeset/pre.json, written by pnpm changeset pre enter next (@changesets/cli 3.0.3) and committed as the tool wrote it (commit d3d35a1029):

    {
      "mode": "pre",
      "tag": "next"
    }

    Under v3 the file holds mode and tag only. There is no initialVersions and no changesets list.

  2. .changeset/22080-v18-line-opens.md, front matter "@objectstack/spec": major (commit 52f7a509c6). 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 carries Clause-②: no and its ADR-0087 disposition (see Gates).

    • Step 2 was not skipped. The card's count command, re-run on the cut 3d9188502e, prints 17 : minor and 32 : patch, and no major. No major was pending, so without this marker pre mode would version 17.8.0-next.0.
    • Why @objectstack/spec. Any fixed-group member moves all 69 to the same version, so the choice decides only where the note lands and what the major means. @objectstack/spec is the protocol package. scripts/sync-protocol-version.mjs, which the root version script runs, derives PROTOCOL_VERSION from 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.json from the checkout it runs in (the head), so steps 1 and 2 ship together

  • scripts/check-changeset-no-major.mjs:396 sets REPO_ROOT to the checkout the script runs in. readPre(root) (:2018) reads join(root, '.changeset', 'pre.json') from that working tree, :2098 hands the result to judge, and judge returns exempt when pre?.mode === 'pre' (:662). --base sets only where the diff starts. It is never where pre.json is read.
  • In CI, the Check Changeset job in .github/workflows/pr-automation.yml checks out the PR merge ref (the default for a pull_request event) and runs node scripts/check-changeset-no-major.mjs --base "$MERGE_BASE" (:1130). The merge ref carries this PR's pre.json.
  • Both legs were measured at 52f7a509c6:
    • As committed: exit 0, ✓ Changesets is in pre-release mode (tag: next) — ... skipping the no-major guard., plus a notice naming @objectstack/spec.
    • Control: with .changeset/pre.json deleted from the working tree only, exit 1, ⛔ This PR introduces changeset(s) that declare a major bump. The base 3d9188502e has no pre.json at all, so a guard that read the base could never have answered exempt. Restored with git checkout HEAD -- .changeset/pre.json: git diff HEAD is empty, and the guard exits 0 again.
  • So no split is needed, and this PR carries steps 1 and 2.

Pins

Pin Reading
In a throwaway worktree, never committed, changeset version puts the fixed group at 18.0.0-next.0 Detached throwaway worktree at 52f7a509c6: pnpm changeset version exits 0. All 69 fixed-group members read 18.0.0-next.0, and pre.json is unchanged. The 37 consumed changesets (36 pending plus this one) moved to .changeset/pre/. packages/spec/CHANGELOG.md opens ## 18.0.0-next.0 / ### Major Changes with this note. The worktree was then removed.
check-changeset-no-major and check-changeset-fixed pass on the PR node scripts/check-changeset-no-major.mjs --base origin/main exits 0 on the pre-release exemption above. Driven with a pull_request payload carrying this body (--event), it also exits 0 with the level axis read. node scripts/check-changeset-fixed.mjs exits 0: ✓ .changeset/config.json "fixed" group is in sync with 69 public workspace packages.
.changeset/pre.json is present at the merge, with "mode": "pre" and "tag": "next" Added in d3d35a1029 with 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 publish publishes under next, and latest stays 17.7.0

This is a reading of the file. Nothing was edited, and no publish was run.

  • .github/workflows/release.yml:1762 is job publish, step Publish to npm + push version tags. At :1772 it runs pnpm run release.
  • The root package.json release script is pnpm run build && bash scripts/build-console.sh && bash scripts/release-publish.sh.
  • scripts/release-publish.sh:64 runs changeset publish with no --tag.
  • In @changesets/cli 3.0.3, dist/getPublishPlan.mjs:599-603 getReleaseTag works like this: with no --tag and a pre state present, the tag is preState.tag, which is next. The one exception is a package whose every published version is already a next prerelease. Separately, dist/publish.mjs:61-63 refuses a custom --tag in pre mode.
  • npm view read today: all 69 fixed-group packages have latest = 17.7.0 and no next tag. So each publishes to next, and latest stays 17.7.0.

Gates (run on 52f7a509c6, exit codes captured before any pipe)

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 20 commands for this diff. All 20 exit 0, and --ran reconciles: 20 derived, 20 run, 0 NOT-MEASURED, 0 UNRUN.

Patch round, head a630de657d (seat-appended from addendum 6041313497). At 52f7a509c6 the required Lint & Repo Gates was red at PM dispatch-gates self-test, case the extension narrowing costs no lead … (refused: .changeset/pre): with pre.json tracked, the battery's loose and strict hint rules disagreed on the literal .changeset/pre/ at scripts/check-adr-0087-registration.mjs:536. That line now names the existing exclusion as CONSUMED_PRERELEASE_EXCLUDED (the predicate's match is unchanged), which the extractor reads as an excluded surface. At a630de657d: 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-test 441 assertions and --base origin/main exit 0.

  • Changeset gates:
    • check-adr-0087-registration --base origin/main exits 0. The gate requires a disposition for a major: a probe commit without one was refused, exit 1, with declares a breaking change (major) but no adr-0087: disposition marker. The probe was never pushed. The changeset now carries the not-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/main and check-empty-changeset --base origin/main exit 0.
    • The self-tests of all three, plus check:changeset-gate-self-tests, exit 0.
  • Remaining derived commands, all exit 0: check-closing-keyword-parity and 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-probe and check:watch-hint-literal.
  • Run beyond the derivation, all exit 0: check-changeset-fixed (an artifact roster under .changeset/), check:future-spec-major, check:lockstep-package-count and check:docs-image-tag.
  • NOT MEASURED: the four type-check lanes. The diff touches no TypeScript.

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.0 version PR once this lands. On the files as they stand, it will not.

  • The version-pr job in release.yml (cron 0 */6 * * *) runs pnpm run version in the step Render the post-version tree (:450). The step Validate the post-version tree (:518) then exits 1 at :610-613 when the pass wrote any major-boundary path: "this version pass crossed a MAJOR boundary ... Wire them in before letting this refresh through." The step Create or update the "chore: version packages" PR has no if:, so it does not run after that failure.
  • I measured this in the same throwaway. After changeset version, the other three rewriters of the root version script 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.0 to 18.0.0) and the blank template's objectstack.config.ts, objectstack.manifest.json and package.json.
  • The other lane, cut-rc.yml, refuses a pre tag other than rc (:232-237) and a version not shaped X.Y.Z-rc.N (:156-159). The ruled tag is next.
  • What this means: once this lands, no lane in this repository cuts 18.0.0-next.0 without a workflow change. That change is outside this card, which forbids any release.yml edit. 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

  • The same throwaway pnpm run version shows two more things that the lane wiring has to account for.
    • scripts/sync-release-index-currency.mjs re-dates the v17 entry in content/docs/releases/index.mdx from 17.7.0, released 2026-10-06 to released 2026-10-07. That is the prerelease cut's date. 17.7.0's version commit 4e4e881427 is dated 2026-10-06. Reported to the seat as a finding.
    • scripts/sync-docs-image-tags.mjs moves the 9 pins in docker/README.md, content/docs/deployment/self-hosting.mdx and content/docs/upgrading.mdx from 17.7.0 to 18.0.0-next.0. The self-hosting docs on main would then name a prerelease while latest is 17.7.0. Noted only.
  • The opening note says the dist-tag latest stays 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

claude added 2 commits October 7, 2026 13:44
`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>
…` 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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读 — PR #22084(#22080,v18 开版)

席位 domain:devx#2 · session_01VF48aw8RPG6wzDnMgp6rtw · 2026-10-07T15:46Z。终稿,对照席位自己读的 diff 校正自 PR 正文草稿。复核记录在 #22080 的 ACCEPT 评论。

改了什么

  • .changeset/ 下两个文件(+18 行):
    • pre.json 让 main 进入 Changesets 预发布模式(标签 next);
    • 一条 changeset 把 @objectstack/spec 标为 major。
  • 门禁脚本里一行重命名(+2 −1):scripts/check-adr-0087-registration.mjs 把已有的排除路径 .changeset/pre/ 写成 _EXCLUDED 常量,匹配逻辑不变。不改的话,pre.json 一进来,必过的 Lint 就会在 PM 门禁自检上变红。
  • 合并后,下一次切版本是 18.0.0-next.0,不是 17.8.0。没有产品代码或工作流改动。

为什么改

风险与代价(含回滚)

席位意见

你要做的

确认后合并本 PR(席位不翻 ready 以外的任何开关,不入队)。

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 7, 2026 15:48
This was referenced Oct 7, 2026
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit a87d8be Oct 8, 2026
44 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-22080-v18-pre-mode branch October 8, 2026 07:54
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation needs-user-decision size/s tooling

Projects

None yet

3 participants