Repository navigation
fix(cli): the strict config refusal now carries the rule it enforces — a named export of objectstack.config.ts IS a top-level stack key - #18416
Conversation
`objectstack.config.ts` is loaded as a MODULE: `loadConfig()` takes the default export as the base and merges every NAMED export onto it as a top-level stack key, under the export's own name. That merge is deliberate (`onEnable` and `functions` are declared stack keys authored as named exports), but its consequence was written down nowhere: a named export is legal only when its name is a key `ObjectStackDefinitionSchema` declares, so a helper exported beside the stack reaches the strict parse as a top-level stack key of that name and is refused as unrecognised. The refusal is unchanged — same key, same `unrecognized_keys`, same failing parse, same exit code, same `--json` payload. `os build` and `os validate` now print, on the text face only, the rule behind it and the fix. The rule is also stated on the config-authoring docs page, in the CLI configuration reference, and in the comment every `os init` template scaffolds. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
…nfig-module-single-export
…iteral The `os init` templates render their `objectstack.config.ts` from a template literal, so the backticks around `onEnable` / `functions` in the new comment terminated the literal and left `init.ts` unparseable — 71 parse diagnostics, and two gates that read source through a parser refused to score the file at all (`check-reference-carrier-shape` exit 3, `check:comment-mask-corpus` UNPARSEABLE). The comment says the same thing without the quoting. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 58f6c222f62f21cb6aed5aed19372a0e808d5c54 && git checkout 58f6c222f62f21cb6aed5aed19372a0e808d5c54
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 588475c30f98ffb5ee603382d585e41cd9a59753 f6561b2b0e183878301311993e08de400b05a656 && git checkout -B drift-repro 588475c30f98ffb5ee603382d585e41cd9a59753 && git merge --no-ff f6561b2b0e183878301311993e08de400b05a656
node scripts/docs-audit/affected-docs.mjs --json 588475c30f98ffb5ee603382d585e41cd9a59753
|
…hat it attempted (objectstack-ai#18445) Fixes objectstack-ai#18432 Clause-②: no — this PR puts no new key on a published payload. It changes only a progress line's text and the doc transcript that quotes it; no input the build accepts or refuses changes. Declared by the dispatching seat in claim `5696261079`. ## What changed `os build` / `os compile` printed its package-docs step line **before** the call it announces, and the line carried no count: ``` if (!flags.json) printStep('Collecting package docs (ADR-0046)...'); const docsResult = collectAndLintDocs(...) ``` So a build that collected nothing emitted the same reassuring sentence as one that collected four documents. The print now happens after the collection and reports it: ``` → Collecting package docs (ADR-0046)... 0 collected → Collecting package docs (ADR-0046)... 4 collected ``` This is the reassurance half of objectstack-ai#18170. objectstack-ai#18428 landed the audible half, where an uncollected docs directory speaks for itself; this card was fenced out of that PR because the transcript quoting the step line lived on a page PR objectstack-ai#18416 had staked. That page merged at 2026-09-16T10:48Z, so the fence is lifted. ## The doc half is a measurement, not a copy-edit `content/docs/deployment/cli.mdx` carries the step line inside a fence tagged `transcript=os-build`. The number written there is the sample fixture's real count, **measured, not chosen**: - **N = 0**, read off a real `os build` run. - **Where measured**: the fixture the page's own callout names — the `blank` starter from `packages/create-objectstack/src/templates/blank` (the two-field `my_app_note` object and the three connector plugins) plus the four-field ticket object, the view and the action from `content/docs/getting-started/build-with-claude-code.mdx` §3, renamed out of that page's `support_desk_` namespace into `my_app_`, with the `support` app deliberately not added. Built out-of-tree in the session scratchpad against this branch's `packages/`, run through `packages/cli/bin/run-dev.js`. - **The reconstruction is corroborated by the page's other numbers**, which reproduce exactly on the same run and were not touched: `Running author-time rules (45)`, `Data: 2 Objects 6 Fields`, `UI: 1 Views 1 Actions`, `Runtime: 3 plugins`. - **Why 0**: neither the `blank` template nor the walkthrough ships a `src/docs/` directory, and the stack declares no inline `docs:`. The measured run is the transcript's own case of the sentence the card drafted. Measured run, verbatim: ``` → Checking capability providers (objectstack-ai#3366)... → Collecting package docs (ADR-0046)... 0 collected → Writing artifact... ✓ Build complete (118ms) ``` ## The transcript-drift gate does NOT hold this number — and that is correct Dispatch Zone 2 assumed `check:docs-transcript-drift` would hold the new value. **Measured and falsified**, so it is reported rather than relied on: that gate's `TOKENS` array in `scripts/docs-audit/check-docs-transcript-drift.mjs` carries exactly one row, `author-time-rule-count`, matching `author-time rules \((\d+)\)`. It compares the `45` in this block and nothing else. That is not a hole this PR opens. The gate is scoped by design to values a **live registry** derives; `0 collected` is *fixture identity*, in the same class as `2 Objects 6 Fields` and `1 Views 1 Actions`, which the page's existing callout already covers ("everything else is fixture identity and reproduces"). A `TOKENS` row here would have to build a sample project rather than import a registry, which is a different gate. Named so the next author does not read the drift gate's green as vouching for this digit. The number that gate *does* hold is unchanged by this PR, and `check:docs-transcript-drift` was run. ## Tests `packages/cli/test/build-docs-step-count.e2e.test.ts` pins the behaviour **as a pair**, because a test that only asserted "the step line was printed" passes on the defective tree — the defective tree printed it unconditionally: | fixture | step line | |---|---| | no `src/docs/` at all | `0 collected` | | `src/docs/` present and empty | `0 collected` | | two docs | `2 collected` | Each run's printed count is also compared against the emitted `dist/objectstack.json`, so a number that drifted away from the set it describes cannot pass as text. The pre-fix spelling — the sentence with nothing after the ellipsis — is pinned **absent**. `--json` is asserted to stay one JSON document carrying no step text. The suite spawns the CLI, so it reuses `childEnv()` and the `bin/run-dev.js` + tsx shape of `build-json-advisory-parity.e2e.test.ts` rather than inventing one.⚠️ **Where this pin runs, stated so nobody reads "tests added" as "gated on every PR".** The `.e2e.test.ts` name puts it in this package's **nightly tier**: `packages/cli/vitest.config.ts` routes the population through `OS_TEST_TIERS`, so the queue and per-PR runs collect the 212 non-tier files and this file is not among them; the nightly on `main` collects the tier files, where it lands in `integration` by `vitest-tiers.ts`'s SPAWN predicate. That is the package's own measured cost design — every one of its 60 spawners lives there — so a new spawner follows it rather than having me make a unilateral call about the Test Core critical path. Locally it was run under that switch, and `pnpm check:tier-file-adoption` is green. ## Declared deviation from the claim's file surface Claim `5696261079` declares a two-file surface. This PR carries four files, and the two extra ones are declared rather than quiet: - `packages/cli/test/build-docs-step-count.e2e.test.ts` — the pin the dispatching seat itself asked for in its suggested route ("the assertion that would have caught the original defect"), and what AGENTS.md's Post-Task Checklist step 1 owes. - `.changeset/18432-docs-step-line-reports-count.md` — `@objectstack/cli` is a published package and this moves user-visible build output, so a `patch` changeset is owed and `skip-changeset` would be wrong. No production file outside the two the claim names is touched. `content/docs/releases/` and `packages/spec/` are untouched. ## Verification Gate derivation is from the **actual** changed files, recomputed by the tool from the merge base rather than a hand-written list, and reconciled back with `--ran` carrying each command's own exit code (captured before any pipe): ``` node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack -> 93 command(s), change set: the 4 files above, merge base 55fd5ee node scripts/pm/dispatch-gates.mjs --ran RAN_RECORD_FILE --repo objectstack-ai/objectstack -> 93 derived famil(ies) accounted for — 93 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero — all 93 recorded an exit code and none of them is 3) ``` **92 of 93 green.** The one non-zero is the known pre-existing red: | gate | exit | reading | |---|---|---| | `pnpm check:cross-package-test-inputs` | 1 | **Pre-existing, not this PR.** Its only finding is rooted in `packages/cli/test/init-created-files-summary.e2e.test.ts` descending `packages/spec/dist/` — a file this diff never touches, and the new test file appears nowhere in the report. Filed as objectstack-ai#18353 / objectstack-ai#18348. | Five gates first answered `PREREQUISITE NOT MET` against a partly-built tree (`check:skill-examples` exit 1 on a missing `packages/client-react/dist`, `check:dual-build-cjs-loads` / `check:i18n` / `check:i18n-coverage` / `check:i18n-walk-parity` exit 3). Those are *nothing was measured*, not passes and not findings, so they were re-run after `pnpm build` and all five are **exit 0**. `pnpm check:docs-transcript-drift`, `pnpm check:doc-anchors`, `pnpm check:doc-authoring`, `pnpm check:docs-single-h1` and `pnpm check:nul-bytes` are green on the edited page. `pnpm lint` — the repo-wide `eslint . --no-inline-config`, which the derivation never names — was run **whole**, not narrowed: **exit 0**, no findings. No `.cache/objectui-*` existed in this worktree to pollute it. **Tests** ``` pnpm --filter @objectstack/cli typecheck exit 0 (check:test-typecheck: the test layer compiles under tsconfig.test.json; debt unchanged at 3 file(s) / 28 error(s) / 6 pinned signature(s)) pnpm --filter @objectstack/cli exec vitest run --project unit --shard=N/4 shard 1 53 files / 883 tests passed shard 2 53 files / 750 tests passed shard 3 52 files / 622 tests passed shard 4 52 files / 737 tests passed 210 files / 2992 tests, all green OS_TEST_TIERS=nightly pnpm --filter @objectstack/cli exec vitest run \ --project integration test/build-docs-step-count.e2e.test.ts Test Files 1 passed (1) Tests 5 passed (5) ``` **Reverse verification — the pin can fail, proved rather than asserted.** The fix was committed first, then the pre-fix ordering was restored on disk and the suite re-run: ``` HEAD blob: a1c7daf ON-DISK PROOF: fixed-spelling count 1 -> 0; pre-fix-spelling count now 1 mutated hash: a2b25a52a3bd89486f9266f0bd5e9de03de65d2c result: Tests 4 failed | 1 passed (5) restored: hash matches HEAD blob and `git diff HEAD` is empty ```⚠️ **The predicted direction, stated before the run and then observed: four red, one green.** The `--json` case is a *control*, not a regression detector — `--json` prints no step line on either tree, so a run in which it also went red would mean the ablation had hit something other than the ordering. The four behavioural pins are the ones that must move, and they did. The mutation was proved to reach disk by occurrence count on the text being replaced, not by the editor's exit code; the restore is proved by blob-hash equality plus an empty `git diff HEAD`, not by a return code, and it runs from an `EXIT INT TERM` trap with an absolute path. **Where the measurement of N happened, reproducibly** ``` $ cd FIXTURE_DIR # blank starter + ticket object/view/action, my_app_ namespace $ tsx packages/cli/bin/run-dev.js build → Running author-time rules (45)... → Checking capability providers (objectstack-ai#3366)... → Collecting package docs (ADR-0046)... 0 collected → Writing artifact... ✓ Build complete (118ms) Data: 2 Objects 6 Fields UI: 1 Views 1 Actions Runtime: 3 plugins ``` The fixture was built in the session scratchpad, outside the repository, and deleted; nothing of it is in this diff. ## Acceptance notes - **Pre-existing red, not this PR's**: `check:cross-package-test-inputs` exits 1 on a tree where `packages/spec/dist` is built. Already filed as objectstack-ai#18353 / objectstack-ai#18348; no seventh card. - **Noted, not filed** — the `transcript=os-build` block elides two summary rows the current CLI prints for that fixture (`Logic: 0 Flows` and `Security: 0 Positions 0 Permissions`) and the two `field-no-consumers` advisories it raises. Elision is explicitly within what the drift gate's design allows for a hand-authored transcript ("an elided, annotated, sometimes abbreviated paste"), so this is an observation about the page's editorial choices, not a defect, and it is out of this card's declared scope. Carrier: the next PR that re-measures this block, or a `docs-accuracy-audit` pass over `content/docs/deployment/cli.mdx`. Authored by Claude Code in session `session_01DvvamiacK328idtBYJBxV3`. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…bjectstack-ai#18170) (objectstack-ai#18428) Fixes objectstack-ai#18170 Clause-②: yes **Declared with the claim comment, unchanged.** The card offers two repairs whose clause-② answers differ, so the claim took the conservative arm before the direction was measured. The direction delivered here is the **tightening** (see below); correcting the grade is a tier-qualified seat's act, not this PR's — the changeset is graded `minor` so the `yes` floor holds either way. ## What was wrong `collectDocsFromSrc` reads exactly one directory — `path.join(path.dirname(configPath), 'src', 'docs')` — and no other. Under an ADR-0130 multi-package layout, where every top-level directory under `src/` is a package, a docs directory belongs to its package: `src/PACKAGE/docs/`. Move one there and the two conventions disagree in the worst possible way: - the collector reads nothing; - `compile.ts` prints `Collecting package docs (ADR-0046)...` unconditionally, before it knows whether anything was collected; - the build exits **0** and writes an artifact with no `docs[]` at all. Measured on `objectstack-ai/hotcrm` at `590b095` (pin 17.4.0) by the filing seat: `git mv src/docs src/sales/docs` as the only change, four package docs gone, nothing in the output naming the loss. Re-verified on `origin/main` in this repo before writing a line: the fixed path is `packages/cli/src/utils/collect-docs.ts:222`, the unconditional step line is `packages/cli/src/commands/compile.ts:641`, and `collect-docs.test.ts:80` builds its whole fixture on the same `src/docs` join. ## The direction taken, and the measurement behind it The card offers **either** of two repairs and does not pick. They are not equivalent in kind: | repair | kind | delivered | |:--|:--|:--| | read package docs from each package directory | **widens** the accepted set — the build starts accepting a layout it refuses today | no | | fail or warn loudly when an expected docs directory is absent | **tightens** — an existing silent loss becomes audible | **yes** | The tightening is the card's own stated minimum and the higher-value half under the charter's anti-error axis (「契约收紧…优于消费端宽容…宽容恰是 AI 批量犯错被掩盖的温床」). Two measurements decided it rather than preference: 1. **The widening is not fully specified by anything on this tree.** An ADR-0130 D4 artifact registers **per package** (`packages[]`, option B), and `compile.ts` attaches collected docs to the artifact's **top level** (`finalBundle.docs`). Per-package docs therefore have to say *which package body* they belong to, and that attachment point is open — `lintDocs` also enforces a namespace prefix read from one `stack.manifest.namespace`, which a multi-package stack does not have a single value for. Choosing one is a contract decision, ⛔ not a defect fix. 2. **The tightening has a blast radius of zero in this tree, measured.** No directory matching `src/*/docs` exists anywhere under `examples/` or `packages/` (the two example apps keep their flat `src/docs`), and the `build-json-advisory-parity` fixture builds only `src/docs` — so no existing build, fixture or gate changes behaviour. ## What changed One warning per `src/PACKAGE/docs/` directory holding Markdown, raised where the path is anchored (`collect-docs.ts`), so `os build`, `os validate` **and** `os lint` all report it through the doc-issue channel they already share — text face and `--json` `warnings` alike: ``` ⚠ src/sales/docs: src/sales/docs/ holds 4 Markdown file(s) that were NOT collected: package docs are read from src/docs/ only (ADR-0046 §3.2), so these are absent from the artifact's `docs[]` and from every book that includes them. Move them into src/docs/ … Found: … rule: docs/uncollected-directory ``` `severity: 'warning'`, not `'error'`, deliberately: an error fails the build, and a `src/PACKAGE/docs/` directory is not declared anywhere the build can read — the collector can only *guess* it was meant as ADR-0046 docs, and refusing a tree that is green today on a guess is worse than the silence it replaces. The reasoning is in the function's own docblock, not only here. ## Tests, and the ablation that proves they can fail⚠️ `collect-docs.test.ts` was the pin most likely to go green on the wrong fix — its fixture is built on the same `src/docs` join — so every new case that asserts the warning **also** asserts what was collected. A collector that silently returned `{ docs: [], issues: [] }` passes none of them. Ablation (one-shot proof, no test file left behind), run from the committed state, at `6418bae2d`: | leg | on-disk proof | result | |:--|:--|:--| | mutate: drop the probe call from `collectDocsFromSrc` | `uncollectedDocsDirectories(srcDir)` occurrences 1 → 0, marker 1, blob `366e74b5` ≠ HEAD `3b212703` | **4 failed / 36 passed** | | restore: `git checkout HEAD -- packages/cli/src/utils/collect-docs.ts` | blob back to `3b212703`, `git diff HEAD` empty, marker count 0 | **40 passed** | The two cases that stay green under mutation are the negative controls — they assert *absence* of a warning. Evidence, all at `6418bae2d`: - `pnpm exec turbo run build --filter=@objectstack/cli --concurrency=2` :: exit 0 (57 tasks; the `^...` closure, required before any verdict — `@objectstack/spec/system` is imported by the test file) - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` :: exit 0 — **208 files / 2982 tests passed** - `pnpm --filter @objectstack/cli typecheck` :: exit 0 - `pnpm lint` :: exit 0 — the **whole** lane, not a narrowing (the derivation never names it) - `node scripts/pm/dispatch-gates.mjs --commands` → 62 families, all 62 run, reconciled with `--ran`: **62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN**⚠️ One red, pre-existing and ⛔ not this PR's: `pnpm check:cross-package-test-inputs` exits 1 wherever `packages/spec/dist` is built. Its finding names `packages/cli/test/init-created-files-summary.e2e.test.ts` descending `packages/spec/dist/` — a file this diff does not touch. Already filed as objectstack-ai#18353 / objectstack-ai#18348; ⛔ no fourth card. The `packages/cli` **integration** tier is declared to CI: this diff touches neither an integration-tier file nor a spawn entry (`bin/`, `test/helpers/serve-process.ts`), so only the `unit` tier is owed locally. ## Acceptance notes - **The `compile.ts` step line was deliberately NOT touched**, though it is on this card's declared file surface. Printed *before* the collection it announces, it is the reassurance half of the defect, and reporting the collected count after the call would be the honest form. But that line is quoted verbatim in `content/docs/deployment/cli.mdx` inside a fenced transcript block tagged `transcript=os-build`, and that page is staked by in-flight sibling PR objectstack-ai#18416 — editing it in this window is the breach the dispatch fence names. The change and the sentence it needs are reported back to the PM to sequence behind objectstack-ai#18416, ⛔ not resolved inside this diff. - **The other half of the card — reading `src/PACKAGE/docs/` — is not delivered**, for the contract reason measured above. It wants its own card carrying the attachment question (which `packages[]` body, and whose namespace prefix), ⛔ not a rider here. - `os dev` / `os serve` drop the collector's issues entirely (`serve.ts`, "collection only, never block boot"), so the dev server stays silent about this loss where the build now speaks. Noted, not filed: it is a deliberate, commented posture, and no build or artifact is affected. --- _Generated by [Claude Code](https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ne, and the arming echo is not the landing method (objectstack-ai#18713) Fixes objectstack-ai#18461 `Clause-②: no` One file: `.claude/skills/pm-dispatch/references/platform-readings.md`, held at **466 / 466** (headroom 0, widest line 120 B). No ceiling raised. Net line change **0**: one row rewritten in place, one row added, one payment taken in the same file. `skip-changeset` — `.claude/**` is shipped by no package's `files[]`, so nothing published moves. Reserved rows are untouched and verified by content: `:10`–`:12` and `:431` (objectstack-ai#18469 PR-A's pending patch), `:209` (PR objectstack-ai#18666's one-line change, awaiting the maintainer — it rides at `:208` after the payment, byte identical), and the nine rows PR objectstack-ai#18689 adopted an hour before this branch (`:28`, `:85`, `:249`, `:277`–`:278`, `:291`, `:347`, `:357`, `:393`, `:410` — none appears in any hunk of this diff). ## Row ② — rewritten in place at `:50` Ruling reference: the maintainer's letter **A** on objectstack-ai#18421, comment 5716260764 (2026-09-17T14:41Z, 「同意」). Its operative conclusion is the one this row carries: **`auto_merge.merge_method` is not a reading of what will land** — the landing shape is the merge queue's to decide. **before** (120 B, the falsified form): ```text - 挂上的 auto-merge 存的方法恒为 `merge`,不论请求了什么;REST `auto_merge.merge_method` 读回 `merge`。 ``` **after** (117 B): ```text - auto-merge 回读 `merge_method` 不恒定:同 `SQUASH` 载荷 `merge`/`squash` 皆现,⛔ 非落地方法判据。 ``` **Why 「不恒定」 and not either constant.** 「恒为 `merge`」 is falsified by this card's four PRs; 「恒为 `squash`」 is falsified by the same table's first row and by the ruling's own measurement. Same endpoint (`PUT .../pulls/{n}/ccr/auto_merge`), same `{"merge_method":"SQUASH"}` payload: | PR | echoed `merge_method` | landed as | |---|---|---| | objectstack-ai#18395 | `merge` | single-parent squash | | objectstack-ai#18392 | `squash` | single-parent squash | | objectstack-ai#18416 | `squash` | single-parent squash | | objectstack-ai#18445 | `squash` | single-parent squash | The objectstack-ai#18421 ruling measured the same endpoint answering `merge` (both arming channels, MCP and CCR), and the dispatching seat's own arms today echoed `squash` on PR objectstack-ai#18689 / objectstack-ai#18690 / objectstack-ai#18700 and `merge` on earlier ones. Four landings, two echoes, one landing shape ⇒ the echo varies and decides nothing. A row asserting either constant would teach a seat to investigate a read-back that is simply not a signal. **Neighbours left as they are.** `:49` is about the bare `PATCH` draft bit — unrelated. `:51` (「仓库 `allow_merge_commit:false` 时同样读回 `merge`」) does not restate 「恒」: it is one conditioned measurement, and 「同样」 now attaches to `merge`, which the rewritten row names as one of the two observed values. `:53`–`:55` already carry the landing half (the branch rule decides; three carriers agreeing proves nothing) and are untouched — the row above them no longer contradicts them. ## Row ① — added at `:272` **after** (118 B): ```text - 多标签页还静默截断:`totalCount` 231 而 `returned` 30,单标签同车道 24/24 ⇒ 求交读成空车道。 ``` **Placement — a declared deviation from the dispatch.** The dispatch named the `:247`–`:248` neighbourhood (the MCP `list_issues` rows). The OR half of this reading is already in the file, in the 读数陷阱 cluster at `:269`–`:274`: `:269` 「`labels` 数组是并集」, `:270` 「⛔ 永不读作交集」, `:272` 「失效全静默」, `:273` 「正确读法 = 整车道单标签一次读全加本地对 labels 求交」. Writing the new row at `:247` would have put a second home for the labels-OR fact six rows away from the first — the same duplication the dispatch forbids for row ② (one row for one fact, never two). So the row is placed where the fact it compounds already lives: directly between the silent-failure row `:272` and the prescription `:273`, which it is the reason for. The fact delivered is unchanged; only the address is. **What the row adds over `:269`–`:274`.** Those rows carry the OR semantics and the prescription. They do not carry the **truncation**, and the two compound: the returned page is a fraction of the set with nothing in the response saying which fraction, so a local intersection taken over *that page* can return zero and read as 「本车道无活」. The measurement is inside the row: `labels=["domain:cli","pm:queue"]` answered `totalCount=231` with `returned=30`, while the single-label query on the same lane in the same minute answered 24 / 24 — complete, and intersectable. That is what makes `:273` load-bearing rather than a style preference. ## The payment — one dedup, `:202`+`:203` become one row | payment | before | after | fact lost | |---|---|---|---| | `:202`+`:203` | 「换道:探针绿走 REST 列表端点 `GET /repos/{o}/{r}/issues?state=open&labels=a,b&per_page=N`。」 + 「它走 core 桶且 `labels` 是真 AND;⛔ 完整性自证靠 `&page=N` 加总数核对。」 | 「换道:探针绿走 REST 列表端点列卡,走 core 桶且 `labels` 真 AND;拼写与自证见 `rest-channel.md`。」 (118 B) | the inline endpoint spelling and the `&page=N`-plus-total spelling — both held verbatim in `rest-channel.md` 〈读侧〉, which `:137` already names as their single home (「逐操作通道归属、写侧配方与队列路由三读法见 `rest-channel.md`,⛔ 不在本表复述」). The two facts the row keeps inline are the ones a seat needs before it switches channel; `labels` being a true AND on REST is also stated in-file at `:274`, and the core bucket at `:115` / `:128`. | Net: 3 lines changed, 466 in, 466 out. ## Gate readings `node scripts/pm/check-governed-merges.mjs --test .claude/skills/pm-dispatch/references/platform-readings.md` exits **3 = GOVERNED** (`.claude/**` ×1) — recorded as the verdict it is. This PR stays draft; no seat flips it ready, enqueues it or arms auto-merge. The references tier applies: the skills seat's `## Contract review` record goes on this thread, and the seat lands it. - `pnpm check:pm-skill-ratchet` :: exit 0 — 「platform-readings.md is 466 lines (ceiling 466; headroom 0)」, 「widest table row is 0 bytes (pin 0; headroom 0)」; no line over 120 B (`awk 'length > 120'` returns nothing). - `pnpm check:skill-frame-sync` :: exit 0 — 「the one declared copy of the decision frame is internally coherent … 4 axes … 74 markdown files scanned for undeclared copies」. - `pnpm lint` (repo-wide, `eslint . --no-inline-config`) :: exit 0. - `grep -naP` for control characters over the file: no hits. No tag-shaped fragment in either new row. ### Derived gate list, with exit codes Derived from the worktree with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no hand-fed paths; change set 1 path vs merge base `72dd95fa5`), every command run, each exit code captured by redirect-then-`$?`: ```text node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/pm/check-governed-queue-guard.mjs --self-test :: exit 0 node scripts/pm/check-harness-current.mjs --self-test :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm check:agent-test-spelling :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-governed-merges :: exit 0 pnpm check:pm-half-states :: exit 0 pnpm check:pm-skill-id-lint :: exit 0 pnpm check:pm-skill-ratchet :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:required-contexts :: exit 0 pnpm check:skill-frame-sync :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:pm-settings-deny-roster :: exit 0 (outside the derivation; run because its roster lives under .claude, which this path is in) ``` `check:doc-formula-expressions` first exited **3 — PREREQUISITE NOT MET** (`@objectstack/formula` and `@objectstack/lint` not built, 「Nothing was measured」). It was re-run to a real verdict after `pnpm exec turbo run build --filter=@objectstack/formula --filter=@objectstack/lint` through `scripts/pm/os-verify-lock.sh` (VERDICT command-exit 0, held 1s, waited 0s); only the exit 0 is recorded above. Reconciliation: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran.txt` → 「18 derived famil(ies) accounted for — 18 run, 0 NOT-MEASURED (a DERIVED zero — all 18 recorded an exit code and none of them is 3)」. Outside that union: the 53 artifact-roster families, 11 wide-population families, 14 changeset-pending families and the CI-only families (`--verify-required-set`, `check-half-states --provenance`) — CI's, not this branch's, and named here so their absence is not read as a clearance. ## Out of scope, reported not written The card's third item — `scripts/pm/check-expected-skips.mjs` cannot run in a seat container (`ERR_MODULE_NOT_FOUND: Cannot find package 'yaml'`) — is not a references line and is not written here. In this worktree, after `pnpm install`, `yaml@2.9.0` is present; the crash is a property of the container the seat runs it from, not of the script. _Generated by [Claude Code](https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu)_ --- _Generated by [Claude Code](https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18171
Clause-②: no — this PR puts no new key on a published payload. It keeps the strict config parse exactly as it is and only documents the existing constraint and improves the diagnostic; no input the build refuses today becomes acceptable. Declared by the dispatching seat in claim
5694749953.Branch B as ruled in claim
5694749953: the strict parse stays, the constraint becomes discoverable. Branch A (read only thedefaultexport) is not taken, and nothing here drifts toward it — the acceptance set is byte-for-byte what it was.The landing point, corrected
The dispatch pointed at
packages/cli/src/utils/config.ts"docblock around line 261". That docblock belongs toauthoredSourcePlugin(theos migrate metashim) and is not where the module's exports are collected. The real merge is inloadConfig()itself:The config file is therefore loaded as a module: the default export is the base, and every named export is merged onto it as a top-level stack key, under its own name. That merge is deliberate and load-bearing —
onEnableandfunctionsare declared stack keys an app authors as named exports, and unwrappingmod.defaultalone dropped them.The card's finding reproduces here; its wording does not
Measured on this tree (not on the
hotcrmpin the card read), through the realloadConfig()and the realObjectStackDefinitionSchema:ProbeNamedExport— not a declared key["manifest","ProbeNamedExport"]unrecognized_keys, key namedobjects, default carries noobjects["manifest","objects"]objects, default already hasobjects["manifest","objects"], value[]So the diagnostic gap is real and the card is right to file it, but "the config file may carry no named export" / "exactly one export, the default stack" is too strict to document as the rule: rows 2 and 3 do not fail. What the code enforces is
and that is what this PR writes down. Documenting the card's sentence verbatim would have described a constraint the build does not have, and would have outlawed
onEnableandfunctions.Row 3 is a second, separate defect — a metadata-authoring trap, not a documentation gap — and it is out of scope here: closing it changes what the build accepts, which is branch-A territory and owes a
pm:retriage. It is reported to the dispatching seat to file. See Acceptance notes.Before / after
Both captured end-to-end through
os validateon the same project, on this branch. The "before" leg is the same binary withnamedExportRejectionHintsablated to return nothing (restore verified by blob hash).Before — the refusal names the key, and nothing tells the author why a key they never wrote is being judged:
After — same refusal, same exit code (1), same
--jsonpayload, with the rule and the fix added on the text face only:What changed
packages/cli/src/utils/config.ts—loadConfig()'s header now states the rule where the rule is created;LoadedConfiggained anamedExportsreading (the provenance the explanation needs, and nothing else); newnamedExportRejectionHints()turns a rootunrecognized_keysissue into the rule and the fix, for exactly the keys the merge put there.packages/cli/src/commands/compile.tsandvalidate.ts— both authoring doors print it, soos buildandos validatedo not disagree about what an author is told. ⛔ Text face only: the--jsonbranches are untouched, so no field is added to a published envelope.packages/cli/src/commands/init.ts— all threeos inittemplates scaffold the constraint as a comment at the head of the config they write. No ADR id, no issue number, no repo-relative path —init-template-comments-self-containedsweeps that population and is green.content/docs/getting-started/your-first-project.mdxandcontent/docs/deployment/cli.mdx— the rule on the config-authoring surface and in the CLI configuration reference, with all three rows of the table above named..changeset/18171-config-module-named-export-rule.md—@objectstack/clipatch.The pin, and that it can fail
packages/cli/src/utils/config-named-export-rule.test.ts, 5 cases. The refusal comes first, because it is the property the ruling protects: the helper export still reaches the strict parse and is still refused by name, with theunrecognized_keyscode and the key echoed back. Then the hint's rule and fix; then the two things it must NOT claim (a key the author really did write insidedefineStack(), and a nested unrecognised key); then the two-door parity.Reverse verification, from the committed fix, mutation proved on disk, restored with
git checkout HEAD -- THE_PATH:Direction is worth stating plainly: the two negative cases assert
[]and therefore stay green under this mutation. Only the positive case is load-bearing against it — which is exactly why the file does not consist of negative assertions.Verification
Tree measured:
f6561b2b0(working tree identical to that commit throughout;git statusclean).scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackpnpm check:cross-package-test-inputspnpm lint(whole repo, re-run atf6561b2b0)pnpm --filter @objectstack/cli typecheckpnpm --filter @objectstack/cli exec vitest run --project unitpnpm build(whole repo)check:cross-package-test-inputsnamespackages/cli/test/init-created-files-summary.e2e.test.tsdescendingpackages/spec/dist/— a file this diff does not touch. Filed as #18353 / #18348; all three sibling cards this round hit it independently.packages/cli'sintegrationtier is declared to CI: this diff touches no integration-tier file, nobin/entry and no driver/kernel boot path, so only theunitproject was owed locally.Two gates were red because of this PR and are now green:
check-reference-carrier-shape(exit 3) andcheck:comment-mask-corpus(exit 1) both refused to parseinit.ts, because the first draft of the scaffold comment used backticks inside the template literal that renders it and closed the literal. Fixed inf6561b2b0.Acceptance notes
Filed via the report on #18171 (class c — a metadata-authoring trap;
devdoes not open cards):if (key in merged) continueinloadConfig(). Measured above:export const objects = [{…}]besidedefineStack({ manifest, objects: [] })leavesconfig.objects === [], the authored row never reaches the artifact, and nothing is printed at any level. Dedupe words: config named export shadowed, loadConfig key in merged, silently dropped stack key, objectstack.config module merge.Noted, not filed:
os create(packages/cli/src/commands/create.ts) andcreate-objectstackalso scaffold anobjectstack.config.tsand did not get the comment. The dispatch's declared file surface names theos inittemplate only, so widening it is the dispatching seat's call rather than this PR's. Carrier: whoever takes the follow-up — it lands in the sameinit-template-comments-self-containedpopulation this PR already passes, so it needs no new verification surface.Generated by Claude Code