Repository navigation
fix(cli): os validate runs the per-package author-time rule pass os build already ran - #18769
Conversation
…s build` already ran
`os build` runs the artifact's authoring rules twice: once over the
union-folded stack, then a second `runAuthoringRules('build', …)` pass over
each `artifactPackages(…)` entry with `packageBodyAsStack(…)` as resolution
context, de-duplicated against the union run. `os validate` ran the union pass
and stopped — importing neither seam. By `compile.ts`' own description the
survivors of that second pass are "exactly the set the union could not see", so
that whole set was findings `os build` reported and `os validate` structurally
could not. False-clean, and on the worse door: the fast pre-flight is what an
author runs before shipping.
Measured on origin/main 09e16a5 over examples/app-multi-package, both
commands exiting 0 — `os build --json` warnings: 4 (3 union + 1 per-package
survivor), `os validate --json` warnings: 3. After: both 4, same set, same
order.
The loop is now ONE seam, not two copies. `runPerPackageAuthoringRules` lives
beside `artifactPackages` / `packageBodyAsStack` in `utils/artifact-packages.ts`,
whose header already forbids a second copy of that shape by name: what would
have drifted between two hand-written loops is not the package reading but the
verdict — the de-duplication key, the severity split, the `where` prefix.
`os build`'s observable output is unchanged (text face byte-identical modulo
timings; `--json` payload identical).
Severity mapping is `os build`'s, unchanged: a per-package `error` refuses, an
advisory joins `warnings`. So `os validate` is narrowed only to the bar the
command that ships already holds, and nothing that builds today stops
validating.
Two pins, one per tier. `test/validate-per-package-authoring-seam.test.ts`
(unit) ratchets that both doors call the shared pass, that neither names
`packageBodyAsStack` itself, and that the pass cannot answer differently per
door — with a non-vacuity case, because every single-package fixture in this
suite is blind to the defect. That blindness is measured, not asserted:
`test/build-json-advisory-parity.e2e.test.ts` already claims "nothing rides in
build's `warnings` that validate does not also report" and stayed green through
this defect, its fixtures declaring no `packages[]` at all.
`test/validate-per-package-authoring-parity.test.ts` (integration) is the
behavioural half over a two-package project, with a single-package control.
Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3
Co-authored-by: Claude <noreply@anthropic.com>
…lidate-per-package-authoring-pass
📓 Docs Drift CheckThis PR changes 1 package(s): 17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 3 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 75b93a3124346c7df60764918003eea3fff9f911 && git checkout 75b93a3124346c7df60764918003eea3fff9f911
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 062f5cdd7116a56d687e87dec71d789ef5c593b6 e62c44e48bbde25eb01109e3f1383c44df880796 && git checkout -B drift-repro 062f5cdd7116a56d687e87dec71d789ef5c593b6 && git merge --no-ff e62c44e48bbde25eb01109e3f1383c44df880796
node scripts/docs-audit/affected-docs.mjs --json 062f5cdd7116a56d687e87dec71d789ef5c593b6
|
Docs rider — NOT FALSIFIED, and the one page that states the invariant was RIGHT while the code was wrong · 2026-09-17T20:04Z
The predicate, stated BEFORE readingA page is falsified by this diff only if it states one of:
The population — the corpus, ⛔ not the bot's 17 rowsRe-derived on the tree the bot named ( The readings
⭐ The one page that carries the invariant states it CORRECTLY — the code was the falsified half
That sentence was false on Release-owned pages — read, ⛔ not edited, nothing to route
Noted, ⛔ not done hereThe per-package pass is undocumented for both doors: the "one gate, four doors" table enumerates rule FAMILIES (all of which run in both passes), never the two stack TIERS the pass adds. Documenting it is a real improvement, but it is an ADR-0130 D4 statement about Generated by Claude Code |
…e pass was recorded in
`test/validate-build-gate-parity.test.ts` holds a CLOSED roster: every bare
identifier either command calls must land in exactly one of its three ledgers.
Wiring `runPerPackageAuthoringRules` into both doors left it unclassified, so
the roster reddened — correctly, and on the one shard that runs
`packages/cli` (`Test Core (4/6)`, step "Run this shard's tests").
⛔ Not a refusal: nothing in the suite was newly rejected by `os validate`, so
the `Clause-②: no` declaration is untouched by this red. The two failures were
"every call site in compile.ts and validate.ts is classified" (1 unclassified
name) and "no ledger entry is stale" (3 names no command uses any more).
What the ledgers now say:
* `runPerPackageAuthoringRules` joins SHARED_NON_REGISTRY_GATES. It cannot
become a registry rule: a registry rule is handed ONE stack, and this pass
is the thing that DECIDES which stack — the artifact sliced per package
(ADR-0130 D4/D5). Its row buys a real assertion for free, because
`it.each(SHARED_NON_REGISTRY_GATES)` asserts both commands run it.
* The #18491 entry "Input to the compile-only per-package rule walk — a real
parity gap, reported not closed" is DELETED, not reworded. That entry was
right and is now spent; a ledger row that outlives its finding is how a
closed gap reads as an open one.
* `artifactPackages` survives on its own reason — both commands read it to
COUNT the packages for the step line. `packageBodyAsStack` left the command
files with the loop, as did `findingKey` and the `new Set(…)` de-duplication.
packages/cli unit tier: 213 files / 3041 tests, all passing.
Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3
Co-authored-by: Claude <noreply@anthropic.com>
…ict` narrowing an at-tier review exhibited `.changeset/18677-validate-per-package-authoring-pass.md` declined its `**BREAKING**` banner on the strength of a negative — "not declared breaking, because the narrowing could not be exhibited" — and the negative is false. The fixture that exhibits it did not exist when that entry was written: on the two-package `CONFIG_FLIP` config shipped in `packages/cli/test/lint-per-package-authoring-parity.test.ts`, `os validate --json --strict` reads exit 0 / 0 warnings with `validate.ts` restored to its pre-#18769 blob `bafa54b07f` and exit 1 / 1 warning at head. The entry is still pending and unreleased (`@objectstack/cli` 17.4.0 on npm equals the manifest version, and the CHANGELOG carries none of this text), so this is a forward edit to an unpublished file, not a rewrite of landed history. It adds the banner, the measured table, the mechanism that explains why the union fold cannot see the finding, and the ADR-0087 disposition the banner owes; it also narrows "nothing that builds today stops validating", which was the sentence an upgrader would have been misled by. ⛔ Out of scope, deliberately: the landed clause-② declaration on #18677 and what a `yes (narrowing)` that shipped declared `no` owes beyond this file. That is the maintainer's, and this commit does not answer it. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
…os validate --json` pins `validate.ts` has SEVEN `await emitJson(` exits; both nightly-only pins asserted SIX, so `main`'s nightly tier has been red since the seventh exit landed with #18769 — with no run able to say so, because these files are nightly-tier by NAME and a `packages/cli/**` diff never triggers that workflow. Both CONTRACTS the two files exist for still hold: every one of the seven exits carries `warnings` AND `conversions`. Only the COUNT was stale. So the integer is not re-pinned to 7 — that re-arms the identical trap for exit eight, and only a cron would ever read it. It is derived instead: the extractor must produce one payload literal per `emitJson` call site the file actually has, whatever today's number is. That is strictly stronger than an integer — it also catches a call site the extractor CANNOT see (one spelled without `await`), which a hard-coded count reads as green. The one integer left is demoted from an equality to a floor (six, the population the #12047 / #12125 rulings were made over). It rots only in the direction that has to be reviewed anyway — exits being removed — and it floors the derived pair away from the single vacuum they share, an `emitJson` renamed out of existence taking both sides to zero. Nothing is skipped, disabled or deleted: the count assertion, the success/failure partition and the contract negative are all still there. `validate.ts` is untouched. Measured on this branch, `packages/cli`: OS_TEST_TIERS unset 266 files collected, ZERO of them these two OS_TEST_TIERS=nightly 68 files collected, both of them present Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
…her two doors already ran (objectstack-ai#18813) Fixes objectstack-ai#18778 Clause-②: yes (narrowing) `os lint --strict` now exits 1 on a project it exited 0 for. A newly-refused input is **exhibited** below, not reasoned about.⚠️ objectstack-ai#18677's `Clause-②: no` rested on "no newly-refused input could be exhibited" measured on `os validate`'s door; ⛔ it does not transfer, and on this door it is false. Verified through `readClause2Line` from `scripts/pm/check-clause2-carriers.mjs`, ⛔ not an ad-hoc regex. `os build` has run the author-time rule table **twice** since objectstack-ai#16611 — once over the union-folded stack, then once per `artifactPackages(…)` entry with `packageBodyAsStack(…)` as resolution context, de-duplicated against the union run. objectstack-ai#18769 gave `os validate` the second half. `os lint` ran the union fold and stopped, so by `compile.ts`' own description the survivors of that pass — *"exactly the set the union could not see"* — were findings `os build` reported and `os lint` **structurally could not**. Same false-clean direction, on the fastest of the three doors. All three now call the one shared pass. ## ⭐ The trap, confirmed rather than relayed `lint.ts` **does** import `artifactPackages` and `packageBodyAsStack` — at `packages/cli/src/commands/lint.ts:18`, used at `:542` and `:546`. Opening the hits is what settles it: they feed `os lint`'s **own** intra-package duplicate-name advisory (objectstack-ai#17821), never the shared rule table. ⛔ A count is not a reading. This door is also the one place in the tree where "the loop is already written here, write the second one beside it" is the cheap move, so the seam pin ratchets the call **count** rather than its absence — the sibling doors' `not.toMatch(/packageBodyAsStack\(/)` assertion ⛔ cannot be transplanted here. ## Should `os lint` run it? — settled from the repo's own statements, ⛔ not assumed The dispatch fenced this as a contract question with a STOP arm. It did not need the stop arm; four statements settle it, and the one statement that reads the other way is **false on the tree**. **For, and they are this door's own words:** 1. `packages/lint/src/authoring-rules.ts:38-44` — *"Any rule that can emit `error` runs on all three commands. A gate is only as strong as the weakest command an author or CI happens to run, so a gating rule with partial coverage is not a stricter check — it is a coin flip"*, and *"the three commands are three doors in ONE wall"*. 2. `packages/cli/src/commands/lint.ts:652-656` — the union fold landed **here**, for this exact direction: *"the whole table reported nothing and `os lint` returned no finding of any severity for a project `os build` refuses"*. That is the same sentence as this card, one layer out; the fold fixed which COLLECTIONS the table sees, this fixes which STACKS it is run over. 3. `packages/cli/src/commands/lint.ts:604-611` — `os lint`'s pre-registry hand-wired subset was removed because *"a pre-flight that disagrees with the gate in both directions is worse than no pre-flight"*. 4. `packages/cli/test/validate-build-gate-parity.test.ts` already holds the INPUT equal across all three doors (`all three authoring commands hand the rule table the union-folded stack`), and its objectstack-ai#12297 note states the governing rule for exactly this shape: *"A guard that enumerates a subset of the class it describes reports green for the members it forgot. The list is the class now, not the card."* **Against — one statement, and⚠️ it was already FALSE when it was written:** `validate-build-gate-parity.test.ts`' `PARITY_COMMANDS` docblock read *"`lint.ts` … emits no artifact and **runs no artifact-level gate**, so it is not part of the parity question."* Measured at `7572329069`: `lint.ts:13` imports and `lint.ts:881` **calls** `collectAndLintDocs` — a name in that same file's `SHARED_NON_REGISTRY_GATES` roster, i.e. an artifact-level gate by the file's own classification. The clause is corrected in this PR rather than deleted, because it is the one sentence in this repository that could be read as "`os lint` is exempt from the artifact-level gates" and this card had to settle exactly that. What excludes `lint.ts` from `PARITY_COMMANDS` is the ARTIFACT (that file's question is `os validate` as `os build`'s read-only superset), not the gates. ⛔ `PARITY_COMMANDS` is deliberately NOT widened — that would re-open every ledger classification against a third file in one stroke. ⇒ the roster prediction in the dispatch resolved the other way: `SHARED_NON_REGISTRY_GATES` and its `it.each('both commands run %s')` did **not** move. They are keyed to `PARITY_COMMANDS`, which this PR leaves at two files, so nothing on that roster reddened. What moved is the docblock the roster is read through. ## The measurement Fixture `CONFIG_FLIP` (shipped as the pin's own fixture): `core` owns `pp_account`, the sibling `orders` package owns the view that displays `pp_account.industry`. Judged as one flattened union the field has a consumer and nothing is raised; judged per package, `core` declares a field nothing in `core` reads. ⇒ **the union run is clean and the per-package run is not** — the only shape that can tell "the doors agree" from "the doors agree because neither looked". At `origin/main` `7572329069`: | | `os build --json` | `os lint --json` | `os lint --json --strict` | |---|---|---|---| | **before** | warnings **1** (per-package only) | total **0**, exit **0** | **exit 0**, `failing: 0` | | **after** | warnings **1**, unchanged | total **1**, exit **0** | **exit 1**, `failing: 1` | On the sibling two-package fixture from objectstack-ai#18769 (`CONFIG_MULTI`, where the survivor is the positional-key ECHO): `os build` 3 warnings · `os validate` 3 · `os lint` **2 before, 3 after** — the three doors now report one set. **CONTROL** — a single-package project (no `packages[]`): `packageCount` 0, the pass is skipped, and `os lint` reports zero per-package findings before and after. Without it "the doors agree" is satisfied by three commands that all looked at nothing, which is exactly how this gap survived two cards' worth of parity files. ⛔ **The DEFAULT face is not claimed to move.** No `error`-severity per-package-only finding was exhibited: two probe shapes were tried — a cross-package field consumer and a cross-package page reference (`nav-target-unresolved`) — and both land at `warning`. That half is **NOT MEASURED**, and the `--strict` pin says so in its own comment rather than asserting an unmeasured default-face flip. The severity face is `os lint`'s own and unchanged: one mapping expression now serves both halves (`info` → `suggestion`, everything else verbatim), so an `error` fails the run, a `warning` fails it only under `--strict`. A per-package `error` is one `os build` ALREADY refuses, so this narrows `os lint` to the bar the command that ships holds and never past it. ## Red/green, both legs, from the committed state `packages/cli/src/commands/lint.ts` alone restored to its `7572329069` blob (`git show 7572329:…`), everything else at HEAD. Marker count on disk `2 → 0` **verified before running**; restored with `git checkout HEAD -- <path>`, `git diff HEAD` empty and `git hash-object` back to `5b2eb1af023348865d06a4ab7355708af801db43` after each leg. | pin | ablated | at HEAD | |---|---|---| | `test/lint-per-package-authoring-seam.test.ts` (unit) | **2 failed**, 4 passed | **6 passed** | | `test/lint-per-package-authoring-parity.test.ts` (integration) | **2 failed**, 3 passed | **5 passed** | The 4 and 3 that pass in both legs are deliberately door-independent (the pass's own three-door equality, the `findings` ∪ split identity, the single-package control, the `packageBodyAsStack` count ratchet) — they are not measuring `lint.ts`, and a file where everything reddened would mean the pins were coupled to the fix rather than to the behaviour. The ablated integration failure is the narrowing itself: `expected +0 to be 1` on `failing`. ## Tier, read from the config's own answer both directions `vitest list --filesOnly` per project, ⛔ not the predicate applied by hand: | file | `--project unit` | `--project integration` | |---|---|---| | `lint-per-package-authoring-seam.test.ts` | **1** | 0 | | `lint-per-package-authoring-parity.test.ts` | 0 | **1** | | `validate-per-package-authoring-seam.test.ts` (control) | **1** | 0 | | `validate-per-package-authoring-parity.test.ts` (control) | 0 | **1** | Populations non-vacuous: 214 unit files / 50 integration files. Neither new file constructs `new ObjectQL(`, so neither carries the KERNEL signal, and no existing file changed tier (no existing test file's source was touched except `validate-build-gate-parity.test.ts`, comment-only, which stays unit). ## What ran - `pnpm --filter '@objectstack/cli^...' build` — dependency closure, exit 0. - `pnpm --filter @objectstack/cli exec vitest run --project unit` — **214 files / 3047 tests, all pass**. - `pnpm --filter @objectstack/cli exec vitest run --project integration` over the **declared-narrowed** set of 9 files this diff can reach (`authoring-rule-command-parity`, `union-fold-command-parity`, `validate-per-package-authoring-parity`, `lint-per-package-authoring-parity`, `info-detail-package-fold`, `lint-eval-generator-refusal-separator`, `emit-json-pipe`, `adr-0048-app-split`, `nav-contribution-groups.package-id`) — **9 files / 53 tests, all pass**. The other 41 integration-tier files are DB/migration/secret/generate suites that never run the authoring rule table; ⇒ declared to CI. - `pnpm --filter @objectstack/cli typecheck` — exit 0, including `check:test-typecheck`. Both new test files are really in that program: `tsc -p tsconfig.test.json --listFiles` names them (2 of 2), so the claim is measured, not assumed. - Gate families derived from the merge base by `scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (⛔ not a hand-made path list): **61 derived, 58 run green, 3 NOT MEASURED, 0 UNRUN**, reconciled through `--ran` with an exit code recorded per family. The three are `check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage`, each **exit 3 = PREREQUISITE NOT MET** (packages with no `dist/` in this worktree) — ⛔ read as NOT MEASURED, never as a pass. `check:i18n-walk-parity` also refused at first for the same reason and went green after `pnpm --filter @objectstack/cli build`. - `eslint --no-inline-config` narrowed to the 5 changed source files: **0 errors, 0 warnings**, file count **5** read from `--format json`, not guessed. The narrowing is a measurement because the population is read from eslint's own config (`files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` plus per-directory overlays) and because `eslint.config.mjs:326-329` states this repo *"never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file"* — so this diff cannot move the verdict on any file it does not touch. The repo-wide `pnpm lint` run is CI's. ## Changeset `minor` on `@objectstack/cli` with a `**BREAKING**` banner and the ADR-0087 disposition `not-required (no-migration-prescription)` — `check:adr-0087-registration` reads the arm and prints it back: `[BREAKING+clause-②-narrowing]`. ⛔ Not `skip-changeset`: `packages/cli` is released and both edited sources ship in its `files[]`. `major` is out of the launch window, which `check:changeset-no-major` confirms green. ## Acceptance notes Noted, not filed — nothing here is a reproducible defect, a declared-contract violation, or a metadata trap: 1.⚠️ **objectstack-ai#18769's own `Clause-②: no` is falsified by this card's fixture, and the falsification is MEASURED.** `os validate --strict` exits 1 when `warnings > 0` (`validate.ts:715`). On `CONFIG_FLIP`, with `validate.ts` alone restored to `095c7f60ae^` it exits **0** (0 warnings); at HEAD it exits **1** (1 per-package warning). Restore verified by blob hash. ⇒ a newly-refused input **could** be exhibited on that door too; what was missing was a fixture whose union run is clean, and objectstack-ai#18769's fixture (an ECHO of a union finding) structurally cannot be one. ⛔ Not corrected here — objectstack-ai#18769 is merged, and rewriting a landed declaration is not this card's act. Carrier: the PM seat. Dedupe words: `18769 clause-② narrowing falsified` · `validate --strict per-package warning exit` · `union-clean per-package fixture`. 2. The `findings` member added to `runPerPackageAuthoringRules` exists because `os lint` has no severity split to re-join; re-joining `errors` then `advisories` at that door would put all errors before all advisories and silently re-order a list the union run above it produces in rule order. Stated in the member's own docblock so the next door does not have to re-derive it. 3. `packages/cli/vitest-tiers.ts` treats `new ObjectQL(` as a KERNEL signal, and a per-package pin is exactly the kind of test that grows one later. Nothing to file; the tier table above is the reading that would catch it. ⛔ Untouched, each with its own card: **objectstack-ai#18779** (the positional de-duplication key — changing it changes what `os build` reports) and **objectstack-ai#18780** (`os build`'s text face counting advisories it never prints). --- _Generated by [Claude Code](https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… summary line counts (objectstack-ai#18857) Fixes objectstack-ai#18780 Clause-②: no `os build`'s text face counted per-package author-time advisories it never printed: `⚠ 4 author-time warning(s) — see above` standing over a list of 3. The list grows to the count.⚠️ **This is a RESUMED delivery.** Two commits were already on this branch from a run whose container was killed before it opened a PR. They carried **no** verification — no test reading, no ablation, no changeset measurement, no gate sweep survived. They were re-read adversarially and re-measured from scratch; three defects in them are corrected in this PR and are listed below. ## The card's reading, re-derived rather than relayed The card recorded the 4-vs-3 count as the objectstack-ai#18677 deliverer's, explicitly not re-driven by the filing seat. It reproduces exactly. Measured on `examples/app-multi-package`, `compile.ts` at this branch's merge-base `ad1f94e8ec`, CLI run from source, `NO_COLOR=1`: ``` os build exit 0 3 rendered advisory entries summary: "4 author-time warning(s) — see above" os build --json exit 0 warnings: 4, of which 1 carries a `package 'ID' — ` prefix os validate exit 0 4 rendered advisory entries (no "see above" sentence on that face at all) ``` With `compile.ts` at this branch's HEAD, same fixture: `os build` renders **4**, summary reads **4**, `--json` still carries **4**. The mutation was proven on disk by blob hash before each reading and the restore proven by an empty `git diff HEAD` — not by an editing command's exit code. ⭐ One correction to the card's framing, offered rather than assumed: `os validate` renders its four through a *different* printer — one line per advisory, no closing `rule: ID at PATH` line — and it has no summary sentence to keep honest. So "the two doors disagree on the rendered list" is right, but only `os build` can carry this defect at all. `os lint` cannot either: `see above` appears in exactly one place in `packages/cli/src`, and it is `compile.ts`. ## Which side moves, and what the chosen side costs — measured The card deliberately did not rule on which side moves. **The list moves, not the count**, and the cost of that is measured rather than argued: | face | before | after | delta | |:--|:--|:--|:--| | single-package stack (no `packages[]`) | 2038 bytes | 2038 bytes | the two clocks only — `Load time: Nms`, `Build complete (Nms)` | | union-level author-time FAILURE | 3590 bytes | 3590 bytes | `Load time: Nms` only | | multi-package stack | 3 entries under a count of 4 | 4 entries under a count of 4 | the block renders below the `Running author-time rules per package (N)...` step line, and gains the per-package entry | So the only rendered byte this moves is the one the card exists to move. objectstack-ai#18769 held `os build`'s text output byte-identical on purpose; that hold is honoured everywhere except the defect itself. Shrinking the count instead would have made the text face report 3 while its own `--json` and `os validate` both report 4 — the false-clean direction objectstack-ai#11529 named one list over, and it would have needed a second binding to count a rendering rather than a set. ## The pin, and the two controls `packages/cli/test/build-text-face-advisory-count.test.ts` asserts an **equality read from one run**, not a number: the integer in the summary line, the count of entries rendered above it, and the length of `--json`'s `warnings`. A fixture that raises a different number of advisories keeps passing; a face that counts a set it did not print cannot. Both directions were run, each from a committed tree, each with the mutation proven on disk and the restore proven clean: - **fails before** — `compile.ts` reverted to its pre-fix blob, pin unchanged: `Tests 2 failed | 3 passed`, on `summary said 3; rendered list: … expected 2 to be 3` and on `this per-package finding rides --json and the text face never prints it`. - **passes after** — at HEAD: `Tests 5 passed`. - **the lit control can fail, and fails on the condition it names** — strip `packages[]` out of the fixture and the equality assertions all stay GREEN while `the fixture reaches the per-package pass and raises a survivor there` goes RED: `1 failed | 4 passed`. That is exactly the vacuous pass the control exists to refuse. ## What was rewritten in the pre-existing diff 1. **A raw ESC byte in the pin.** `stripAnsi` carried a literal 0x1B inside its regex literal — the class `check:nul-bytes` rejects, invisible in every reader. Rewritten as an escape; byte-identical at runtime. 2. **A red that predated this branch.** The new once-guard is a call site in `compile.ts`, and `validate-build-gate-parity.test.ts` has held a CLOSED roster since objectstack-ai#18491 — every bare-identifier call site must land in exactly one ledger. It was in none, so that file failed twice (`every call site … is classified`, and the parity gap derived from the same set). The roster and the assertion are both present at this branch's merge-base and the name is absent there, so the red was carried by the two commits this branch started from, not introduced by merging `main`. Classified as `NOT_A_GATE` under the presentation reason, with the argument written next to it: the guard decides WHEN `printAuthoringAdvisories` is called and nothing else, and `validate.ts` has nothing to wire because it has no "see above" sentence. 3. **A changeset sentence stricter than its own measurement.** It claimed the single-package captures "differ only in the `Build complete (Nms)` timer". Re-measured: they differ in **two** clocks — `Load time: Nms` as well. Both are clocks, so the claim survives; the sentence now says what was actually measured. ## What was verified and kept - **Every exit between step 3b and the summary flushes.** Enumerated: the union-failure text branch, the per-package-failure text branch, the continuing path, and the catch-all. The `--json` branches return early through the guard, and `isExitSignal` re-throws ahead of the catch's flush so no face double-prints. - **`^ {4}rule: ` really does tell an advisory from an error.** `printAuthoringAdvisories` closes each entry with a four-space line; `printAuthoringRuleErrors` and `printDocIssueErrors` both indent theirs by six. - **The `--json` leg's `where`-typeof filter isolates `ruleAdvisories` exactly.** None of the other five members of `warningsSoFar()` carries a `where` key — `DocIssue`, `NavContributionGroupDiagnostic`, `PermissionSetNameCollisionDiagnostic`, the capability-provider pairs and the plain-string undeclared-key list were each read. So the third assertion is sound in general, not only on this fixture. ## Changeset level, measured on the built `dist` ⛔ Not inferred from `files[]` and not from the file's look: - positive control — `author-time warning(s) — see above` is present in **1** published file, `packages/cli/dist/commands/compile.js`; - negative control — a token nothing in the tree carries is present in **0**; - `dist/commands/compile.js` sha256 `4999075…` with `compile.ts` at its pre-fix blob, `f1bbb59…` with the fix — **published bytes move**; - restoring and rebuilding a third time reproduces `f1bbb59…` exactly, which is what makes the middle reading a measurement rather than build noise. `@objectstack/cli` is public and versioned and ships `dist`, so a changeset is owed. `patch`: a bug fix in a released package, `Clause-②: no` — no schema key, no closed-set member, no published export, no registry entry, and no payload key, exit code or `--json` byte moves. `check-widening-tells --declaration no` over this diff reports no file on any declared surface (3 NOT MEASURED, 0 tells). ## Verification run on this branch `origin/main` is merged in (not rebased); both pre-existing commits are still ancestors, verified by `git merge-base --is-ancestor` exit 0 on each — a positive reading, which is self-proving in any checkout. - `pnpm --filter @objectstack/cli exec vitest run --project unit` — **214 files, 3048 tests, 0 failed** - `pnpm --filter @objectstack/cli exec vitest run --project integration` — **51 files, 427 tests, 0 failed** - the six nightly-tier `os build` pins, run under `OS_TEST_TIERS=nightly` because the queue population excludes them by name — `build-json-advisory-parity`, `build-json-undeclared-key-parity`, `build-json-failure-warnings`, `build-multi-package-artifact`, `compile-artifact-packages`, `build-docs-step-count`: **6 files, 41 tests, 0 failed** - `pnpm --filter @objectstack/cli typecheck` — exit 0. Note `tsconfig.json` includes `src` only, so the test layer is judged by the `check:test-typecheck` half of that script, which reports the layer compiling under `tsconfig.test.json`. - `pnpm --filter '@objectstack/cli^...' build` — exit 0 (the dependency closure). - the gate families `scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives from this branch's own change set, each exit code captured before any pipe.⚠️ Two earlier readings were contention artefacts and are recorded as such rather than as failures: four `scaffold-emission-typechecks` cases reddened in a run that was competing with a gate sweep and was then killed at a timeout, and both pass in the clean full run above. ## Acceptance notes - `check:type-check-debt` was killed by the OOM killer (exit 137 inside its full-repo build, gate exit 3) during a contended sweep. Its own failure text says that is **not** a pass and **not** a finding — nothing was measured. It is re-run alone in the final sweep and the reading is in the report. - Noted, not filed: `printDocIssueErrors` and `printAuthoringRuleErrors` render identical six-space `rule:` lines, so a text-face assertion that keys on that indent alone cannot tell a doc error from a rule error. Nothing in this PR depends on it, no open PR is heading for that file, and there is no carrier — recorded here rather than as a card. --- _Generated by [Claude Code](https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…, so the input each door hands them is written as its own axis (objectstack-ai#18872) Fixes objectstack-ai#18815 Clause-②: no Docs-only. One file, +69 / -0: `content/docs/deployment/validating-metadata.mdx`. ## The gap, restated at the scope it is actually at The "one gate, four doors" table has exactly two axes: its **rows** are rule **families**, its **columns** are the four doors. A check mark says that door runs that family. It cannot say what that door **hands** the family to judge — and that input is where the three CLI doors have drifted three separate times. So the two-pass shape they run (the union fold over the artifact's collections, then the same table once per `packages[]` entry) was documented nowhere, for any door, across the three landings that wired it. This PR adds a third axis to the page as its own `###` section, plus two short scoping paragraphs: one above the table naming what the table's two axes are, one under the `one-directional` parenthetical saying which scope that sentence is written at. ## The card's gating claim, re-derived rather than relayed The card rests on "all three doors now run the per-package pass". Verified on the branch base `fb2bccfc96` by reading the call sites, not by trusting the card: | door | call site | wired by | |---|---|---| | `os build` | `packages/cli/src/commands/compile.ts:482` | objectstack-ai#16611 | | `os validate` | `packages/cli/src/commands/validate.ts:421` | objectstack-ai#18677 (PR objectstack-ai#18769, merged 2026-09-17T21:18:28Z) | | `os lint` | `packages/cli/src/commands/lint.ts:722` | objectstack-ai#18778 (PR objectstack-ai#18813, merged 2026-09-17T23:32:18Z) | All three reach the one loop, `runPerPackageAuthoringRules` at `packages/cli/src/utils/artifact-packages.ts:189`. Both PRs are merged and are ancestors of this branch's base. **The successor condition holds.** Measured end to end as well, not only read: `validate-per-package-authoring-parity`, `lint-per-package-authoring-parity` and `union-fold-command-parity` (spawned, real binaries) — 3 files, 15 tests, exit 0. ## The seat's premise probe, re-run structurally The dispatch handed one grep (`per-package | union fold | union-folded | stack tier` => 0 hits, lit control `os build|os validate|os lint` => 25). Re-run as a structural read rather than a keyword miss, and widened to the whole docs tree: - on the page, every occurrence of "tier" is a **rule** tier — `pre-parse tier` (:519), `react tier` (:335), `tier findings` (:63), the ADR-0049 `tier programme` (:241). None is a stack shape. - across `content/docs/**`: zero hits for the per-package pass or the union fold in that sense (the 13 `per-package` hits are changelogs, doc-book grouping, capability prefixes, one-app-per-package ADRs), against a lit control of **64** files naming the three commands. Same probe over the published `skills/` catalog: zero, lit control 14. So the gap is real and is repo-wide, not just table-shaped. ## Falsification — the card's own word for the axis is already taken, and for a DIFFERENT axis The card and the dispatch both name the second axis the **stack TIER**. That phrase is already spent in this repo, on something else: - `packages/lint/src/authoring-rules.ts:1671` — *"The stack tiers a command has in hand when it runs the registry"*, documenting `AuthoringRuleRun`, whose members are `normalized` and `parsed`; - `:215` — *"Which tier of the stack a rule reads"*, for the same two; - `:213` — and `AuthoringRuleTier` is a third sense again, `'gating' | 'advisory'`. Those are not near-synonyms of the axis this card is about; they are **orthogonal to it**. `runPerPackageAuthoringRules` hands BOTH stack tiers the same per-package stack (`artifact-packages.ts:230-231`, `normalized: asStack, parsed: asStack`), and `lint.ts:653` says the mirror thing for the other pass — *"Both tiers are handed the UNION-FOLDED stack"*. A page that wrote "stack tier" for the union-vs-per-package axis would therefore have created a **new** collision, in the very module the table cites as its source, of exactly the class this card exists to close. ⇒ the page names the axis for what it is — the **input** each door hands the rule table — and names the two passes what the code already calls them: **the union fold** and **the per-package walk**. The card's *requirement* is met (family and input are kept apart, explicitly, at the one paragraph where they were conflated); its *wording* is deliberately not adopted. Flagging it rather than quietly diverging. ## Also written out, because it is a third thing again The fourth door is on **neither** pass. A runtime write is one item, so the publish gate evaluates differentially (context alone, then with the item grafted in, objectstack-ai#4463) and narrows its resolution context to the written item's **package closure** (objectstack-ai#9612) — which changes what a rule can *resolve*, never what it judges, and iterates no `packages[]`. "Runs per package" therefore names one thing at the three CLI doors and another at this one, and the `runtime publish` column says neither. Sourced from `packages/lint/src/runtime-gate.ts:208-259`, not from the card, which explicitly did not assert what that row should say. ## Evidence - **Derived gate families** — `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived **39**; all 39 run, exit codes recorded and reconciled: `--ran` => *"39 derived famil(ies) accounted for — 39 run, 0 NOT-MEASURED (a DERIVED zero — all 39 recorded an exit code and none of them is 3)"*. Five first reported `PREREQUISITE NOT MET` (exit 3 / exit 1, nothing measured) and were re-run to exit 0 after building `@objectstack/spec`, `@objectstack/formula`, `@objectstack/lint` and `@objectstack/client-react`: `check:doc-formula-expressions`, `check:doc-security-posture`, `spec check:docs`, `spec check:skill-examples`, `check:docs-transcript-drift`. - **`pnpm lint`** — the family `dispatch-gates.mjs` does not name. Run **whole**, not narrowed: `eslint . --no-inline-config`, **exit 0**, 82s, at `777cd9aeca`. - **MDX compiles** — `@mdx-js/mdx` 3.1.1 `compile()` on the edited page, exit 0. - **Anchors** — `pnpm check:doc-anchors` exit 0; the new heading adds `#what-each-door-hands-the-rule-table` and renames nothing, so the existing inbound links to `#the-one-gate-four-doors` (`cli.mdx:650`, `deployment/index.mdx:153`) are untouched. - **Control bytes** — `grep -naP` over the edited file: no hits; `pnpm check:nul-bytes` exit 0. - **Publishing surface, measured with controls both ways** — 83 manifests, 70 non-private. Manifests whose `files[]` mentions `content`: **0**. Positive control, `dist`: **70 of 70**. Manifests with no `files[]` at all (whole dir ships): **0**. `content/docs` is at the repo root, outside every package directory, and the one manifest that names a `content/docs` path at all does so in its `description` string (`plugin-webhooks`), not in `files[]`. ⇒ nothing published moves ⇒ `skip-changeset`, applied to this PR. ## Acceptance notes *Out of scope, noted and not filed:* - `content/docs/getting-started/examples.mdx` §"A project is a multi-package artifact" is the page that teaches `packages[]` to authors and would be the natural second home for a one-line pointer at the per-package pass. It does **not** enumerate the doors and contradicts nothing here, so triage's escalation condition ("a second page that cannot express the axis ⇒ p1 plus a structural card") is **not** met — measured, not assumed. Successor: none; noted for whoever next edits that section. - `content/docs/deployment/cli.mdx` carries the doors in two places (`:649`, `:668`, `:1485`) and defers to this page's matrix by link for the detail. Those three statements are true at both passes now, so nothing there is falsified by this PR and nothing was edited there. Successor: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3)_ Co-authored-by: Claude <noreply@anthropic.com>
…os validate --json` pins (objectstack-ai#18880) Fixes objectstack-ai#18848 Clause-②: no — the diff is two files under `packages/cli/test/`, no governed surface (`docs/adr/**`, `.claude/**`, `skills/**`, `AGENTS.md`, `CLAUDE.md`). Declared from the diff, and it agrees with the claim comment's declaration from the card. `packages/cli/src/commands/validate.ts` is **untouched** — read only, never written. The seventh exit is correct and stays. ## What was actually wrong `validate.ts` has **7** `await emitJson(` exits; two nightly-only pins asserted **6**. | reading | value | |:--|:--| | `await emitJson(` exits in `validate.ts` at `b0b5f31cc6` | **7** (`:286 :364 :434 :517 :554 :661 :762`) | | control at `095c7f60ae^` (pre-objectstack-ai#18769) | **6** | | what both pins asserted | `toHaveLength(6)` | | exits carrying `warnings:` | **7 of 7** | | exits carrying `conversions:` | **7 of 7** | ⇒ both CONTRACTS these two files exist for still hold. Only the COUNT was stale. Re-measured independently here and the dispatching seat's figures reproduce exactly. The seventh exit came with objectstack-ai#18769 and is legitimate. Its payload carries both keys. ## The choice: DERIVED, not `7` The claim asked for the option taken and the cost of the rejected one. **Rejected — `toHaveLength(7)`.** It is one character of work and it is wrong for a measurable reason. It re-arms the identical trap for exit eight, and the trap is not theoretical: it just cost a night of red `main` and was about to cost a duplicate p1 card. Its cost is not only that it rots, but **where** it rots — see the tier reading below. And it is strictly weaker: ablation C is a `validate.ts` with eight call sites where a pin of `7` reads **green** while the file has an exit no assertion in the family covers. **Taken — read the count off the source.** The extractor must produce one payload literal per `emitJson` call site the file actually has: ``` callSites = every `emitJson` call in SRC, matched with a word boundary, optional whitespace, then the opening paren expect(literals).toHaveLength(callSites.length) ``` Both sides read raw source, which keeps them symmetric: a commented-out `await emitJson(` is counted by the extractor and by the pattern alike. The one asymmetric case — prose naming the call with its paren but no `await` — reddens, and that is the accepted price for not importing a comment masker into these two files. Nothing is skipped, disabled, quarantined or deleted. The count assertion is still there, the success/failure partition is still there, the contract negative is still there: - `toHaveLength(6)` → `toBeGreaterThanOrEqual(6)`. The **one integer left**, demoted from an equality to a floor. Six is the population the objectstack-ai#12047 / objectstack-ai#12125 rulings were made over, not a count of today; it rots only in the direction that has to be reviewed anyway (exits being *removed*), and it floors the derived pair away from the single vacuum they share — an `emitJson` renamed out of existence takes both sides to zero and every assertion here with them. **I write no `7` anywhere in this diff.** - `valid: false` × 5 → `literals.length - 1`, beside `valid: true` × 1. Derived, and the two together now also assert the PARTITION: an exit carrying neither literal reddens, which the two frozen integers never checked. ## The tier, measured in BOTH directions Not inferred from the workflow YAML — collected, on this branch, in `packages/cli`: | `OS_TEST_TIERS` | files vitest collects | these two pins among them | |:--|--:|:--| | unset (pull request · merge queue · local default) | 266 | **0** | | `nightly` | 68 | **2** — both, in the `integration` project | ⇒ the pins are not "a red nobody looks at". In the queue population they are **not collected at all**. No pull request and no merge-queue run could have reddened on this, which is why objectstack-ai#18769's own CI was green and correct. That is a measurement for objectstack-ai#18520, not a new card — it is in the report for the seat. ## Red → green, in the nightly tier itself Not a substitute: the tier the pins actually live in, reached locally with `OS_TEST_TIERS=nightly`. **Before** (at `b0b5f31cc6`, pre-fix): ``` FAIL |integration| test/validate-json-failure-conversions.e2e.test.ts AssertionError: the `emitJson` exit count moved — a new exit must carry `conversions` too: expected [ …(7) ] to have a length of 6 but got 7 FAIL |integration| test/validate-json-failure-warnings.e2e.test.ts AssertionError: … expected [ …(7) ] to have a length of 6 but got 7 Test Files 2 failed (2) Tests 2 failed | 5 passed | 16 skipped (23) ``` This is also the **first runner-level reproduction** of this card: the filing seat stated it had no install and relayed the PR run's result instead. **After:** ``` Test Files 4 passed (4) Tests 14 passed | 36 skipped (50) ``` Four files, because the run also carries the two `build-json-failure-*` siblings — see acceptance notes. ## Ablation — four legs, mutation proven on disk, restore proven byte-exact Run from the **committed** fix. The mutation is confined to my own two test files (the `SRC` seed), driven by env var; `validate.ts` is never written. `trap … EXIT INT TERM`, absolute paths, and the restore is verified by `git diff HEAD` empty **and** `git hash-object` equal to the HEAD blob hash (non-empty, both files). | leg | mutation | expected | observed | |:--|:--|:--|:--| | **A** | an 8th exit, `await`, carrying both keys — an *honest addition* | green | **green** — 2 files, 7 passed | | **B** | an 8th exit missing `warnings:` | warnings red, conversions green | **exactly that** — `expected [ Array(1) ] to deeply equal []` | | **C** | an 8th call site spelled **without** `await` | derived equality red | **both red** — ``an `emitJson(` call site the payload extractor could not read: expected [ …(7) ] to have a length of 8 but got 7`` | | **D** | source truncated to 5 exits | floor red | **both red** — `expected 5 to be greater than or equal to 6` | Leg **A** is the fix doing its job: a hard-coded `7` is red here. Leg **C** is the capability the rejected option does not have at all — the extractor reports 7, so `toHaveLength(7)` passes while the file carries an exit no assertion covers. Leg **B** proves deriving the count weakened nothing: the contract still bites over the *added* exit, and the two files isolate from each other. Leg **D** proves the remaining integer is live. A first ablation attempt exited 127 on a wrong vitest path — the mutation landed but nothing ran, so those readings were void and discarded rather than reported. The table above is the re-run. ## Changeset `skip-changeset`. Measured, not assumed — nothing published moves: - `packages/cli` `files[]` is `["dist", "README.md", "CHANGELOG.md"]`; `tsconfig.build.json` has `include: ["src"]`, so `test/` is outside the compiled tree entirely. - `npm pack --dry-run --json`: **0** published paths under `test/` or `src/`. Instrument lit by the positive control `README.md`, which is present. -⚠️ The `dist/` positive control was **not lit** in the worktree where that manifest was taken (no build yet at that point). The `include: ["src"]` boundary is what carries the negative, and the closure build reading is in the report. ## Acceptance notes — noted, not filed - **The same rotting shape sits on two more pins, green today.** `test/build-json-failure-warnings.e2e.test.ts` and `test/build-json-failure-conversions.e2e.test.ts` pin `toHaveLength(11)` / `10` / `1` over `compile.ts`. Measured: `compile.ts` has 11 literals, 10 `success: false`, 1 `success: true` — **green right now**, and one honest exit away from repeating this exact p1, again only at night. ⛔ Deliberately **not** touched here: `compile.ts` is a objectstack-ai#18779 carrier and is fenced for this card, and a p1 with a cron clock is not the place to double the review surface. Offered to objectstack-ai#18520 as a measurement rather than as a new card. - **`validate.ts:661` carries a comment that the seventh exit made false** — it says the ordering site is read by "every one of the six exits" and that "a seventh exit cannot be added with a different member order". A doc nit inside a read-only file; carrier: the next PR that touches `validate.ts`. ## An instruction conflict, named rather than quietly resolved Triage comment `5723031597` pinned the dispatch order: land the two-line literal repair first, and take the "does an integer pin belong here at all" question as a second, separate stroke. The claim comment `5724808732` instead hands the choice to the deliverer and says ⛔ not to reflexively hard-code `7`. I took the claim's instruction, because the thing triage was protecting against is not in play: the derived form is the *same edit in the same assertion slot*, measured and proven in this run, and it delays the red `main` by nothing. The design question triage deferred — whether these files belong in the core tier, or the tier's path filter should name `validate.ts` — is untouched here and remains the cli lane's call. ## Verification `OS_TEST_TIERS` measured in both directions · red→green in the nightly tier · four ablation legs · gate families derived with `scripts/pm/dispatch-gates.mjs` and reconciled with `--ran`. Full readings, including anything that came back NOT MEASURED, are in the `os-dev-report` comment on objectstack-ai#18848. --- _Generated by [Claude Code](https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3)_ Co-authored-by: Claude <noreply@anthropic.com>
…llection index, so an echo no longer survives it (objectstack-ai#18878) Fixes objectstack-ai#18779 ## The card's central reading, reproduced first Measured on `origin/main` `a43b9d0654` over the repo's own two-package fixture `examples/app-multi-package`, `os build --json` exiting 0: ``` warnings: 4 [0] field-no-consumers object "crm_order" · field "account" objects[0].fields.account [1] field-no-consumers object "crm_order" · field "amount" objects[0].fields.amount [2] field-no-consumers object "crm_account" · field "industry" objects[1].fields.industry [3] field-no-consumers package 'com.example.multi.core' — object "crm_account" · field "industry" objects[0].fields.industry ``` `[3]` is `[2]`, re-reported at the package-local index. Same rule, same entity, same message; the only difference is the top-level collection index, which is the one coordinate `findingKey` had no business comparing. **1 survivor, 1 echo, 0 genuinely new** — the card's reading holds, and the pass's strongest available value statement was carried entirely by a duplicate. ## Which option I took, and what the rejected one would have cost I fixed **the key**, and the comments with it. Fixing only the comments was the cheaper option and it was rejected on a measurement, not a preference: the de-duplication exists so that "the author cannot tell a real per-package finding from an echo" would stop being true, and on the one fixture anyone can check, it was still true. Correcting the prose would have left `os build` printing `4 author-time warning(s)` for 3 distinct ones, and left the repo with an accurate comment describing a filter that does not filter. The cost of the option I took is that `os build` / `os validate` / `os lint` report one fewer line on such a project, which is an observable change and is why this carries a changeset. Triage settled the blocker: the "`os build` output stays byte-identical" constraint was objectstack-ai#18769's **PR contract, not a product contract**, and its scope ended with that PR. ## Measured: exactly what stops being reported Same fixture, all three doors, the key reverted and restored in place (on-disk blob hash asserted both ways, tree verified clean after): | | before | after | |---|---|---| | `os build --json` | warnings 4, exit 0 | warnings 3, exit 0 | | `os validate --json` | warnings 4, exit 0 | warnings 3, exit 0 | | `os lint --json` | total 4, failing 0, exit 0 | total 3, failing 0, exit 0 | | `os lint --json --strict` | total 4, failing 4, exit 1 | total 3, failing 3, exit 1 | The one line that stops being reported is the echo above. **No input's verdict moves**, and that is structural rather than a property of this fixture: every finding the de-duplication drops has, by construction, a finding with the same key already in the reported set — the seed is the union run's findings, which every door reports, and it grows only with per-package findings that themselves survived. `os build` already exits 1 on a union error *before* this pass runs, and `os lint --strict` fails on `errors + warnings`, a count that could only reach zero if the twin went unreported too. ## Clause-② Clause-②: no Derived from the measured diff, not inherited. The dispatching claim left this blank deliberately and expected `yes (widening)` on the reasoning "fewer findings reported ⇒ `--strict` refuses less". Measured, `--strict` does **not** refuse less: `failing` drops 4 to 3 and the verdict stays `exit 1`, because the dropped line's twin is still counted. No accept set moves in either direction, and the diff adds no schema key, closed-set member, published export or registry entry. Declaration carrier: `Clause-②-correction: 5723810598` on the card. ## The falsified sentence — all carriers, re-derived `git grep "set the union could not see"` on `a43b9d0654` finds more than the four the dispatch named. Source **and** tests, all corrected here: | file | treatment | |---|---| | `packages/cli/src/commands/compile.ts` | claim corrected, quote kept | | `packages/cli/src/commands/lint.ts` | claim corrected, quote kept | | `packages/cli/src/commands/validate.ts` | claim corrected, quote kept | | `packages/cli/src/utils/artifact-packages.ts` | claim corrected at the definition | | `packages/cli/test/lint-per-package-authoring-parity.test.ts` | claim corrected | | `packages/cli/test/lint-per-package-authoring-seam.test.ts` | claim corrected | | `packages/cli/test/validate-per-package-authoring-parity.test.ts` | claim corrected | | `packages/cli/test/validate-per-package-authoring-seam.test.ts` | claim corrected | Each keeps the sentence as a **quotation being corrected** rather than deleting it, so the next reader meets the correction where they would have met the claim. ## TWO pins were being held up by the echo Both are the same shape and both were repaired the same way — by giving the fixture a survivor the union genuinely cannot see, never by relaxing an assertion. **1. `validate-per-package-authoring-parity.test.ts`** (objectstack-ai#18677). Its non-vacuity case went red: the planted fixture's only per-package survivor was the echo, so filtering it left the parity cases comparing two empty sets. **2. `build-text-face-advisory-count.test.ts`** (objectstack-ai#18780) — found by CI, not locally, and the local gap was mine: `packages/cli`'s `test` script is a bare `vitest run` with **no `--project` filter**, so CI runs both projects, while I had run `--project unit` plus only the two integration files this diff touches. This file is in the integration project and was never collected. Its lit control asserts the fixture reaches the per-package pass and leaves a survivor: ``` AssertionError: expected 0 to be greater than 0 test/build-text-face-advisory-count.test.ts:239 > the fixture reaches the per-package pass and raises a survivor there ``` `bc_account.industry` had no consumer anywhere, so the union raised it too and the "survivor" was that finding re-reported at the package-local index. `orders` now owns the view that displays it. ⛔ `toBeGreaterThan(0)` is untouched — it is what stops objectstack-ai#18780's equality pins from going vacuous. Both preconditions now additionally assert the survivor's **pedigree** (the field is named only behind the per-package prefix, and no union finding names it), so neither control can be silently re-lit by a duplicate. The `warnings: 4` reading in objectstack-ai#18780's header is kept as the record of the defect it measured, with a note that the same fixture reports 3 since this change. ## What the key does not buy, measured rather than quoted The key becomes position-insensitive, **not** collision-proof: two entries that render the same `where` still share a key, exactly as they already did whenever their indices happened to match. That residue is measured, not sized by citing a neighbouring pin — which is the move this card exists to correct. `packages/lint/src/data-model-rule-where-slot.test.ts` holds something narrower than "every rule names its entity in `where`": it fails any rule that puts a **bare config path** in `where`. Measured instead over every example stack in this repo that parses today (`app-multi-package`'s built artifact, `app-crm`, `app-showcase`, `app-todo`): 45 registry rules, 103 findings, **103 distinct neutralised keys, 0 collisions**, on `a43b9d0654`. ## Verification - **Pin red before / green after.** `test/per-package-dedup-positional-echo.test.ts`, key reverted to base in place: `2 failed | 4 passed` (the ECHO and REAL_UNION cases). Key restored: `6 passed`. On-disk mutation proved by blob hash both ways; the test imports the mutated module through a relative source specifier, so no build sits between mutation and assertion. - **The control can fail.** An ablation widening the rewrite from the top-level index to *every* index turns exactly one case red — the NESTED control. The first draft of that control was built on a `field-no-consumers` twin and stayed **green** under the same ablation, so the fixture now carries a bare `unique: true` index to give the control a finding whose path really has a nested index. A control that cannot fail is decoration. - **Tier measured in both directions**, from the predicate rather than the filename: the new pin fires no integration signal and is absent from the derived integration population — UNIT tier, asserted by the file's own last case. - **Changeset decided by measuring the built `dist`**, with controls both ways: the rewritten key is present in `packages/cli/dist/utils/artifact-packages.js` and `files[]` ships `dist`; positive control `runPerPackageAuthoringRules` present; negative control (a test-only symbol) 0 hits. Published ⇒ changeset, graded `patch`. - `dispatch-gates.mjs`: **61 derived families, 61 run, 0 NOT-MEASURED, 0 UNRUN** (exit codes recorded, none is 3). - `pnpm lint` (`eslint . --no-inline-config`, whole repo, not narrowed): **exit 0** at `6a0a4df1b4`. - `pnpm --filter @objectstack/cli test` run **WHOLE** — no `--project`, no file list, exactly what CI runs: **267 files / 3485 tests passed, exit 0**. The same command at `15cc0db8d4` reproduced CI's red first: `3 failed | 264 passed (267)`, of which the one real assertion was objectstack-ai#18780's lit control (the other two were this worktree lacking `packages/cli/dist`, which that pin refuses by name; cleared by building the package). - `pnpm --filter @objectstack/cli typecheck`: exit 0. `check:dual-build-cjs-loads` first returned exit 3 — its own text says "This is NOT a pass: nothing was measured", 8 packages had no `dist` in this worktree. I built them and re-ran it: exit 0. ## Acceptance notes - **Two pending changesets still assert the falsified sentence** and are **not** touched here: `.changeset/18677-validate-per-package-authoring-pass.md` and `.changeset/18778-lint-per-package-authoring-pass.md`. `check:empty-changeset` refuses a PR that modifies a changeset present on the merge base, and names this exact situation as its DELIBERATE CORRECTION class, whose remedy is "do NOT restore it; get it confirmed on the PR". That confirmation is the reviewing seat's to give, so the call is surfaced here rather than taken. The correction itself is published in this PR's own changeset, which lands in the same release. Note also that objectstack-ai#18677's changeset says "After: both report 4", which this change makes read 3. - Derivation ran against a tree behind `origin/main` (the derivation's own staleness warning named `scripts/gen-sdui-manifest-node.mjs` among others); CI judges the merge. - Noted, not filed: `bin/run-dev.js` needs `TSX_TSCONFIG_PATH` pinned when invoked from an example directory, and says so itself in a good refusal — a working command, not a defect. Authored by Claude Code in session `session_01DvvamiacK328idtBYJBxV3` (durable attribution kept in prose: the body-edit channel appends its own footer block, so a footer sent here would be stored twice). --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…validate --strict` narrowing an at-tier review exhibited (objectstack-ai#18867) Fixes objectstack-ai#18823 Clause-②: no `.changeset/18677-validate-per-package-authoring-pass.md` — PR objectstack-ai#18769's still-pending, still-unreleased entry — declined its `**BREAKING**` banner on the strength of a negative, verbatim: *"⛔ **not** declared breaking, because the narrowing could not be exhibited and is bounded by an existing gate"*, alongside *"**No newly-refused input could be exhibited on any fixture**"*. The negative is false. This PR edits that one file: it adds the banner, the measured table, the mechanism that explains why the union fold cannot see the finding, and the ADR-0087 disposition the banner owes — and it narrows the sentence an upgrader would have been misled by. ⛔ A forward edit to an **unpublished** file. ⛔ Not a rewrite of landed history, and ⛔ not a ruling on objectstack-ai#18677's landed `Clause-②: no` — see "Boundaries" below. ## 1. The clock, re-confirmed at this branch's base `be7aeb8275` — ⛔ not inherited The card is `priority:p1` because the entry is unconsumed. Three readings, all taken here: | reading | value | |:--|:--| | the file on `origin/main` | present (`git ls-tree`) | | `npm view @objectstack/cli version` | **17.4.0** | | `packages/cli/package.json` version on `origin/main` | **17.4.0** — equal, so no release has bumped it | | the entry's own distinctive sentence in `packages/cli/CHANGELOG.md` | **0** hits | ⇒ unconsumed. Amending it now costs a diff; amending it after the release that consumes it costs a published version number that cannot be recalled. ## 2. ⭐ The reviewer's table, REPRODUCED here — ⛔ not copied The measurement is PR objectstack-ai#18813's isolated at-tier contract review (record `5722342660`, finding **F2**). The card's first instruction is to reproduce it rather than build on it. Driven in this worktree, on the `CONFIG_FLIP` two-package fixture planted verbatim from `packages/cli/test/lint-per-package-authoring-parity.test.ts` (lines 115-166, sha256 `d5fb835ac5…`), through `packages/cli/bin/run-dev.js` with tsx: | `os validate --json --strict` on `CONFIG_FLIP` | exit | warnings | |:--|:--|:--| | `packages/cli/src/commands/validate.ts` restored to its pre-objectstack-ai#18769 blob `bafa54b07f` | **0** | 0 | | at head (blob `340cec6253`) | **1** | 1 | **It reproduces.** The one warning is `field-no-consumers` at `package 'com.example.ppflip.core' — object "pp_account" · field "industry"`. Ablation hygiene, because a mutation that never reached disk reads exactly like a clean run: - the mutation was proven on disk before the CLI was driven — `git hash-object` on the file returned `bafa54b07f…`, equal to the target blob, and the marker count `runPerPackageAuthoringRules` moved **3 → 0** in that file; - restore is `git checkout HEAD -- packages/cli/src/commands/validate.ts` from a `trap … EXIT INT TERM` with an absolute path, verified by hash equality back to `340cec6253…` **and** by `git diff HEAD` being empty, not by an exit code; - `git status --porcelain` is empty after the run. ⛔ No landed code is modified by this PR — the ablation is a one-off measurement, and `validate.ts` is untouched in the diff. Extra reading this PR took that the record did not, and the changeset now states: the **default (non-strict)** face of `os validate --json` on the same fixture at head is **exit 0 with 1 warning**. So on this fixture only the `--strict` door moved. ## 3. What the file now says - `**BREAKING**` banner naming the door that moved: `os validate --strict` can now fail a project it passed before. - The before/after table above, plus the named finding and the fact that `os build` already reports it — so the narrowing is still bounded by the command that ships. - The remedy an upgrader owes: drop `--strict` to keep the old verdict, or fix what the per-package pass reports. - ⭐ The mechanism, which is what replaces the false negative: `packageBodyAsStack` hands each package the artifact's whole `packages[]` as **resolution context**, so a cross-package *reference* still resolves and the reference-integrity rules stay quiet — but a **reachability** rule asks what the *stack* reads, and per package the stack is that one package's own body. A field whose only consumer lives in a sibling package is live to the union run and inert to the per-package run. That is the shape the earlier fixtures could not produce, and it is why "could not be exhibited" was a statement about the fixtures rather than about the property. - An `adr-0087:` disposition marker, in the HTML-comment form the gate reads, claiming `not-required (no-migration-prescription)`: nothing an author writes changes, so `objectstack migrate meta` has nothing to rewrite. - The sentence "nothing that builds today stops validating" is removed. It was true of the default face and false of `--strict`, and it is the sentence the card names as the one that would mislead an upgrader. - Level is untouched at `minor`. ⛔ Nothing here asks for `major`; the launch window refuses it and the banner plus the disposition are what carry breaking-ness. ## 4. This PR's own changeset — MEASURED, with controls both ways The question "does a PR that only edits a changeset file publish anything?" was answered by running `changeset version` in a throwaway comparison worktree at this branch's base and reading `packages/cli/CHANGELOG.md`, which `packages/cli`'s `files[]` ships (`["dist","README.md","CHANGELOG.md"]`). Four legs: | leg | resulting `@objectstack/cli` version | `packages/cli/CHANGELOG.md` | |:--|:--|:--| | base, untouched | 17.5.0 | sha256 `d132f18a05…` | | **negative control** — base + an edit to a file no package ships (`scripts/check-adr-0087-registration.mjs`) | 17.5.0 | **byte-identical** to base | | **this PR** — base + the changeset amendment only | 17.5.0 | sha256 `10ccd96910…`, 20 diff lines, **all inside the entry objectstack-ai#18677 already schedules** | | **positive control** — base + a NEW changeset (`'@objectstack/cli': patch`) | 17.5.0 | one **new bullet** appears: a release entry of its own | ⇒ Two facts, and they point in different directions, so both are stated: 1. This PR **does** move bytes that ship. ⛔ It is not true that editing a changeset publishes nothing. 2. This PR **declares no release of its own** — no new entry, no version movement — which is exactly the wording the `changeset-check` job uses for the `skip-changeset` exemption, and it is what the positive control above makes visible rather than assumed. ⇒ **Route taken: `skip-changeset`.** Of the three routes the `Check Changeset` log itself names, route 3 (an empty-frontmatter changeset) is closed — newly added ones are rejected (objectstack-ai#5471) because an all-empty set makes `changesets/action` return green while publishing nothing (objectstack-ai#4898). Between the other two, the positive control above is what decides it: adding a real changeset would add **one new published CHANGELOG bullet** — a user-facing release note announcing a correction to the release note directly above it — while moving no version. This PR amends the prose of a bump that is **already declared**; it adds none. That is the exemption's own wording.⚠️ **The label is not on this PR yet.** `node scripts/pm/label-write.mjs --issue 18867 --repo objectstack-ai/objectstack --add skip-changeset` was refused by this session's local permission classifier (reason: `[CI Bypass]`) before any request was issued — ⛔ not by GitHub, and ⛔ no status code was reached. This dev did ⛔ not route around that refusal through a second channel. **The measurement is above and the route is declared; applying the label is left to the dispatching seat.** Until it is applied, the `Check Changeset` red below stands. ## 5.⚠️ The pending-note correction still needs a person's word — ⛔ and the red on this PR is NOT the gate that asks for it Run locally, `node scripts/check-empty-changeset.mjs --base origin/main` exits **1** here, naming this file and this class: > DELIBERATE CORRECTION -- your change may have made this PENDING release note false, and you rewrote it in the same stroke. Remedy: do NOT restore it -- say so on the PR and get it confirmed; restoring it from the base would put the false sentence back. and closing: > Correcting a pending release note is a decision about a release rather than a refactor -- say so on the PR, naming the note and what changed under it, and get it confirmed. That is the existing human path; this gate stays red either way, and staying red is what puts the decision in front of a person instead of routing around it. **So, saying it, as the gate asks:** - **The note:** `.changeset/18677-validate-per-package-authoring-pass.md`, PR objectstack-ai#18769's, pending and unreleased. - **What changed under it:** ⛔ nothing in the code. What changed is the **evidence**: a fixture that did not exist when that note was written (`CONFIG_FLIP`, shipped by PR objectstack-ai#18813) exhibits the newly-refused input the note declared unexhibitable. The note's runtime claims are otherwise untouched and undisputed. - **What is asked:** confirmation that this pending release note may be corrected in place. This PR stays **draft** until then.⚠️ **A correction to an earlier reading in this very PR, stated rather than quietly dropped.** This section first argued that `skip-changeset` must be withheld so the refusal above would stay red on CI and summon a person. **Measured on this PR's own failing run** (job 105457719184, step list read from the Actions API, ⛔ not inferred from the check name), that argument is false: | step | outcome | |:--|:--| | 11 · Require a changeset (or the skip-changeset label) | **failure** | | 12 · Reject an empty-frontmatter changeset added by this PR — where `check-empty-changeset --base` runs | **skipped** | | 13 · Require an ADR-0087 disposition on a declared-breaking changeset | **skipped** | | 14-15 · allow-major re-read, major guard | **skipped** | Step 11 short-circuits the job, so the foreign-changeset refusal **never executes on CI at all** — labelled or not. Withholding the label therefore hides nothing and reveals nothing; what it does instead is leave a *misleading* headline red ("This PR adds no changeset ... run `pnpm changeset`") on a PR that correctly adds none. ⇒ the refusal's "say so on the PR and get it confirmed" is a **prose-and-person** requirement, discharged by this section, ⛔ not by a CI red that does not happen.⚠️ **What the label costs, so nobody reads a green board as more than it is:** with `skip-changeset` on, the whole `changeset-check` job is exempt, so steps 12 and 13 do not run here either. Both were run locally at `1056c00195`: `check-empty-changeset --base origin/main` exit **1** (by design, the refusal quoted above) and `check-adr-0087-registration --base origin/main` exit **0**, reading `.changeset/18677-…md [BREAKING] not-required (no-migration-prescription)`. The live ADR-0087 check also runs over the whole pending stock at RC-cut time (`cut-rc.yml`, `--base $SNAPSHOT_SHA`), so the disposition is still checked before any release consumes this entry — just not on this PR.⚠️ For context, the two landed precedents for this act — objectstack-ai#18126 (the same repair, BREAKING banner + ADR-0087 disposition onto a pending entry) and objectstack-ai#17851 — both carried `skip-changeset`. Measured, not recalled: the foreign-changeset refusal landed in objectstack-ai#18146 at `0ffb4963e5` **2026-09-14T06:56:58Z** and objectstack-ai#18126 merged at `f3b41e87d4` **2026-09-14T04:04:11Z**; `git merge-base --is-ancestor 0ffb496 f3b41e8` exits **1**, with a control leg (`f3b41e87d4^` against `f3b41e87d4`) at exit **0** on a non-shallow checkout. So the refusal post-dates both by about three hours and ⛔ neither is precedent for it — which is exactly why the reading above was measured on this PR rather than borrowed from them. ## 6. Verification | what | result | |:--|:--| | `node scripts/check-adr-0087-registration.mjs --base origin/main` | **exit 0** — `.changeset/18677-…md [BREAKING] not-required (no-migration-prescription)` | | `node scripts/check-changeset-no-major.mjs --base origin/main` | exit 0 | | `node scripts/check-empty-changeset.mjs --base origin/main` | **exit 1 — by design, see §5; it does not run on this PR's CI, labelled or not** | | the other 15 commands from `scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (self-tests, `check:nul-bytes`, `check:published-files`, `check:objectui-changeset`, `check:pm-changeset-deadline-census`, …) | all **exit 0** | | `pnpm lint` — the whole repo, `eslint . --no-inline-config`, ⛔ not narrowed | **exit 0** at `1056c00195` | | control characters | `grep -naP` over the changed file for `[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]`: no hits | `dispatch-gates.mjs` does not name the `pnpm lint` family; it was run anyway, unnarrowed, so no narrowing argument is owed. Its own provenance line reads `objectstack-ai/objectstack` at `1056c00195`, and `--repo` was asserted and held. No package test or typecheck is owed: the diff touches no package source, no `exports`, no spec contract and no built artefact. `packages/cli`'s dependency closure was built only to drive the CLI for §2. ## Boundaries — what this PR deliberately does not do - ⛔ **It does not touch `validate.ts` or any landed code.** The diff is one file. - ⛔ **It adds no `Clause-②:` line to the changeset body**, though the sibling `.changeset/18778-lint-per-package-authoring-pass.md` carries one. Writing `yes (narrowing)` into objectstack-ai#18677's note would be a retro-correction of a landed declaration, and the card marks that "Not asserted" and routes it above this seat. The banner and the ADR-0087 disposition are release-facing and are what the card prescribes; the governance declaration is not. - ⛔ **It does not rule on what a landed `yes (narrowing)` that shipped declared `no` owes beyond this file**, nor on whether objectstack-ai#18677's mandatory contract review is now owed. That is the maintainer's. - ⛔ It does not touch `content/docs/releases/`, any `packages/*/CHANGELOG.md`, `packages/cli/src/commands/compile.ts`, `packages/qa/vitest-filter-preflight/**` or `packages/cli/vitest.config.ts`. ## Acceptance notes - **Noted, not filed:** `.changeset/18778-lint-per-package-authoring-pass.md` carries its `Clause-②: yes (narrowing)` line inside the changeset body, i.e. in text that ships verbatim into the published CHANGELOG. Whether that governance token belongs in a user-facing release note is a question about changeset convention, not a defect: no gate reads it there, nothing is falsified by it, and it is out of this card's one-file surface. Carrier: the next PR to touch changeset conventions; no PR is in flight on it. - **Noted, not filed:** the ⭐ generalization this card turns on — *a property that could not be exhibited with the fixtures on hand is UNEXHIBITED, ⛔ not absent* — is currently recorded only in card prose (objectstack-ai#18823, and triage `5722459190` which asks for it on the discriminant list). It has no home in `AGENTS.md` or any gate. Placing it is a governed-surface edit and so is not this PR's to make. Carrier: the triage seat that asked for it. Authored by Claude Code in session `session_01DvvamiacK328idtBYJBxV3`; the branch is `claude/issue-18823-pending-changeset-breaking-banner`. (Attribution is stated here in prose on purpose: this body is edited through a channel measured to store only a bare footer, which carries no session id.) --- _Generated by [Claude Code](https://claude.ai/code)_ Co-authored-by: Claude <noreply@anthropic.com>
…objectstack-ai#18677 / objectstack-ai#18778 pending changesets (objectstack-ai#19531) Fixes objectstack-ai#19245 Clause-②: no Two **pending, unreleased** changesets each asserted the sentence `packages/cli`'s own source now explicitly forbids restating, and each attributed it to that source. A changeset body ships verbatim into `packages/cli/CHANGELOG.md`, so this correction costs a diff today and a published falsehood after the release that consumes them. **Prose only** — two `.changeset/*.md` files, no code path changes, no export, no key, no accept set moved. **The two shas this diff is actually between:** merge base `eec56c37dfc89086658c1659bb2da869dfa08d4f` (the tip of `origin/main` at branch time *and* at every measurement below) → head `2babd1876786eb135509df70ab4fc2ee0113a7f3`. Verified with `git merge-base origin/main HEAD`, not assumed from the branch point. ## 1. The settled bound, read at the head this branch points at ⛔ No fourth phrasing was invented. Both replacement sentences are the tree's own, read from the two docblocks the card names: `packages/cli/src/commands/compile.ts:465-470` > `findingKey` now neutralises the top-level collection index, so what survives is the set of per-package findings no union finding already carried under the same rule, `where`, message and non-top-level position. ⛔ Do not re-inflate that to "exactly the set the union could not see" — `utils/artifact-packages.ts` states the bound and why it is narrower than that sentence. `packages/cli/src/utils/artifact-packages.ts:237-244` > What reaches the lists below is therefore the set of per-package findings whose `rule`, `where`, `message` and NON-top-level position no union finding already carried.⚠️ That is the whole claim, and it is deliberately narrower than "exactly the set the union could not see" — ⛔ do not restate it as that sentence. Two entries rendering the same `where` still collapse (see `findingKey`) … Both halves are carried into both entries: the **bound**, and the **reason it is narrower** (two entries rendering the same `where` still collapse). An amended note that stated the bound and dropped the caveat would be the same defect one notch smaller, so neither entry drops it.⚠️ Deliberately, **neither replacement reproduces the retired sentence verbatim**, not even as the "narrower than" contrast the source uses. The source can quote it because the source is the thing that prohibits it; a release note quoting it would put the sentence back into `CHANGELOG.md`, which is the entire cost this card exists to avoid — and it would leave the population sweep in §3 reading 3 again. ## 2. What each entry now says | file | before | after | |:--|:--|:--| | `.changeset/18677-validate-per-package-authoring-pass.md:7` | "By `compile.ts`' own description the survivors of that second pass are «exactly the set the union could not see», so that whole set was findings `os build` reported and `os validate` **structurally could not**." | "By `compile.ts`' own description the survivors of that second pass are the per-package findings no union finding already carried under the same rule, `where`, message and non-top-level position — deliberately narrower than everything the union run missed, because two entries rendering the same `where` still collapse. That whole set was findings `os build` reported and `os validate` **structurally could not**." | | `.changeset/18778-lint-per-package-authoring-pass.md:10-13` | "every finding that pass produces — «exactly the set the union could not see», in the build command's own words — was reported by the command that ships and invisible on the fastest of the three doors." | "every finding that pass produces — in the build command's own words, the per-package findings no union finding already carried under the same rule, `where`, message and non-top-level position — was reported by the command that ships and invisible on the fastest of the three doors. That bound is deliberately narrower than everything the union run missed: two entries rendering the same `where` still collapse." | Both attributions are now **accurate**: `compile.ts` does say the replacement, in those words. The downstream conclusion each entry draws is untouched and still holds — a survivor is by construction something the union run did not report, so it remains a finding `os build` reported and the other door structurally could not. Only the **size** claimed for that set moves, which is exactly the correction objectstack-ai#18779 landed in the code and did not reach these two notes. ⭐ **One correction, not two.** The two entries needed the same substantive change; only the sentence surgery differed, because one file is unwrapped prose and the other is hard-wrapped at 80 columns. Each file's own wrapping convention is preserved. ⭐ **The `**BREAKING**` section PR objectstack-ai#18867 just added to `18677-…md` is not touched.** The assertion sits at `:7`; that banner and its table occupy `:22-29`. `git diff` shows one changed line in that file. ## 3. The sweep over the whole `.changeset/` population, with lit controls⚠️ **The first instrument was wrong and is reported rather than quietly replaced.** Its normaliser collapsed `\s+` only. That is enough for markdown, but inside a block comment the sentence's line wrap carries a `*` continuation marker, so a bare `\s+` bridge does **not** join `"exactly the` to `set the union could not see"`. On the tree scan in §4 it silently undercounted **13 → 11**. The corrected normaliser strips each line's comment-continuation prefix (`*`, `//`, `#`) *before* collapsing whitespace. Every number below is from the corrected instrument; both are kept in the report. Whole population, whitespace-normalised, case-insensitive, **every hit opened**: | run | population | lit control | needle | |:--|:--|:--|:--| | `.changeset/` @ `origin/main` `eec56c37df` (before) | **554** files | superset "the union could not see" → **3** occurrences, INSTRUMENT LIT | **3** | | `.changeset/` @ head `2babd18767` (after) | **554** files | superset → **1**, INSTRUMENT LIT | **1** | The three before, each opened, ⛔ not counted: 1. `18677-…md:7` — assertion, attributed to `compile.ts` → **corrected here** 2. `18778-…md:11` — assertion, attributed to the build command → **corrected here** 3. `18779-…md:52` — *"Also corrected: the sentence «…», **which was false** for as long as the key was positional"* → **quoted correction, untouched** The one after is row 3. ✅ **The only surviving hit in the whole population is `18779`'s quoted correction**, which is what the claim comment asked to be shown rather than trusted.⚠️ A note on the population figure: the card recorded **482** changesets at `847e5773a`. At `eec56c37df` it is **554**. The tree moved; the count is re-measured here, ⛔ not inherited. The needle count is unchanged at 3, which is the number that matters.⚠️ A second lit control was run on the before-sweep and is recorded because a zero is not a reading until a control hits: the distinctive string `position-insensitive, not collision-proof` returned exactly **1** file (`18779-…md`), confirming the loop was reading real bytes at real paths — the failure mode the card records (a loop that double-prefixed `.changeset/` and returned a wholly convincing `0`). ## 4. `.changeset/18779-…md` is untouched, and this PR makes its completeness claim true `18779-…md:55-56` claims the sentence *"is now stated at the bound the pass can actually hold, **in every file that carried it**"*. That claim was **false on `origin/main`**, falsified by the two rows above. It is true at this head. Measured over the **whole tree**, not just `.changeset/` — 9128 tracked text files at head, corrected normaliser, lit control 15 occurrences of the superset in 10 files: **13 occurrences in 10 files, every one opened and classified. Zero are assertions.** | site | class | |:--|:--| | `.changeset/18779-per-package-dedup-positional-key.md` | quoted correction | | `packages/cli/src/commands/compile.ts` ×2 | 1 quoted correction ("used to end … and that was FALSE"), 1 **prohibition** ("⛔ Do not re-inflate that to") | | `packages/cli/src/utils/artifact-packages.ts` ×3 | 1 "narrower than" contrast (`:101`), 1 historical account that names **objectstack-ai#18677 and objectstack-ai#18778 by number** as the two that quoted it, 1 **prohibition** ("⛔ do not restate it as that sentence") | | `packages/cli/src/commands/lint.ts` | quoted correction | | `packages/cli/src/commands/validate.ts` | quoted correction | | `packages/cli/test/{lint,validate}-per-package-authoring-{parity,seam}.test.ts` ×4 | quoted corrections | | `packages/cli/test/per-package-dedup-positional-echo.test.ts` | quoted correction | ⇒ Nothing in the tree still **asserts** the sentence. Editing `18779-…md` would be a no-op that spends a third file on the serial. ⛔ Left alone, exactly as ordered. ## 5. ⭐ The written confirmation `Check Changeset` route 0 asks for — and why that check stays red `node scripts/check-empty-changeset.mjs --base origin/main` exits **1** here, and that is **by design**, ⛔ not a defect and ⛔ not a finding about the gate. The route-0 discriminator `.github/workflows/pr-automation.yml` prescribes was run rather than reasoned about: ``` $ git merge-base origin/main HEAD eec56c3 $ git diff --name-status eec56c3 HEAD -- '.changeset/*.md' M .changeset/18677-validate-per-package-authoring-pass.md M .changeset/18778-lint-per-package-authoring-pass.md ``` Every row is `M`, none is `A` ⇒ the **DELIBERATE CORRECTION** class. The workflow's own instruction for it, verbatim: > -> do NOT apply 'skip-changeset'. Write the confirmation on the PR -- name the note and what changed under it, and get it confirmed there in writing -- and LEAVE THIS CHECK RED. > … Ruled on objectstack-ai#18375 (ruling D, maintainer 2026-09-18): the 'skip-changeset' label is never applied to a PR that edits an existing changeset. **So, saying it, as the gate asks:** - **The notes:** `.changeset/18677-validate-per-package-authoring-pass.md` (PR objectstack-ai#18769's, amended by PR objectstack-ai#18867) and `.changeset/18778-lint-per-package-authoring-pass.md` (PR objectstack-ai#18778's). Both pending, both unreleased. - **What changed under them:** ⛔ nothing in this PR's code — this PR contains none. What changed under them earlier is **objectstack-ai#18779**, which neutralised the top-level collection index in `findingKey` and in the same stroke retired the sentence both notes quote. Both notes were written before that landed and were never revisited; the source they cite was, and now prohibits the sentence by name. - **What is asked:** confirmation that these two pending release notes may be corrected in place. ⛔ Restoring either from the base would republish a sentence the shipping code contradicts. This PR stays **draft** until that confirmation.⚠️ **This PR carries no label, and the red is expected on CI.** Step 11 of `changeset-check` ("Require a changeset (or the skip-changeset label)") fails first — this PR adds no changeset of its own — which short-circuits the job, so the foreign-changeset refusal above never executes on CI at all. `Check Changeset` is not a required context, so its red blocks no merge and an approver merges over it. ⭐ Measured on the adjacent precedent rather than recalled: PR objectstack-ai#18867's head `1056c00195` shows `Check Changeset` = **failure** on both runs, its final label set is `documentation, size/s, tooling` with **no `skip-changeset`**, and it merged. The route-0 block and ruling D are present in `pr-automation.yml` at objectstack-ai#18867's own merge commit `03008c7e1a`, so that ruling is not newer than the precedent. ## 6. Verification All **19** commands derived for this surface, re-derived at this head — `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, which reported `--repo … checked against this checkout's 'origin' remote — it holds`, change set `2 path(s) vs merge base eec56c3`, and emitted **exactly the 19** the dispatch named. Exit codes captured by redirecting first (⛔ never through a pipe). | # | command | exit | |:--|:--|:--| | 1 | `node scripts/check-adr-0087-registration.mjs --base origin/main` | **0** | | 2 | `node scripts/check-adr-0087-registration.mjs --self-test` | **0** | | 3 | `node scripts/check-changeset-no-major.mjs --base origin/main` | **0** | | 4 | `node scripts/check-changeset-no-major.mjs --self-test` | **0** | | 5 | `node scripts/check-closing-keyword-parity.mjs` | **0** | | 6 | `node scripts/check-closing-keyword-parity.mjs --self-test` | **0** | | 7 | `node scripts/check-comment-mask-corpus.mjs` | **0** | | 8 | `node scripts/check-empty-changeset.mjs --base origin/main` | **1 — by design, §5** | | 9 | `node scripts/check-empty-changeset.mjs --self-test` | **0** | | 10 | `node scripts/pm/release-rehearsal-clone.mjs --self-test` | **0** | | 11 | `pnpm check:changeset-gate-self-tests` | **0** | | 12 | `pnpm check:driver-memory-census` | **0** | | 13 | `pnpm check:gitlink-declared` | **0** | | 14 | `pnpm check:nul-bytes` | **0** | | 15 | `pnpm check:objectui-changeset` | **0** | | 16 | `pnpm check:pm-changeset-deadline-census` | **0** | | 17 | `pnpm check:published-files` | **0** | | 18 | `pnpm check:refd-timer-probe` | **0** | | 19 | `pnpm check:watch-hint-literal` | **0** | Gate 8's refusal names both files and the DELIBERATE CORRECTION class; §5 is its remedy. Gate 1 reads `.changeset/18677-…md [BREAKING] not-required (no-migration-prescription)` — the disposition PR objectstack-ai#18867 added survives this edit intact. Beyond the 19, run because the derivation refuses to call their silence a clearance: | what | result | |:--|:--| | `node scripts/check-changeset-fixed.mjs` — flagged ⛔ by the derivation, "roster under `.changeset`, which one of your paths is in" | **exit 0** — config `fixed` group in sync with 70 public packages | | `pnpm check:pm-governed-prose` | **exit 0** — and it names the 6 governed surfaces (`docs/adr/**` · `.claude/**` · `skills/**` · `AGENTS.md` · `CLAUDE.md` · `docs/NORTH-STAR.md`). `.changeset/**` is not among them, so no governed-surface obligations attach to this diff | | `pnpm lint` — whole repo, `eslint . --no-inline-config`, ⛔ **not narrowed**, so no narrowing argument is owed | **exit 0** at `2babd18767` | | control characters — `grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'` over both changed files | no hits (grep exit 1) | **No package test or typecheck is owed and none is claimed.** The diff touches no package source, no `exports`, no spec contract, no built artefact and no test. `turbo`'s affected set is empty of packages for a `.changeset/*.md` edit; that is stated as "not owed", ⛔ not as "green". **No changeset is owed for this PR.** It declares no release of its own — route 0 above is the discriminator, and it also forbids the `skip-changeset` label that would otherwise declare that in writing. **No label is written by this PR**, as ordered, and the order is independently correct: ruling D forbids `skip-changeset` on exactly this class. ## 7. Where this PR's reading differs from the dispatch order ⭐ Reported rather than silently accommodated, per the order's own instruction. - **`Clause-②: no` is declared above exactly as the seat declared it**, and this PR's reading of the finished diff **agrees**: prose in two unreleased notes, no export, no key, no member, no registration, no accept set moved in either direction. -⚠️ **The order's label reasoning is right and its prediction is wrong.** "Write no labels" is correct — ruling D forbids `skip-changeset` here by name. But "⇒ your round needs no label to go green" does not hold: PR objectstack-ai#18867, the precedent cited for it, went **red** on `Check Changeset` and was merged over, as measured in §5. This round will be red too, and ⛔ that red is not "a real finding about the gate" — it is the gate's documented behaviour for this class, and §5 is the prose-and-person remedy it prescribes. - **Nothing else in the order needed refusing.** The settled bound fits both sentences; `18677`'s `**BREAKING**` section is untouched; the two entries needed one correction, not two. ## Boundaries — what this PR deliberately does not do - ⛔ It does not touch `.changeset/18779-per-package-dedup-positional-key.md`. §4 shows why that would be a no-op. - ⛔ It does not touch `packages/cli/src/**`, any test, `content/docs/releases/`, or any `packages/*/CHANGELOG.md`. - ⛔ It does not add or change a `Clause-②:` line inside either changeset **body**. `18778-…md` carries `Clause-②: yes (narrowing)` at `:36`; that is a landed declaration about its own release and ⛔ not this card's to re-grade. - ⛔ It writes no label, and it did not route around that anywhere. ## Acceptance notes - **Noted, not filed:** `.changeset/18677-…md:9-16` pins a measurement to `origin/main 09e16a5` — `os build --json warnings: 4` against `os validate --json warnings: 3` — and objectstack-ai#18779 has since moved that fixture's build count from 4 to 3. It is ⛔ not a defect: the reading is explicitly bound to a named sha, it was true there, and `18779-…md:19-24` publishes the 4→3 move in the same release, so a CHANGELOG reader gets both. Rewriting a correctly-dated historical measurement would be the larger error. **Carrier:** none — no PR is in flight on that file and none is predicted; recorded here because the "somebody will touch this anyway" fallback ⛔ does not hold for `.changeset/*`. - **Noted, not filed:** the first sweep instrument used for §4 undercounted the tree scan 13 → 11 by collapsing `\s+` without first stripping block-comment continuation markers. That is a fact about a throwaway script in this session, ⛔ not about any tracked file — no gate, helper or committed tool has the defect. It is written down because the card's own history records the opposite failure of the same instrument class (a convincing `0`), and the pair is the argument for the lit control. **Carrier:** none; nothing in the repo carries this code. --- _Generated by [Claude Code](https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18677
Clause-②: no — no key is added to any published payload, and no newly-refused input could be exhibited (measurement below). Verified through
readClause2Linefromscripts/pm/check-clause2-carriers.mjs, ⛔ not an ad-hoc regex.os buildruns the artifact's authoring rules twice: once over the union-folded stack, then a secondrunAuthoringRules('build', …)pass over eachartifactPackages(…)entry withpackageBodyAsStack(…)as resolution context, de-duplicated against the union run.os validateran the union pass and stopped — importing neither seam. Bycompile.ts' own description the survivors of that second pass are "exactly the set the union could not see", so that whole set was findingsos buildreported andos validatestructurally could not. False-clean, on the worse door: the fast pre-flight is what an author runs before shipping.The measurement
examples/app-multi-package— the repo's only two-package fixture — atorigin/main09e16a5, both commands exiting 0:os build --jsonos validate --jsonos build's own output is unchanged: text face byte-identical modulo timings and artifact size,--jsonpayload identical.Red/green, both legs, from the committed state. With
validate.tsalone restored to its pre-change blob (pinned commit09e16a574, ⛔ never a moving ref) andartifact-packages.tsleft carrying the shared pass, so the reverted change is exactly the one the card is about:Restored with
git checkout HEAD -- …(⛔ not a baregit checkout --, which reads the poisoned index);git diff HEADempty and the blob hash back to340cec6253…. After: 28 passed in the unit run (the two pins +vitest-tiers-partition) and 4 passed in the integration run.The card's declared-unmeasured limb, answered
"Does any authoring rule fire ONLY under
packageBodyAsStackresolution today?" — measured against the registry, ⛔ not guessed from rule names, with the instrument's positive control lit:examples/app-multi-packagepath)errorseverity, any fixture tried⇒ a measured "none today" for the gating tier, and the mechanism is
packageBodyAsStack's own design, not luck: it hands each package the artifact's wholepackages[]as resolution context, and the reference-integrity suite resolves object names through it (artifactProvidedObjectNames,packages/lint/src/validate-object-references.ts). Two constructed attempts at a per-package-onlyerror— a view on a sibling's object, an app whose own navigation names a sibling's object — resolved identically in both runs and produced nothing.defineStackrefuses the first shape outright at definition time.⛔ NOT MEASURED: whether any of the 30 gating rules is reachable per-package-only. Two candidate shapes were measured and both were negative; the remaining rules were not swept one by one.
Why this is not a second copy of the loop
runPerPackageAuthoringRuleslives besideartifactPackages/packageBodyAsStackinutils/artifact-packages.ts, whose header already forbids a second copy of that shape by name. What would have drifted between two hand-written loops is not the package reading but the verdict — the de-duplication key, the severity split, thewhereprefix. The severity mapping isos build's, unchanged: a per-packageerrorrefuses, an advisory joinswarnings.Tiers — both pins gate the merge queue
test/validate-per-package-authoring-seam.test.ts→ UNIT (tierSignals=none). Source ratchet + the pass's door-independence, with a non-vacuity case.test/validate-per-package-authoring-parity.test.ts→ INTEGRATION (childProcess,helperCliOrTsx). Deliberately carries no.e2esegment, so by the orthogonal nightly cut it stays QUEUE-tier — the combinationvitest-tiers.tsnames as deliberate. Both were confirmed present intestFilesOnDisk()withOS_TEST_TIERSunset, andtest/vitest-tiers-partition.test.tspasses.test/build-json-advisory-parity.e2e.test.ts— whose standing assertion "nothing rides in build'swarningsthat validate does not also report" stayed green through this entire defect, its fixtures declaring nopackages[]— is NIGHTLY tier by name, so the queue run skips it. Run explicitly underOS_TEST_TIERS=nightly: 8 passed, unaffected.Acceptance notes — out of scope, noted, not fixed here
findingKeyincludes the positionalpath, and a collection index inside one package's body is not the index the flattened top level gives the same item — so what survives is "the set the union could not see" plus every finding whose two coordinates differ. Onexamples/app-multi-packagethe single survivor is an echo of the union's owncrm_account.industryfinding at the package-local index. ⛔ Not fixed here: changing the key changes whatos buildreports, a different decision from making the two doors agree. To file. Dedupe words:findingKey positional path·per-package echo·de-duplication key collection index.os buildcounts per-package advisories on its text face that it never prints.printAuthoringAdvisories(ruleAdvisories)runs before the per-package loop appends to that list, so the closing line reads⚠ 4 author-time warning(s) — see aboveabove a list of 3. Measured onexamples/app-multi-package;--jsoncarries all 4, so this is the text face only and is notos build --jsonalso drops the capability-provider and package-docs warnings thatos validate --jsoncarries #11727. After this changeos validateprints all 4 — the direction is now build-side. To file. Dedupe words:printAuthoringAdvisories before per-package loop·see above count mismatch·build text face advisory omitted.os linthas the same structural gap and it is not fixed here. The card's table readscompile.tsvsvalidate.ts; measured, the asymmetry was 2 of 3 doors.lint.tsimportsartifactPackagesandpackageBodyAsStack— but for its own intra-package duplicate-name advisory (os lint'snaming/namespace-prefixreports a legitimate cross-package name reuse as an intra-package duplicate, contradicting the ADR-0048 §3.4 sentence in its own message #17821), not the shared table. ItsrunAuthoringRules('lint', …)is union-only, exactly asvalidate.ts' was. ⛔ A count is not a reading: opening the hits is what separates the two. Out of the dispatch fence, which namedos validate. To file. Dedupe words:os lint per-package authoring pass·lint.ts union-only runAuthoringRules·third door #18677.origin/mainmerged at4c837a58aa; gates re-run on the merged tree.Generated by Claude Code