Skip to content

fix(cli): os lint runs the per-package author-time rule pass the other two doors already ran - #18813

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-18778-os-lint-third-door-authoring-pass
Sep 17, 2026
Merged

os-support-ai merged 2 commits into
mainfrom
claude/issue-18778-os-lint-third-door-authoring-pass

Conversation

@os-support-ai

@os-support-ai os-support-ai commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #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. ⚠️ #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 #16611 — once over the union-folded stack, then once per artifactPackages(…) entry with packageBodyAsStack(…) as resolution context, de-duplicated against the union run. #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 (#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 os lint never surfaces ADR-0087 conversion notices — it normalizes with no onConversionNotice sink, the #3782 parity gap os build was in #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 #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 7572329069:…), 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. ⚠️ fix(cli): os validate runs the per-package author-time rule pass os build already ran #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 fix(cli): os validate runs the per-package author-time rule pass os build already ran #18769's fixture (an ECHO of a union finding) structurally cannot be one. ⛔ Not corrected here — fix(cli): os validate runs the per-package author-time rule pass os build already ran #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: #18779 (the positional de-duplication key — changing it changes what os build reports) and #18780 (os build's text face counting advisories it never prints).


Generated by Claude Code


Generated by Claude Code

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 3 documentable anchor(s).

22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json ac720a986593f1983e802d49f81d77989277ea21.

⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ac720a986593f1983e802d49f81d77989277ea21 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c440eaf2c667a682fb11462f3b55cadae228a5f2 — the merge of head 45b22427a91f66ad090ec6ba3d64b9642a63a6d0 into base ac720a986593f1983e802d49f81d77989277ea21, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c440eaf2c667a682fb11462f3b55cadae228a5f2 && git checkout c440eaf2c667a682fb11462f3b55cadae228a5f2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ac720a986593f1983e802d49f81d77989277ea21 45b22427a91f66ad090ec6ba3d64b9642a63a6d0 && git checkout -B drift-repro ac720a986593f1983e802d49f81d77989277ea21 && git merge --no-ff 45b22427a91f66ad090ec6ba3d64b9642a63a6d0

node scripts/docs-audit/affected-docs.mjs --json ac720a986593f1983e802d49f81d77989277ea21

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs ac720a986593f1983e802d49f81d77989277ea21 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 45b22427a91f66ad090ec6ba3d64b9642a63a6d0

Isolated at-tier contract review of PR #18813 (card #18778), declared Clause-②: yes (narrowing). Input set: the card and its five comments (5721187721, 5721425530, 5722028692, 5722078126, 5722129660), the PR body, the PR diff (the API diff equals git diff 7572329069...45b22427a9 line for line) and the head's check runs. Protocol read from origin/main at e7eb4e9184 (references/contract-review.md, SKILL.md 〈入队与落地〉, check-clause2-carriers.mjs --template). Every number below was re-derived in a detached worktree at the head, ⛔ not copied from the deliverer's report: the fixtures were planted verbatim from the shipped lint-per-package-authoring-parity.test.ts (CONFIG_FLIP, CONFIG_SINGLE) and driven through bin/run-dev.js; ablations restore with git checkout HEAD -- PATH and blob hashes were verified before and after each leg (lint.ts 5c0e752dcb base / 5b2eb1af02 head; artifact-packages.ts c774059821 / af0f3a0554; git status --porcelain empty after every restore).

① Derived judgments

Declared arm tested: narrowing. Each accept-set or public-surface change the diff implies, named and judged one by one:

  1. os lint --strict exit on a multi-package project whose per-package pass has a warning-severity survivor: 0 → 1. RIGHT, and this is the newly-refused input, re-derived rather than accepted. On CONFIG_FLIP at head os lint --json --strict exits 1 (failing 1, passed false, total 1); with lint.ts alone restored to the merge-base blob it exits 0 (failing 0, total 0). The one added issue is warning field-no-consumers at package 'com.example.ppflip.core' — object "pp_account" · field "industry", and os build --json reports exactly that one per-package warning in BOTH states, so nothing is refused here that os build does not already report. Narrowing, exhibited.
  2. os lint default (non-strict) exit: unchanged on every fixture measured (exit 0 in both states on CONFIG_FLIP). The direction is provably monotone: the union call is the same call, the per-package survivors are only appended to issues, and failing = errors + (strict ? warnings : 0) is non-decreasing in issues — this face can stay or narrow, never widen. Whether an error-severity per-package-only survivor exists on ANY fixture (which would move the default face 0 → 1, still a narrowing bounded by os build, which already refuses it) is NOT MEASURED by the deliverer and NOT MEASURED by me; the changeset scopes its "unchanged" to the measurement above, which is the honest scope. RIGHT.
  3. os lint --json payload: no new key. Top-level key set identical in both states (conversions, duration, errors, failing, issues, passed, strict, suggestions, total, warnings); issue objects carry the pre-existing severity, rule, message, path, fix; the per-package where rides inside message exactly as os lint has always folded it. Counts move, shape does not. Not a public-surface widening. RIGHT.
  4. os lint text face: same renderer, more rows, no new section. RIGHT.
  5. os lint --score and --eval: a DERIVED face the PR body and changeset do not name. scoreMetadata (packages/cli/src/lint/score.ts:123) reaches lintConfig, so a multi-package project with per-package survivors now scores lower — measured on CONFIG_FLIP: 100 at the merge-base blob, 97 at head (one warning's penalty). The bundled eval corpus (DEFAULT_METADATA_EVAL_CORPUS, 5 cases) declares no packages[], and os lint --eval --json returns identical per-case scores and pass bits in both states, so no published eval result moved; a user-supplied live eval over a multi-package generated stack could newly fail --eval-min. Same single change seen through the rubric, same direction (a score can only fall), level unaffected — the declaration and the arm still hold. RIGHT as declared; the omission is a CHANGELOG-completeness note for the dispatching seat (③), ⛔ not a widening.
  6. runPerPackageAuthoringRules gains the additive return member findings (packages/cli/src/utils/artifact-packages.ts) — the one place a narrowing arm could hide a widening. Judged INTERNAL, not public surface: the module is reachable from none of the package's four exports entries (., ./console, ./hook-body, ./package.json); the built dist/index.d.ts, dist/console.d.ts and dist/hook-body.d.ts do not name it; the deep dist/utils/… path is sealed by the exports map. errors and advisories come out of the same loop, and because AuthoringFinding.where is a required member the rewritten {...prefixed(e), package} keeps the previous key order. Additive, internal, no accept-set effect. RIGHT.
  7. os build and os validate faces: unchanged. os build --json, os validate --json and os validate --json --strict payloads on CONFIG_FLIP are identical (volatile duration/size stripped) between head and a tree with BOTH lint.ts and artifact-packages.ts restored to the merge-base blobs. RIGHT.
  8. Single-package CONTROL (CONFIG_SINGLE, no packages[]): identical in both states — total 1 (the ordinary field-no-consumers on ps_thing.unused), --strict exit 1 in both. The instrument distinguishes "changed" from "always so". RIGHT.
  9. validate-build-gate-parity.test.ts: docblock only; PARITY_COMMANDS stays ['compile.ts', 'validate.ts']; the corrected clause ("runs no artifact-level gate") was false on the tree, since lint.ts calls collectAndLintDocs. No surface. RIGHT.
  10. Direction against the published contract text: content/docs/deployment/cli.mdx:1485 (os lint [config] — "Every author-time gate validate/build run, plus style and convention checks") and validating-metadata.mdx:503-505 ("a green os lint means the build's gates are green too") were FALSE for a multi-package config before this PR and are TRUE after it. The diff pulls the code toward the declared contract; yes is the conservative declaration for a change that refuses something it accepted, and narrowing is the right arm. Nothing is widened on any face examined.

Red/green from the committed state, mine: unit lint-per-package-authoring-seam.test.ts 6/6 at head, 2 failed / 4 passed with lint.ts ablated; integration lint-per-package-authoring-parity.test.ts 5/5 at head, 2 failed / 3 passed ablated (expected +0 to be 1 on failing). Tier read from vitest list --filesOnly: seam → unit (214 files), parity → integration (50 files), matching the PR's table. check-widening-tells: all 6 files NOT MEASURED (no declared surface covers packages/cli) — reported as such, ⛔ not read as a zero. dispatch-gates --tier on the six paths: no path mandate, clause ② from content only.

② Semver level

Changeset .changeset/18778-lint-per-package-authoring-pass.md: '@objectstack/cli': minor, a **BREAKING** banner, the ADR-0087 HTML-comment marker reading adr-0087: not-required (no-migration-prescription), and Clause-②: yes (narrowing) in the body. This agrees with the rule as written: yes takes at least minor; (narrowing) is BREAKING; during the launch window major is refused and breaking-ness is carried by the banner plus the ADR-0087 disposition (check-changeset-no-major.mjs header; pr-automation.yml "WHICH LEVEL"). Re-run locally against merge-base 7572329069: check-adr-0087-registration exit 0 reading [BREAKING+clause-②-narrowing] not-required (no-migration-prescription); check-changeset-no-major exit 0; Check Changeset is green on the head. Not skip-changeset: packages/cli is released and both edited sources ship under dist. The body carries the remedy a behaviour narrowing owes (drop --strict to keep the old verdict, or fix what os build already reports). Level, arm and declaration AGREE.

③ Boundary flags

open_questions: [] — nothing to answer there. Every flag in the report's out_of_scope_findings, the PR's acceptance notes and the docs rider, answered or escalated:

  • F1 default face NOT MEASURED — answered in ①.2: monotone, cannot widen; whether an error-severity per-package-only survivor exists stays NOT MEASURED; no bearing on the arm.
  • F2 "fix(cli): os validate runs the per-package author-time rule pass os build already ran #18769's own Clause-②: no is falsified" — CONFIRMED by my own measurement and ESCALATED to the dispatching seat. With validate.ts alone restored to the pre-fix(cli): os validate runs the per-package author-time rule pass os build already ran #18769 blob (095c7f60ae^, hash bafa54b07f verified) os validate --json --strict on CONFIG_FLIP exits 0 with 0 warnings; at head it exits 1 with the one per-package warning. So os validate --strict gained a newly-refused input in fix(cli): os validate runs the per-package author-time rule pass os build already ran #18769. Its changeset .changeset/18677-validate-per-package-authoring-pass.md is still pending on origin/main (unreleased; npm @objectstack/cli is 17.4.0) and states "⛔ not declared breaking, because the narrowing could not be exhibited", with no BREAKING banner and no ADR-0087 marker. Outside this PR and not this PR's fault; a card to amend that pending changeset (banner + disposition) before the release that consumes it is the successor, and rewriting a landed declaration is not this PR's act.
  • F3 findings member — answered in ①.6: internal, additive, not public surface.
  • F4 PARITY_COMMANDS docblock — answered in ①.9: comment-only, roster unchanged.
  • F5 vitest-tiers.ts KERNEL signal — noted; neither new file constructs new ObjectQL(; not a contract matter; nothing owed.
  • F6 docs rider routing (card vs a parallel docs-only PR for the four-doors tier axis) — no published page is falsified by this PR (the two statements in ①.10 became TRUE), so nothing is owed on this head; where the addition lands is the dispatching seat's routing call, not a contract question; agreed it is not a commit on this branch.
  • F7 triage's escalation condition (5721187721: an artifact refused only by the per-package pass, fed to os lint, let through ⇒ live violation ⇒ p1) — measured MET at the merge-base: on one project os lint --strict exited 0 while os validate --strict exited 1. The priority relabel is the triage/PM seat's, not this record's; recorded so it is not lost.
  • F8 the dispatch's STOP arm ("do not assume os lint should run it") — settled from the repo's own statements, verified on the tree: packages/lint/src/authoring-rules.ts:38-44 (any rule that can emit error runs on all three commands; three doors in ONE wall), lint.ts at 7572329069 :604-611 and :652-656, and the two published doc statements in ①.10. No maintainer fork remains; nothing for the decision box.
  • F9 (mine, from ①.5) — the --score/--eval face is not named in the changeset; a one-line addition would make the CHANGELOG grep-complete for an upgrader running --eval --eval-min over multi-package output. Same direction, same level; ⛔ not a blocker for the declaration this review judges.

CI on the head at the time of this record: 45 check runs, 37 success, 8 skipped, 0 failed or pending; all seven required contexts success; mergeable_state clean. Head unchanged at 45b22427a9; the PR thread holds one bot comment and the card thread the five comments named above.

Implemented-by: claude/issue-18778-os-lint-third-door-authoring-pass
Reviewed-by: session_01DvvamiacK328idtBYJBxV3

Independence, stated plainly: the implementation claim (5722028692) is mode:subagent under session session_01DvvamiacK328idtBYJBxV3, and the seat adopting this verdict is that same session. Under references/contract-review.md a mode:subagent dev's identity is its BRANCH and SELF-REVIEW is reachable only under mode:remote; the independence device here is this isolated at-tier subagent, fed only the card, the PR and the protocol text, ⛔ not the dispatch order or the seat's conclusions. NOT MEASURED by this review: the full 214-file unit and 50-file integration populations (only the two new pins and their ablations ran here; CI Test Core is green), the deliverer's ESLint and 61-family gate sweep (CI Lint & Repo Gates is green), and the existence of any error-severity per-package-only fixture. Rendered 2026-09-17T22:59Z.

VERDICT: PASS


Generated by Claude Code

@os-support-ai
os-support-ai marked this pull request as ready for review September 17, 2026 23:05
@os-support-ai
os-support-ai added this pull request to the merge queue Sep 17, 2026
Merged via the queue into main with commit 9bd631f Sep 17, 2026
50 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-18778-os-lint-third-door-authoring-pass branch September 17, 2026 23:32
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ojects` package (objectstack-ai#18853)

Fixes objectstack-ai#18788

Clause-②: no

⚠️ **This is a RESUMED delivery.** A previous run's container was killed
after it pushed one commit and before it opened a PR, ran a single test,
or measured anything. That commit was treated here as an untrusted
proposal: every claim in it was re-measured, one false statement in it
was corrected, and its incomplete half was rewritten. What was verified
and what was rewritten is itemised at the foot of this body.

---

## 1. The premise, reproduced FIRST — with four lit controls

⛔ The card's whole subject is that a reading which cannot fail is
indistinguishable from one that passed, so the premise was reproduced
before anything was built on it, against the BASE tree (`ad1f94e8ec`,
this branch's merge base) rather than against the branch's own fix. The
pre-fix `packages/cli/vitest.config.ts` was read out of that commit
verbatim (`git hash-object` of the copy `837c2d1319` equals the BASE
blob) and run under a temporary name, so no tracked file was mutated;
the probe files were untracked and `git status` was empty afterwards.

Probe: a `beforeAll` sleeping 500ms. vitest `4.1.11`.

| # | run | exit | evidence |
|---|---|---|---|
| R1 | `@objectstack/cli`, `--project unit`, `--hookTimeout=1` | **0** |
`Test Files 1 passed` |
| R2 | `@objectstack/cli`, no `--project`, `--hookTimeout=1` | **0** |
`Test Files 1 passed` |
| R3 | ⭐ **LIT** `@objectstack/plugin-dev` (no `projects`),
`--hookTimeout=1` | **1** | `Error: Hook timed out in 1ms.` |
| R4 | ⭐ **LIT** `@objectstack/cli`, `--testTimeout=1`, 500ms in the
test BODY | **1** | `Error: Test timed out in 1ms.` |
| R5 | ⭐ **LIT** `@objectstack/cli`, `beforeAll(fn, 1)`, no flags |
**1** | `Error: Hook timed out in 1ms.` |
| R6 | ⭐ **LIT** `@objectstack/cli`, root `test.hookTimeout: 1` in the
config | **1** | `Error: Hook timed out in 1ms.` |

⇒ The two zeros are READINGS. R3 proves the flag works where no
`projects` narrowing exists; R4 proves the CLI-to-project path is ALIVE
in this very package under the same config and invocation shape, so the
defect is ALLOWLIST MEMBERSHIP and nothing wider; R5 and R6 prove the
VALUE reaches the hook by both other spellings, so `hookTimeout` is not
unenforceable here — only the CLI spelling of it is inert.

### The mechanism, read off the installed runner

`resolveProjects` in `vitest/dist/chunks/cli-api.CnMVyzaz.js` builds
each project's test config as `test: { ...options.test, ...cliOverrides
}`, where `cliOverrides` is a CLOSED allowlist of **twenty** CLI option
names. `testTimeout` is on it. `hookTimeout` and `teardownTimeout` are
not. Everything else reaches the ROOT config and stops there.

---

## 2. The three routes, and why route 3

⛔ The card deliberately does not rule on which side moves, and neither
did the dispatch. All three were costed against the measurements above.

**Route 1 — stop relying on the flag, rewrite the witness.** Available
and measured to work: R5 and R6 are exactly the two spellings that DO
bite in a `projects` package. What it cannot do is stop the NEXT reader
reaching for the flag — this repo's own prior art reaches for it, and a
dispatching seat handed it over as a suggestion on the round that filed
this card. A remedy that is a habit is not a remedy. ⭐ Route 1 is
therefore not discarded: it is what the refusal text NAMES, which is the
one way a convention gets enforced rather than recommended.

**Route 2 — forward the value into the projects.** The cheapest spelling
is not even per-project: every one of these configs declares `extends:
true`, so writing the parsed value into the ROOT `test` block reaches
all of them — R6 measures exactly that, by hand. It works. Rejected on
three counts:

- **it invents a precedence rule vitest does not have, and then hides
the day vitest acquires one.** For an allowlisted name vitest spreads
the CLI value last, so the CLI wins over the project's own block; for a
name vitest never carries there is no rule at all, so a forward has to
pick one, for eight packages, on this repo's authority. And the pick
goes stale silently: the day a vitest bump adds `hookTimeout` to
`cliOverrides`, vitest's own override and the forward are BOTH live and
nothing reddens. The refusal has the opposite failure mode — its
transcription pin equals-checks the installed array, so that same bump
turns it RED. **This is measured, not argued:** ablation B below puts
`hookTimeout` on the transcribed allowlist and the pin goes red on cue.
- **it fixes exactly the names it enumerates.** The allowlist is twenty
names and vitest's CLI surface is not. A forward built for three names
leaves the rest handing out the same false greens — the identical
defect, narrower, now behind something called a fix. Widening the
forward to every CLI option is a second transcription of vitest's option
table, which is the multiplication `packages/qa/vitest-filter-preflight`
exists to stop.
- **it is a lenient consumer-side accommodation for an upstream
contract**, which Prime Directive objectstack-ai#12 refuses in the form it usually
takes.

**Route 3 — refuse the run, and name the spellings that do bite.
CHOSEN.** Refusing costs nothing that was working: the flag measures
nothing today, so no run loses a capability, and a run naming no such
flag is byte-identical to the run it is today (pinned in both
directions). What it buys is that the false clearance becomes impossible
to READ — and because the notice carries route 1's two measured
spellings, the reader leaves with the instrument they came for rather
than a bare refusal.

---

## 3. ⚠️ Why the fix lives in `packages/qa/vitest-filter-preflight`, not
in `packages/cli`

The dispatch order neither scoped this package in nor out, so here is
the measurement that justifies it rather than a preference.

The fix needs a transcription of a private vitest code path (the
twenty-name allowlist). This repo has already ruled once, in objectstack-ai#17978, on
where such a transcription goes and why there is exactly one:
`matchesVitestFilter` — a transcription of `TestProject.filterFiles` —
was pulled out of `packages/cli` into this package precisely because
eight configs needed it and eight copies would drift from vitest and
from each other, with every drift failing SILENTLY GREEN. That is the
same failure direction as this card's defect.

The concrete measurements that follow from putting it there:

- **Cost to the consumer: ZERO new import lines.** All eight configs
already import this module by relative path; the new function is
re-exported from `src/index.ts`, so each config's wiring is one name in
an existing brace list plus the call.
- **The anti-phantom sweep already exists and already DERIVES its
population.** `test/config-wiring-sweep.test.ts` walks `packages/` for
configs declaring `projects` and requires the wiring. Putting the
refusal anywhere else means either no sweep for it, or a second sweep.
- **The alternative was costed.** Inlining the transcription in
`packages/cli/vitest.config.ts` fixes one package and makes the other
seven a copy-paste job — which is objectstack-ai#17978's finding, reintroduced.

⇒ The package is justified by the prior ruling it is the product of, not
by convenience. ⛔ If the seat still wants it narrowed, the narrowing is
mechanical (move the module into `packages/cli`, drop seven wirings) and
this section is the argument to overrule.

---

## 4. ⭐ The population is EIGHT, and all eight are wired

The card names `packages/cli`; triage measured the population at eight
and told the fixer so in its own words (「修的时候**总体是 8**,⛔ 不是 1」). The
defect is a property of declaring `projects`, not of `packages/cli`, and
that was measured in a SECOND package rather than assumed:

| run | exit | evidence |
|---|---|---|
| `@objectstack/types`, 500ms `beforeAll`, `--hookTimeout=1` | **0** |
`Test Files 1 passed` |
| ⭐ **LIT** `@objectstack/types`, `beforeAll(fn, 1)`, no flag | **1** |
`Error: Hook timed out in 1ms.` |

The interrupted commit wired `packages/cli` only. Wiring one and leaving
seven would have shipped the shared module's own sentence — "eight
packages in this repo declare vitest `projects`, every one of them has
this defect" — as a documented, unfixed defect in seven places, with
nothing to catch them, since a sweep that covered the new call would
then have needed a maintained exemption list for the other seven: the
exact artefact objectstack-ai#17978 removed.

⛔ The wiring is NOT the interesting half. A config can contain the call
in code position and still refuse nowhere — a swallowed throw, a wrong
argv source, a re-export resolving to a stub. So
`config-wiring-sweep.test.ts` grew a BEHAVIOURAL leg and asks a real
`vitest` child, per package, in both directions:

```
PACKAGE                  clean(exit/refusal)    --hookTimeout=1(exit) names-own-pkg
packages/cli             exit=0 refusal=0       exit=1 refusal=1      ownname=2
packages/core            exit=0 refusal=0       exit=1 refusal=1      ownname=2
packages/objectql        exit=0 refusal=0       exit=1 refusal=1      ownname=2
packages/qa/dogfood      exit=0 refusal=0       exit=1 refusal=1      ownname=2
packages/rest            exit=0 refusal=0       exit=1 refusal=1      ownname=2
packages/runtime         exit=0 refusal=0       exit=1 refusal=1      ownname=2
packages/spec            exit=0 refusal=0       exit=1 refusal=1      ownname=2
packages/types           exit=0 refusal=0       exit=1 refusal=1      ownname=2
```

The refusal must name THAT subject's own manifest name — that is what
makes the non-zero a reading, since a spawn failure, an unresolvable
config or a missing build all exit non-zero too and none of them prints
this text. ⛔ The clean-run leg is not optional: without it every other
assertion in the file is satisfied by a config that refuses EVERYTHING,
which would be a far worse defect than the one being fixed.
`--filesOnly` globs test paths and never imports them, so neither leg
depends on build state. 16 children, ~9s wall.

---

## 5. Ablations — these pins can fail, and it was watched

Each leg: mutate → prove the mutation reached disk by an anchored count
→ run → restore → prove the restore by `git hash-object` equality
against the HEAD blob. ⛔ No `dist` leg applies and that is a
measurement, not an omission: `@objectstack/vitest-filter-preflight` is
`private: true`, has **no** `build` script, and its `exports` points
straight at `src`, which every consumer imports by relative path — there
is no built artefact for a mutation to fail to reach.

**Ablation A — delete the wiring from
`packages/types/vitest.config.ts`.** On-disk proof: call sites before=1
after=0. Result: `Test Files 1 failed`, `Tests 2 failed | 63 passed` —
exactly the two new legs for that subject, the source assertion and the
behavioural refusal. Restored to blob
`c2f03814c52b71f88fa1f8c90938a7db102e4e7e` (== HEAD).

**Ablation B — put `hookTimeout` ON the transcribed allowlist.** On-disk
proof: occurrences before=0 after=1. Result: `Test Files 2 failed | 1
passed` — the transcription pin red (`equals the cliOverrides array in
the installed vitest`), the pure-function legs red, and **all eight**
behavioural refusal legs red. ⭐ The discriminator legs (`--testTimeout`
is not refused; the notice is the empty string when nothing is dropped)
stayed GREEN, which is the right direction: the ablation removed a
refusal, it did not make the module refuse everything. Restored to blob
`6b078d5e61ee51113ec35d950caffe75ed09f55d` (== HEAD).

**Restore leg:** `git diff HEAD --stat` empty, suite green again — `Test
Files 3 passed (3)`, `Tests 111 passed (111)`.

---

## 6. Changeset: `skip-changeset`, and it rests on a measurement

The dispatch asked this be decided by measuring, ⛔ never inferred from
`files[]` or from a file's look. Two independent instruments, both with
lit controls:

**(a) The manifests.** `@objectstack/vitest-filter-preflight` is
`private: true`, carries no `files[]` and has **no `build` script** —
nothing in it is ever shipped. `@objectstack/dogfood` likewise.

**(b) npm's OWN packing** (`npm pack --dry-run --json`), per published
package, asked whether any path this PR changes is in the list npm would
actually ship:

| package | shipped files | `vitest.config*` | `test/*` | the card's
test file | ⭐ POS CTRL (`package.json` + `dist/*`) |
|---|---|---|---|---|---|
| `cli` | 533 | 0 | 0 | 0 | **529** |
| `core` | 18 | 0 | 0 | 0 | **15** |
| `objectql` | 18 | 0 | 0 | 0 | **15** |
| `rest` | 10 | 0 | 0 | 0 | **7** |
| `runtime` | 10 | 0 | 0 | 0 | **7** |
| `spec` | 2021 | 0 | 0 | 0 | **217** |
| `types` | 16 | 0 | 0 | 0 | **13** |

⇒ Every one of this PR's thirteen paths is a vitest config, a test file,
or a file in a private package. Zero of them ship, and the positive
control is lit in every row, so the zeros are readings.
**`skip-changeset` is the measured verdict**, and the label is applied
on this PR.

---

## 7. ⚠️ `objectstack-ai#18520` is NOT absorbed, and the TIER split did not move

`packages/cli/vitest.config.ts` is touched, and the dispatch required
this be stated explicitly. **The `unit` / `integration` TIER split is
untouched** — no change to `vitest-tiers.ts`, to `integrationTestFiles`
/ `unitTestFiles`, to `OS_TEST_TIERS`, or to either project's `include`.
The only edit to that file is the preflight call above the
`defineConfig`. objectstack-ai#18520's other half — "nightly-only tiers can never be
reddened by a PR" — is reported, not done. ⛔ Not widened into.

The new `packages/cli` pin lands in the `unit` tier by the derivation,
not by a name: it imports `node:child_process` to ask vitest a question,
which is the same classification `vitest-tiers-partition.test.ts`
already carries.

---

## 8. ⛔ Files the dispatch fenced off — untouched

`packages/cli/src/commands/compile.ts` (objectstack-ai#18780 in flight),
`packages/cli/src/utils/artifact-packages.ts` and
`packages/cli/src/commands/lint.ts` (objectstack-ai#18813) appear in no hunk of this
branch's own commits. They appear in `git diff BASE...HEAD` only because
`origin/main` was MERGED in — never rebased, never force-pushed, never
amended, per the dispatch's hard constraint.

---

## 9. Verification

- `pnpm --filter @objectstack/vitest-filter-preflight test` → **`Test
Files 3 passed (3)`, `Tests 111 passed (111)`**
- `pnpm --filter @objectstack/cli exec vitest run --project unit
test/hook-timeout-override-refusal.test.ts` → **`Test Files 1 passed
(1)`, `Tests 4 passed (4)`**
- `pnpm --filter @objectstack/vitest-filter-preflight typecheck` → clean
- The derived gate family (`node scripts/pm/dispatch-gates.mjs
--commands`, 62 commands over the measured 13-path change set) — results
in the round report. `check:cross-package-test-inputs`,
`check:test-source-alias`, `check:tier-file-adoption`,
`check:cli-test-child-env`, `check:published-files`, `check:nul-bytes`
and `check:comment-mask-corpus` are the ones this diff most directly
implicates; all green.
- `check:dual-build-cjs-loads` reports **`PREREQUISITE NOT MET`** in
this worktree — it reads built output and names `studio`, `client-react`
and the connectors, packages this diff never touches. ⛔ Recorded as NOT
MEASURED, not as a pass and not as a failure. CI checks out and builds
fresh.

---

## 10. What was VERIFIED from the pre-existing commit, and what was
REWRITTEN

**Verified and kept** — `src/project-cli-override-preflight.ts`'s
mechanism (the twenty-name allowlist, transcribed verbatim and confirmed
against the installed `cli-api` chunk, order included); its
refusal-over-forwarding conclusion;
`test/project-cli-override-preflight.test.ts` (the transcription pin,
the discriminator and the silence contract — all now actually run, 111
green, and ablated); the `src/index.ts` re-export; the `packages/cli`
wiring and its behavioural pin.

**Corrected** — the header's account of vitest's merge order. The commit
claimed a forwarded value would override a project's own timeout "in the
opposite direction from the one vitest documents". Read off
`resolveProjects`, the CLI value is spread LAST for an allowlisted name,
so the CLI already wins; the true objection is that vitest has NO rule
for a name it never carries, so a forward invents one and then goes
silently stale. The route-2 rejection is rebuilt on the three counts
that survive the correction.

**Rewritten / added** — the population. The commit fixed one of eight
packages and measured none of it. Seven configs wired, the sweep grown a
per-subject behavioural leg in both directions, the defect measured in a
second package, both ablations, the npm-pack changeset measurement, and
the declared division of labour between the card-local `packages/cli`
pin and the population sweep.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3

---
_Generated by [Claude
Code](https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…, 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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] os lint is the THIRD door with the same union-only authoring-rule gap — #18677 closed two of three, and the card's own table said 2 of 2

2 participants