fix(core,runtime,plugin-dev,plugin-security): refuse packages: null as malformed, never absent - #20228
Conversation
… as malformed, never absent
ObjectStackDefinitionSchema.packages is `.optional()`, which admits
`undefined` and not `null`; the schema and composeStacks already refused
`null`, while every reader read it as absent. The readers now follow the
declaration: the core resolver's absent branch is `undefined` only, the
runtime collection reader and the plugin-dev i18n guard move with it, and
plugin-security inherits the refusal. Same code and envelope as `{}`:
INVALID_ARTIFACT_PACKAGES / 422. The spec's rule text beside
AssembledPackageBodySchema is corrected to match; the schema is unchanged.
Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 5 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 148 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 ba3ff68ba4d8f5507ce27601a51d032999f1e5e1 && git checkout ba3ff68ba4d8f5507ce27601a51d032999f1e5e1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 93cfc3f593cbd794f244b6ce922e7410776cad8a 0706ffef7c56b9ce968bbec76d3eab7f0a0d7de9 && git checkout -B drift-repro 93cfc3f593cbd794f244b6ce922e7410776cad8a && git merge --no-ff 0706ffef7c56b9ce968bbec76d3eab7f0a0d7de9
node scripts/docs-audit/affected-docs.mjs --json 93cfc3f593cbd794f244b6ce922e7410776cad8a
|
Contract reviewServed-tier: 98/98 ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL |
… exactly The resolver's callers that hand it the whole artifact raise the refusal; MetadataPlugin's artifact door parses the stack schema first and never hands the resolver a `null`, so it leaves the list, and the verify reader raises only for a collection the top level does not carry. The plugin-dev limbs that answer before the package walk now include `manifest.i18n` and a non-empty `manifest.translations`. The readers claim is scoped to the readers this note changes, and the grant binding to a record-shaped `grantedPermissions`. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 95/95 ① Derived judgments
② Semver levelCorrect. ③ Boundary flags
Implemented-by: VERDICT: PASS |
…stamp under @objectstack/lint's ledger key Rework round 1. Ruling A on #19926 (5805260775): `null` is malformed, everywhere. `packages/lint`'s site census (#20206, ruling A on #15293) put `null` out of scope with a pointer to #19926, but #19926's own claim fenced `packages/lint` out as this card's surface -- the lint leg had no owner. `packagesOf` (object-graph.ts) now treats only `undefined` as absent; `null` falls to the same INVALID_ARTIFACT_PACKAGES refusal as `{}`/`0`/`'x'`/ a keyed object. The message names `null` as itself rather than `typeof`'s `'object'`, matching the type label PR #20228 uses in packages/core/src/artifact-packages.ts. Every `null` pin flips from a silence control to a refusal assertion, at `packagesOf` directly and at each of the three public functions its four (now five, with validate-mapping-target-fields.ts) call sites sit behind. `undefined` (absent) and a well-formed array stay green controls. CI fix: `check:error-code-provenance` (job "Lint & Repo Gates", step 120) was red on the prior head -- `packages/lint` stamps the registered code `INVALID_ARTIFACT_PACKAGES` (object-graph.ts's `err.code = ...`) without being listed under its own owner key in `ERROR_CODE_LEDGER`. Fixed the way the gate prescribes: a new `'@objectstack/lint'` row in packages/spec/src/api/error-code-ledger.zod.ts, with a comment recording the wire path (door: 'none' -- packages/lint's rules are pure `(stack) => Finding[]` functions called from `os validate`/`os lint`/ `os build`, never through an HTTP boundary). No code minted or duplicated; the existing registered code is reused, provenance is merely now recorded under a second owner (precedent: 3f9e2ea, "list plugin-security's class-field error codes under its own ledger key"). Two changesets: `@objectstack/lint: minor` (Clause-②: no (narrowing) -- unchanged from round 1) for the behavior change, and a new `@objectstack/spec: minor` (Clause-②: yes) for the ledger row -- a new per-package face on a published payload, modelled on the 3f9e2ea precedent's own spec-only changeset. The PR-level declaration becomes `Clause-②: yes (narrowing)`, carrying both facts. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
…ording REWORK round 2 for #20206 (at-tier record 5856823202, items 1-2): 1. `validate-object-references.test.ts`'s non-array refusal test pinned only [null, 42, 'core']; add `{}` and a keyed object so all three validators pin the identical set `packagesOf` itself does. 2. Text corrected to match the head, wording only: (a) "four call sites" / "four copies" -> "five ... three files" in the `packagesOf` docblock and the `object-graph.test.ts` comment, now that `validate-mapping-target-fields.ts` is a fifth site. (b) every sentence claiming lint reads `null` "the way `resolveArtifactPackageOrder` does" or raises "the SAME code ... for the identical defect" is qualified for `null` specifically: on `main`, core still reads `null` as absent and names it via `typeof`; that only changes once #19926 (PR #20228) lands. Fixed in the docblock, the inline naming comment, the lint changeset and the ledger-row comment in `error-code-ledger.zod.ts`. (c) the lint changeset's door-reachability claim corrected: only `os lint` reaches `packagesOf`'s refusal (exit 1, message on stdout via `printError`, `code` under `--json`); `os validate` and `os build` already refuse a malformed `packages` earlier, at `ObjectStackDefinitionSchema.safeParse`, before these rules run (re-read `validate.ts:293`, `compile.ts:356`, `lint.ts:673/1140`, `format.ts:369` directly to confirm). No `packagesOf` semantics changed. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
|
Landing order reversed: this PR enters the queue FIRST, ahead of PR #20229 ·
|
…20228 landed REWORK — one more wording round for #20206 (seat ruling 5857515836, extended). PR #20228 (#19926) merged as a9fb83e in round 4, so every sentence still conditioning lint's null-refusal parity with resolveArtifactPackageOrder on "once #20228 lands" was false at head. Restated in the present tense, wording only, no code changed: - object-graph.ts's packagesOf docblock (the null bullet and the @throws block) and its inline naming comment. - the lint changeset's parity sentence. - the ledger row comment in error-code-ledger.zod.ts. git grep -n -E "once (it|#20228|#19926|PR #20228)[^.]*lands|still reads (it|\`null\`) as absent|adopted early" over this PR's changed files now returns 0 hits. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
…on and docs readers (objectstack-ai#20231) Fixes objectstack-ai#19925 Clause-②: no (narrowing) ## What changed Ruling A on objectstack-ai#15293 (`5634034754`) says that a `packages` which is present but is not an array (`{}`, `0`, `'x'`) is malformed, not absent, and that every reader refuses it. The runtime, `@objectstack/core` and the plugin readers already refused it. The four `@objectstack/cli` readers answered "no packages" instead. They now refuse it with core's own `INVALID_ARTIFACT_PACKAGES` refusal (ADR-0112, `status: 422`). - `stack-collections.ts` gets ONE exported helper, `declaredPackageEntries(packages)`: - the key absent (`undefined`) answers `[]`, and that is the only value the helper answers on its own; - an array comes back by reference; - every other value, `null` included, goes to `resolveArtifactPackageOrder`. A non-array is refused there. The resolver's absent answer (`[artifact]`, the object passed in, by reference) is recognised by identity and answers `[]`. So `null` gets whatever the resolver answers for it (round 1, after ruling `5805260775` on objectstack-ai#19926). The helper spells neither the rule nor the refusal, and no new code is minted. - `packageBodies` routes through the helper. So do `docsPackageRefs`, `bodyDocsOf` and `attachPackageDocs` in `collect-docs.ts`. None of the four keeps its own `Array.isArray` guard. - New pins in `packages/cli/test/non-array-packages-readers.test.ts` (16 tests). Each refusal asserts `code` + `status`. - New pins in `packages/cli/test/null-packages-follows-resolver.test.ts` (16 tests), added in round 1: - Leg 1 holds each reader's answer for `packages: null` to the REAL resolver's verdict on `{ packages: null }`. That is the absent answer today, and the refusal once objectstack-ai#19926's core change lands. - Leg 2 arms a resolver double that refuses `null` with the non-array envelope, and every reader must then refuse. A private `null` branch in the CLI never asks the resolver, so today only leg 2 can see one. - The existing row `docsPackageRefs` › "is empty for anything that is not an array" (`src/utils/collect-docs.package-docs.test.ts`) pinned `[]` for `{}`, which is the retired answer. It now asserts the refusal envelope and keeps its absent control. - Changeset `.changeset/19925-cli-non-array-packages-refusal.md`: `@objectstack/cli` minor, `Clause-②: no (narrowing)`, a BREAKING paragraph, and the ADR-0087 disposition `not-required (no-migration-prescription)`. Round 1 replaced its `null` sentence with a paragraph saying that `null` follows core's resolver (ruling `5805260775`). ## Reach, measured: why the changeset narrows The built CLI (`packages/cli/bin/run.js`) was run on `ce70876e4` (before) and on this branch (after). Each fixture is a hand-written `objectstack.config.ts` whose default export is a plain object carrying a `manifest` and the `packages` value shown. | `packages` | command | before | after | |---|---|---|---| | `{}` / `0` / `'x'` | `os info --json` | exit 0, `stats.objects: 0` | exit 1, `INVALID_ARTIFACT_PACKAGES` | | `{}` / `0` / `'x'` | `os lint --json` | exit 0, `passed: true` | exit 1, `INVALID_ARTIFACT_PACKAGES` | | `{}` / `0` / `'x'` | `os validate --json` | exit 1, schema `invalid_type` at `packages` | unchanged | | `{}` | `os build --json` | exit 1, schema `invalid_type` at `packages` | unchanged | | `{}` | `os serve` | exit 1, core's `INVALID_ARTIFACT_PACKAGES` sentence raised during boot | unchanged | | well-formed array | `os info` / `os lint` | exit 0: 1 object / `passed: true` | unchanged | | absent | `os info` / `os lint` | exit 0: 1 object / `passed: true` | unchanged | | inlined entry (lit control) | `os info` / `os lint` | exit 1, `INVALID_ARTIFACT_PACKAGE_ENTRY` | unchanged | With its default strict parse, `defineStack(…)` refuses `packages: {}` at config load (`STACK_SCHEMA_INVALID`). Only two spellings reach `os info` / `os lint` with the shape: a plain-object export, and `defineStack(…, { strict: false })`. Both were measured, and both behave as the table says. So two public doors ACCEPTED the shape and answered success. The claim carried `Clause-②: no`. This PR follows the same-door precedents (`.changeset/19120-install-door-parses-manifest-version.md`, `.changeset/19417-install-door-parses-manifest-id.md`) and declares `Clause-②: no (narrowing)` with a `minor` bump instead. ## The four readers, before and after The readers were called from source with a well-formed control and an absent control. | reader | `{}` / `0` / `'x'` before | after | well-formed | absent | |---|---|---|---|---| | `packageBodies` (via `resolveStackCollection`) | `[]` | `INVALID_ARTIFACT_PACKAGES`, 422 | the body's objects, unchanged | `[]`, unchanged | | `docsPackageRefs` | `[]` | `INVALID_ARTIFACT_PACKAGES`, 422 | the package ids, unchanged | `[]`, unchanged | | `bodyDocsOf` | `[]` | refuses through the helper | the body's docs, unchanged | `[]`, unchanged | | `attachPackageDocs` | the same reference back | `INVALID_ARTIFACT_PACKAGES`, 422 | attaches, unchanged | the same reference back, unchanged | `bodyDocsOf` has one caller, `collectAndLintDocs`, which calls it only for refs that `docsPackageRefs` produced from the same value. So a non-array never reached it, before or after. It uses the helper anyway, so that no reader keeps a guard of its own. The ablation below confirms that no pin can observe it. ## Ablation The fix was committed first (`67d5b4829`). The pin file imports `src/` relatively, so no `dist/` leg is involved. Each leg went through `scripts/ablation-replace.mjs`: the anchor went 1 → 0, the injected text 0 → 1, and the blob changed. The leg then ran the pin file, and the tool restored the file (blob equal to HEAD, `git diff HEAD` empty). At the end, `git hash-object` of both files matched their HEAD blobs. | leg | mutation | pin file | |---|---|---| | A | the helper's non-array branch answers `[]` | 12 failed / 4 passed | | B | `packageBodies` gets its old `Array.isArray` guard back | 6 failed / 10 passed | | C | `docsPackageRefs` gets its old guard back | 3 failed / 13 passed | | D | `attachPackageDocs` gets its old guard back | 3 failed / 13 passed | | E | `bodyDocsOf` gets its old guard back | 16 passed: unreachable, the expected direction | Round 1: the fix was committed first (`5bac82635`). The leg restored the private `null` branch, `if (packages === undefined || packages === null) return [];`, through `scripts/ablation-replace.mjs`: the anchor went 1 → 0, the injected text 0 → 1, and the blob `c8c830d2e25b` → `78739bfea3c8`. - On the two pin files, the unmutated HEAD gave 32 passed. - The mutation gave 7 failed / 25 passed. All 7 leg-2 rows went red, and leg 1 stayed green, which is expected today. - The restore was proven: blob == HEAD (`c8c830d2e25b`), `git diff HEAD` empty, status clean. ## Verification Round 1, all on HEAD `ae8e3e83f`, after merging `origin/main` at `3cb84d084`. - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: 229 files, 3250 tests passed. The `integration` tier is left to CI, because the diff touches no spawn entry and no integration-tier file. - `pnpm --filter @objectstack/cli typecheck`: exit 0. `check:test-typecheck` compiles both pin files under `tsconfig.test.json`. - `pnpm lint` over the whole repo: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands` derived 62 commands. All 62 exited 0, and the `--ran` reconciliation reports 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN. As in round 0, `check:dual-build-cjs-loads` and `check:i18n-coverage` first exited 3 (PREREQUISITE NOT MET, some `dist/` missing). Both were re-run on the same HEAD once the builds were present, and exited 0. - `node scripts/check-adr-0087-registration.mjs --base origin/main` and `node scripts/check-changeset-no-major.mjs --base origin/main`: exit 0 each. - `node scripts/check-issue-citations.mjs --base origin/main`: exit 0, with 6 citations resolving. - `packages: null` on the built CLI, with a plain-object config: - On this HEAD, `os info --json` exits 0 (`stats.objects: 0`) and `os lint --json` exits 0 (`passed: true`). Both are unchanged. - A throwaway worktree of this HEAD was given PR objectstack-ai#20228's `packages/core/src/artifact-packages.ts` (`0706ffef7`), and core was rebuilt. There, both commands exit 1 with `INVALID_ARTIFACT_PACKAGES`, and all four readers refuse `null`, with no CLI edit. Both pin files pass there too (32 of 32), and leg 1 takes the refusal branch. - The worktree was removed afterwards, and nothing from it was committed. Round 0, on `a89a8e528`: 228 files / 3234 tests passed, and typecheck, lint, all 62 gates and the citation check each exited 0. ## Not in this PR - objectstack-ai#20206 remains open. It carries the four `packages/lint` sites (`domain:spec`). - objectstack-ai#19926 remains open, but it is not a separate decision. Ruling `5805260775` (letter A) makes `packages: null` malformed at every reader, and core's resolver refuses it. That core change is PR objectstack-ai#20228, which touches no `packages/cli` file. - The four readers here hand `null` to the resolver and do not answer it themselves, so the refusal reaches them when objectstack-ai#20228 lands, with no CLI edit. - Until then, `null` reads as no packages, exactly as before this PR. ## Acceptance notes - **File surface.** The claim named "their tests in `packages/cli/test/`". The new pins live there. The existing `docsPackageRefs` row sits beside its source in `packages/cli/src/utils/collect-docs.package-docs.test.ts`, and it pinned the retired answer, so it had to change in this PR. - **`artifactPackages`** (`packages/cli/src/utils/artifact-packages.ts`) keeps its own `!Array.isArray` guard. That file is outside the claimed surface, so it is untouched. Its docblock says it reads the PARSED stack. Every caller reaches it only after the schema parse, or after one of the four readers has judged the same value: - `compile.ts`, `validate.ts`, `nav-contribution-groups` and `permission-set-name-collisions` pass parsed data; - `lint.ts`, `sdui-manifest.ts` and `lintDocNavTargets` run after `authoringRuleUnionStack` or `docsPackageRefs`. So no public door reaches it with a non-array now. Its carrier is whichever PR next touches that file. - **`os serve`.** `serve.ts` wraps `collectDocsFromSrc` in a catch-all ("docs are additive — never block boot"). A `docsPackageRefs` refusal is swallowed there. The same boot then refuses the stack through `shouldAutoRegisterObjectQL` and the manifest service. Measured: `os serve` still exits 1 with the same sentence. --- _Generated by [Claude Code](https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ckages readers (objectstack-ai#20229) Fixes objectstack-ai#20206 Clause-②: yes (narrowing) Both facts are true and both are read. `yes`: `ERROR_CODE_LEDGER['@objectstack/lint']` (`@objectstack/spec`, published) is a new per-package face, present where it was absent before (rework round 1, fixing a red `check:error-code-provenance`). `narrowing`: `packages/lint` is published, and `os lint` now refuses a `stack.packages` shape it used to accept silently — the spec `packages` array declaration itself does not move; only this reader now honours it (`os validate` and `os build` already refuse a malformed `packages` earlier, at `ObjectStackDefinitionSchema.safeParse`, before ever reaching `packages/lint`'s rules — this PR does not change that door). Two changesets carry the two facts separately: `.changeset/20206-lint-packages-non-array-refused.md` (`@objectstack/lint: minor`, `Clause-②: no (narrowing)`, the ADR-0087 disposition) and `.changeset/20206-lint-error-code-provenance-row.md` (`@objectstack/spec: minor`, `Clause-②: yes`). ## What changed Ruling A on objectstack-ai#15293 (comment 5634034754): a present `packages` that is not an array (`{}`, `0`, `'x'`, a keyed object) is malformed, not absent, and every reader must refuse it. Four `packages/lint` readers fell through `recordsOf(stack.packages)` to `[]` instead: - `validate-object-references.ts:165` (`artifactProvidedObjectNames`) - `validate-translation-references.ts:753, 848, 921` (`contributedNavItemsByApp`, `objectExtensionsByTarget`, `artifactProvidedRecords`) They now share one small reader, `packagesOf(stack)` (`object-graph.ts`), which: - returns `[]` for an absent `packages` (`undefined` ONLY — see the `null` leg below); - reads a well-formed array exactly as `recordsOf` did (junk entries dropped, unchanged); - throws `INVALID_ARTIFACT_PACKAGES` (ADR-0112, `status: 422`) for anything else present. `recordsOf` itself is untouched: it stays the shared map-or-array reader `objects`/`sections`/`tabs` need, where a keyed map is legitimate. `packages` never has a map form, so this is a second, narrower reader rather than a branch on the first one. ## A fifth site, found on re-reading `origin/main` The card's site census was taken at `origin/main` `1c8b320`. This worktree forked from a later `origin/main` that already carries objectstack-ai#20208 (merged), which added a fifth copy of the identical `recordsOf(stack.packages)` pattern: `validate-mapping-target-fields.ts:95` (`extensionFieldsByTarget`). Fixed here under the in-place-fix exemption — same defect class as this card, a mechanical fix with the form already pinned by the other four, the file held by no other claim (objectstack-ai#20208 is merged), same gate family, no new verification surface. This report amends the claim's declared file surface to include `validate-mapping-target-fields.ts` and its test. ## Rework round 1 — the `null` leg (ruling A on objectstack-ai#19926, `5805260775`) `null` is malformed, everywhere. This card originally put `packages: null` out of scope with a pointer to objectstack-ai#19926, but objectstack-ai#19926's own claim fenced `packages/lint` out as its surface — the lint leg had no owner. Per the seat's review comment on objectstack-ai#20206 (5855890525), that leg moves here as the execution of an existing ruling, not a new decision: - `packagesOf` now treats only `undefined` as absent; `null` falls to the same `INVALID_ARTIFACT_PACKAGES` refusal as `{}` / `0` / `'x'` / a keyed object. - The refusal message names `null` as itself (`` `packages` of type null ``) rather than `typeof null`'s `'object'`, which would name a `{}` the author never wrote — the naming objectstack-ai#19926 (PR objectstack-ai#20228) gives `resolveArtifactPackageOrder` once it lands, adopted early here. - Every `null` pin flipped from a silence control to a refusal assertion: `packagesOf` directly (`object-graph.test.ts`), and each of the three public functions its five readers sit behind (`validateObjectReferences`, `validateTranslationReferences`, `validateMappingTargetFields`). `undefined` (absent) and a well-formed array stay green controls. - Ablated: `|| declared === null` restored into the absent branch via `scripts/ablation-replace.mjs` — all four `null` pins (one per test file) went red (`expected function to throw an error, but it didn't`), 228/232 still green, mutation and restore both verified on disk (blob hash back to HEAD, `git diff HEAD` empty). See the report comment for the full readings. ## Rework round 1 — CI fix (error-code provenance) CI was red on the prior head (`bd29ec386f`), job `Lint & Repo Gates`, step 120 "Error-code provenance guard" (`pnpm --filter @objectstack/spec check:error-code-provenance`) — reproduced locally first, quoting the gate's own message: ``` FAIL — 1 stamp site(s) of a registered code with no provenance row: @objectstack/lint stamps 'INVALID_ARTIFACT_PACKAGES' (assign) at packages/lint/src/object-graph.ts:278 — not listed under its own owner key ``` `packagesOf`'s `err.code = 'INVALID_ARTIFACT_PACKAGES'` is a genuine, independent stamp of an already-registered code (deliberately reused from `@objectstack/core`'s `resolveArtifactPackageOrder`, never minted new) — not a case where "a door in another package names the wire vocabulary" (the gate's waiver shape), since `packages/lint`'s rules are pure `(stack) => Finding[]` functions with no door of their own. Fixed the way the gate's own message prescribes: a new `'@objectstack/lint'` row in `packages/spec/src/api/error-code-ledger.zod.ts` listing `INVALID_ARTIFACT_PACKAGES`, with a comment recording the wire path (`door: 'none'`, the objectstack-ai#16449 reading already used for `@objectstack/spec`'s own `STACK_*` rows) — no allowlist, no waiver, no new code. Precedent: `3f9e2eaa1c`, "list plugin-security's class-field error codes under its own ledger key," which used the identical remedy and the identical `@objectstack/spec: minor` / `Clause-②: yes` changeset shape for a new owner-key row. ## Rework round 2 — pin gap and wording (at-tier record `5856823202`, items 1–2) - **Pin gap (item 1):** `validate-object-references.test.ts`'s non-array refusal test pinned only `[null, 42, 'core']`, while the other two validators already pinned `{}`. Added `{}` and a keyed object (`{ a: { manifest: {} } }`, the one shape `recordsOf` read as a map). (Round 3 found `validate-mapping-target-fields.test.ts` still lagged both on the same front — see below; only once that landed did all three validators reach parity.) - **Count, corrected (item 2a):** "four call sites" / "four copies" in the `packagesOf` docblock and the `object-graph.test.ts` describe-block comment now read "five … three files", matching the fifth site (`validate-mapping-target-fields.ts`, above) this PR already fixes. - **Core parity, qualified (item 2b):** on `main`, `resolveArtifactPackageOrder` (`packages/core/src/artifact-packages.ts:208`) still reads `null` as absent and names a refusal with `typeof`; PR objectstack-ai#20228 (objectstack-ai#19926) changes both, and has not landed. Every sentence claiming lint reads `null` "the way `resolveArtifactPackageOrder` does" or raises "the SAME code … for the identical defect" was only ever true for `{}` / `0` / `'x'` / a keyed object today — for `null` it is qualified with "once objectstack-ai#19926 (PR objectstack-ai#20228) lands" instead, in the `packagesOf` docblock, its inline naming comment, this changeset and the ledger-row comment. Wording only; no code changed. - **Door reachability, corrected (item 2c):** re-read `validate.ts:293`, `compile.ts:356`, `lint.ts:673`/`1140`, and `format.ts:369` (`printError`) directly. `os validate` and `os build` both run `ObjectStackDefinitionSchema.safeParse` before the lint readers ever run, and a malformed `packages` refuses there first — the lint reader is unreachable from those two doors for this defect. Only `os lint` reaches it: exit 1, the message on stdout via `printError` (not stderr), `code` present under `--json`. The lint changeset and this PR body (above) are corrected to say exactly that, not "`os validate` / `os lint` / `os build` … on stderr". - **Re-ablated** (same anchor as round 1, now against commit `68398a0e7e`, which carries every round-2 edit): `|| declared === null` restored into `packagesOf`'s absent branch — all four `null` pins (one per test file) went red, 228/232 still passed, mutation and restore both verified on disk (blob hash back to `HEAD`, `git diff HEAD` empty). The two new pins from item 1 (`{}` and a keyed object in `validate-object-references.test.ts`) stayed GREEN through this run — they assert a different branch (the non-null refusal path, untouched by this anchor), confirming the mutation is `null`-specific. Full readings in the report comment. ## Rework round 3 — the mapping validator's own pin gap (in-seat ruling `5857515836`, at-tier record `5857513507`, item 1) - **Pin gap:** `validate-mapping-target-fields.test.ts`'s non-array refusal loop pinned only `[{}, 0, 'x', null]` — no keyed object, and no explicit `packages: undefined` control (only the array control at the `objectExtensions` test above it). Added `{ a: { manifest: {} } }` to the loop, and a dedicated `packages: undefined` control test beside it — targeting `full_name` (a field `contact` declares directly), not `sla_tier` (which resolves via the STACK's own top-level `objectExtensions`, not a package's — `region`, in that same fixture, is the package-supplied one — and this control's minimal fixture declares no `objectExtensions` at all, so `full_name`, a field `contact` declares directly, is the one target that resolves regardless). All three validators now pin the same shape classes: `{}`, a number, a string, `null` and a keyed object, plus an array control and an explicit `undefined` control. - **Wording, corrected:** the round-2 sentence above and `validate-object-references.test.ts`'s matching comment both said "the identical set"/"the same set" before this fix landed, which was false — `validate-mapping-target-fields.test.ts` was still short two cases. Reworded to "the same shape classes" in both places; true now that this round closes the gap. - **Ablated:** pointed `scripts/ablation-replace.mjs` at `packagesOf`'s array-vs-everything-else branch (`if (Array.isArray(declared)) return declared.filter(isRec);`), replacing it with a version that also accepts any `isRec` value the old `recordsOf`-style way. There is no narrower branch to anchor on than this — `{}` and a keyed object share the exact same guard in the implementation, so an ablation of one is necessarily an ablation of both. 5 tests went red: `object-graph.test.ts`'s `{}` and keyed `it.each` cases explicitly, plus all three validators' refusal loops (each stops at its first affected element — `{}` is first in the mapping and translation loops, so the new keyed assertion at the end of the mapping loop is covered by that same failing test rather than isolated on its own). 228/233 still passed; `0` / `'x'` / `null` stayed refused throughout, untouched by this anchor. Mutation and restore both verified on disk (blob hash back to `HEAD`, `git diff HEAD` empty, `git status` clean). Full readings in the report comment. **Landing order**: PR objectstack-ai#20228 landed as `a9fb83ef06`; merged into this branch at `82dc0c9c01` (round 4 below, merge commit `3fef33e23b` plus one wording-fix commit) — `null-packages-follows-resolver.test.ts` leg 1 is green now. ## Pins `packagesOf` is pinned exhaustively in `object-graph.test.ts`: an array is the control (junk-dropping behaviour unchanged), an absent (`undefined`) `packages` stays silent, and `{}` / `0` / `'x'` / a keyed object / `null` are each refused with `code: 'INVALID_ARTIFACT_PACKAGES'`, `status: 422` (the `null` case additionally pins the message names `null`, not `object`). Each of the three public functions these readers sit behind (`validateObjectReferences`, `validateTranslationReferences`, `validateMappingTargetFields`) gets its own throw-pin proving the wiring reaches the shared reader, since all three now call the identical function. One existing pin asserted the OLD fall-through semantics and is flipped: `validate-object-references.test.ts`'s `'ignores a packages value that is not a list of entries'` (`null`, `42`, `'core'` all silently ignored) is now two tests — `undefined` stays the silence control, and `null` / `42` / `'core'` / `{}` / a keyed object (the last two added in round 2) now assert the refusal (code + status), matching the same shape classes the other two validators pin. ## Census (H2) Grepped the whole tree for a non-array `packages` fixture reaching any of the five readers: none besides the one flipped test above. `packages/spec/src/stack-artifact-packages.test.ts` tests the spec schema's own refusal at a different layer and is untouched. ## Gates Local, targeted (container under heavy multi-agent contention — most runs this round queued 5-20+ minutes on the shared `os-verify-lock`, one holder held it ~1150s straight; retried with a stable slot rather than enumerating the whole farm, per contract): - `pnpm --filter @objectstack/spec build && check:generated` — green, all 15 generated artifacts up to date (measured post-merge, against a tree that also absorbed 137 files' worth of unrelated `origin/main` movement — see "Post-merge" below). - `pnpm --filter '@objectstack/lint^...' build` (dependency closure incl. `@objectstack/spec` DTS + `@objectstack/formula`) — green. - `pnpm --filter @objectstack/lint typecheck` (`tsc --noEmit` + `check:test-typecheck`) — green, no new debt. - `pnpm --filter @objectstack/spec exec vitest run src/api/error-code-ledger.test.ts` — 21/21 passed. - `pnpm --filter @objectstack/lint exec vitest run` the five test files — **232/232 passed**, both before and after the merge. - `check:error-code-provenance`, `check:error-code-casing`, `check:dispatcher-error-vocabulary`, `check:strictness-ledger` — all green (the four families `dispatch-gates.mjs` newly derives once the diff touches `packages/spec/src/api/error-code-ledger.zod.ts`). - `check:adr-0087-registration` / `check:changeset-no-major` — green with the updated `yes (narrowing)` declaration (verified with a synthetic `pull_request` event carrying this PR's own line). - `check:nul-bytes`, `check:issue-citations`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:doc-authoring`, `check:type-check-coverage`, `check:published-files`, `check:watch-hint-literal` — all green (unchanged from round 0). **Post-merge**: `origin/main` moved on `packages/spec/src/api/error-code-ledger.zod.ts` (137 files total, mostly unrelated) between round-0 and this round; merged (`a4b05d6e15`, no rebase, no force-push) — clean, no conflicts, our new `'@objectstack/lint'` row and both stamp sites survived intact. Full rebuild + `check:generated` + typecheck + the six test files above all re-run and green against the merged tree. `dispatch-gates.mjs --ran` reconciliation this round: 13 of 86 now-derived families measured locally (the ones above, `check:error-code-provenance` included — this is the family whose local absence let the CI failure through last round); the rest are left to CI, mostly repo-wide `--self-test` checker-health invocations and generated-artifact sub-checks already covered wholesale by the green `check:generated` run above. **Round 2** (head `68398a0e7e`, wording-only + the item-1 pin addition — `packagesOf` semantics unchanged): re-derived `dispatch-gates.mjs --commands` after a fresh `git fetch origin main` — identical 86-family list to round 1 (diff empty), so nothing new to run and nothing skipped. `origin/main` re-checked three times this round (before the commit, before the ablation, and again here): still has not touched any file this PR touches (only `validate-rls-predicate-enforceability.*` and unrelated changesets) — no merge needed this round. Container restarted mid-round (~15:05Z) and killed the in-flight background verification; the worktree and its uncommitted diff survived, the diff was re-verified complete and committed (as `7be6204521`, tree identical to this head) before any ablation or long run, then re-run from scratch, all in the foreground under the shared lock with the same stable slot (`issue-20206-dev-r2`; two `queue-timeout (exit 99)` attempts before it landed — recorded as NOT MEASURED, not as failures, per contract). `check:commit-card-trailers` then refused the first push over a model name in the co-author trailer (this session's own harness-attribution reminder, which the repo's model-free-trailer contract overrides); the tip commit was unpublished, so amended in place to the model-free pair — `git commit --amend`, no force-push, tree byte-identical — landing as `68398a0e7e`: - `pnpm --filter '@objectstack/lint^...' build` — green. - `pnpm --filter @objectstack/lint typecheck` — green, same pre-existing debt as round 1 (2 files / 6 errors / 2 pinned signatures, unrelated, shrink-only), no new debt. - `pnpm --filter @objectstack/lint exec vitest run` the four `packagesOf`-reaching test files — **232/232 passed** (`object-graph.test.ts`, `validate-object-references.test.ts`, `validate-translation-references.test.ts`, `validate-mapping-target-fields.test.ts`). - `pnpm --filter @objectstack/spec exec vitest run src/api/error-code-ledger.test.ts` — 21/21 passed. - `check:error-code-provenance` — green: `self-test OK`, then `scanned 2477 files; 328 registered-code stamp site(s): 312 listed, 16 waived … every registered-code stamp site is listed under its own owner key or carries a recorded waiver (9 waiver(s), all live)`. - `check-adr-0087-registration.mjs --base origin/main` and `check-changeset-no-major.mjs --base origin/main --event` (a synthetic `pull_request` payload carrying this PR's real body, byte for byte) — both green, re-run post-commit; `readClause2Line` on the live body reads `{"kind":"declared","value":"yes","arm":"narrowing"}` — a clean declaration, not the near-miss the seat flipped to its own paragraph round 1 (still on its own line here). **Round 3** (head `42b3bfbf2e`, one bounded patch — `packagesOf`'s function body and the ledger's row entry unchanged since `a4b05d6e15`; their surrounding comments moved in round 2, `68398a0e7e`): PR objectstack-ai#20246 (`443b2f4fdc`) entered the merge queue at 15:58:56Z and reached `main` at 16:17:46Z: after round 3's check and commit (16:09:36Z, amended 16:17:08Z) and before its push (about 16:20Z). Round 4's merge picked it up cleanly. Under the shared lock (stable slot `issue-20206-dev-r3`, lock free both times, no queueing this round): - `pnpm --filter '@objectstack/lint^...' build` — green. - `pnpm --filter @objectstack/lint typecheck` — green, same pre-existing debt, no new debt. - `pnpm --filter @objectstack/lint exec vitest run` the four `packagesOf`-reaching test files — first pass caught a bug in the new control test itself (its mapping target `sla_tier` resolves via the STACK's own `objectExtensions`, not a package's — the control's minimal fixture declares no `objectExtensions` at all, so `packages: undefined` correctly produced a real finding rather than staying silent — fixed by retargeting the control at `full_name`, a field `contact` declares directly, amended into the same unpushed commit); re-run **233/233 passed**. - Ablation: see "Rework round 3" above — mutation landed, 5 tests red (`{}` and keyed pins across all four files, `0`/`'x'`/`null` unaffected), 228/233 passed, restore verified byte-identical to `HEAD`. - `check:commit-card-trailers` — green (model-free trailers carried through the amend). - Landing-order addendum: PR objectstack-ai#20228 has not merged as of this push (checked via the REST API right before pushing); pushed as planned, per the addendum's instruction for that case. ## Round 4 — merge (PR objectstack-ai#20228 landed as `a9fb83ef06`) PR objectstack-ai#20228 merged at 16:40Z. `git fetch origin main && git merge origin/main` (`3fef33e23b`, no rebase, no force-push) — clean, no conflicts. Diff stat, old head (`42b3bfbf2e`) → the merge commit: **287 files changed, 8926 insertions(+), 1939 deletions(-)**, split: - **From `main`**: all 287 files — verified by set-equality against `git diff --name-only a9fb83e origin/main` computed from the pre-merge merge-base (`ab820016b`): identical file lists both directions (`comm -23`/`comm -13` both empty). Nothing else moved. - **Anything else**: empty, by construction — the merge introduced no manual conflict resolution (`git status` was clean immediately after `git merge`, no file was hand-edited as part of it). One shared file, `validate-mapping-target-fields.ts` + its test, was touched by both sides: PR objectstack-ai#20246 (`443b2f4fdc`, "an import mapping target may name a declared part of a compound field") reached `main` at 16:17:46Z, between round 3's commit and its push. Git merged it automatically with no conflict — the new address-part-mapping tests PR objectstack-ai#20246 adds sit above our round-3 additions in the test file, which are untouched by the diff (confirmed directly: `git diff 42b3bfb HEAD -- packages/lint/src/validate-mapping-target-fields.test.ts` shows only PR objectstack-ai#20246's own hunks). A separate at-tier record on the round-3 head (`42b3bfbf2e`, before this merge) found the round-3 comment mis-attributing which `objectExtensions` source resolves `sla_tier` — fixed in `82dc0c9c01`, its own commit, folded into this same push: the fixture's STACK-level `objectExtensions` supplies `sla_tier`; `region` is the one that needs a package. The `full_name` retarget was already correct. That same correction is threaded through this PR body's round-3 bullets above. **Proof**, under the shared lock (stable slot `issue-20206-dev-r4`; severe contention — the `@objectstack/cli^...` dependency closure needed six attempts: four `queue-timeout (exit 99)` (NOT MEASURED, place kept each time), one killed by this session's own 590s foreground wrapper at 54/55 tasks cached from the partial run before it, then a clean finish): - `pnpm exec turbo run build --filter='@objectstack/cli^...' --concurrency=2` — green, 55/55 tasks. - `pnpm --filter @objectstack/cli exec vitest run test/null-packages-follows-resolver.test.ts` — **16/16 passed**, both legs. Leg 1 (`` `objectstack-ai#19925 leg 1: each reader answers `packages: null` the way the real resolver does` ``) includes the named case `` `os lint` lintConfig `` (`READERS[3]`, `:107`) — GREEN, now that `resolveArtifactPackageOrder` genuinely refuses `null` post-objectstack-ai#20228, matching `lintConfig`'s own refusal. Leg 2 (the resolver-double leg) is unaffected either way and stayed green throughout every round. - `pnpm --filter @objectstack/lint typecheck` — green, no new debt. - `pnpm --filter @objectstack/lint exec vitest run` the four `packagesOf`-reaching test files — **237/237 passed** (up from 233: PR objectstack-ai#20246 added 4 tests to `validate-mapping-target-fields.test.ts`; none of the new tests touch `packages`). - `check-adr-0087-registration.mjs --base origin/main` — green, re-run post-merge. - `check:commit-card-trailers` — green on both commits (the merge commit and the wording-fix commit). `origin/main` moved once more after this merge (`17bd318771`, unrelated `InlineAction`/`ViewMetadataParsed` spec types) — checked, touches none of this PR's files; not re-merged, since nothing to pick up. **Round 5** (head `a38a259df6`, wording only): with PR objectstack-ai#20228 in the head, every `null`-parity sentence now states core's refusal in the present tense: the `packagesOf` docblock, its `@throws`, the inline naming comment, the lint changeset and the ledger-row comment. The round-1/2 sections above that say "once … lands" are history. At `a38a259df6` none of the PR's files carries a conditional claim about objectstack-ai#20228 (`git grep` sweep: 0 hits). The seat corrected this body's objectstack-ai#20246 timing (the queue build at 15:58:56Z versus the landing on `main` at 16:17:46Z). ## Acceptance notes None. This PR's scope is exactly the five `recordsOf(stack.packages)` readers described above, their `null` leg (ruling A on objectstack-ai#19926), and the CI-fix ledger row the first two require — the ledger edit is outside the claim's originally declared file surface (`packages/lint/**` + `.changeset/`) but is the coordinator's explicit rework instruction, reproduced and fixed the way the gate itself prescribes. --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…bjectstack-ai#20253) Fixes objectstack-ai#20216 Clause-②: no (narrowing) **Landing order: PR objectstack-ai#20228 → PR objectstack-ai#20229 → this PR.** objectstack-ai#20228 has merged. objectstack-ai#20229 (the `packages[]` read in `artifactProvidedObjectNames`, still in a rework round) edits the same file. This PR does not touch that hunk, its import line, or `packages/lint/src/object-graph.ts`, and a local `git merge-tree` of this branch against objectstack-ai#20229's head `a4b05d6` writes a clean tree. The seat enqueues this PR after objectstack-ai#20229 merges, and this branch merges `origin/main` then. ## CI fix round: `Test Core (6/6)`, and a file-surface amendment - **What went red:** `packages/cli/test/generate-scaffold-validates.test.ts`, "`os g 'view'` writes a stack `os validate` accepts", got `object-reference-unknown at views[0].object: view container object "probe_thing" …`. - **Why:** the harness judged each scaffold in a host stack holding only the collection under test. The view scaffold binds `probe_thing`, the object `os g object probe_thing` writes, but no object was in the stack. The new leg refused it correctly: the omission was the harness's, not the template's. Reproduced red locally before the fix (1 failed, 17 passed). - **Fix (test fixture only):** every `namesObject` generator other than `object` itself (view, action, flow, app) is now judged beside the object scaffold for the same name, materialized through the same `bundle-require` loader. A new pin asserts the view's binding and the seeded object's `name` are the same spelling, and that a non-binding generator (`dashboard`) and `object` itself carry no seeded object. ⛔ The rule is not weakened, `probe_thing` is not special-cased, and no test is skipped. - **File-surface amendment:** `packages/cli/test/generate-scaffold-validates.test.ts`, test fixture only. No other CLI fixture needed a change (see the runs below). - **Other generators:** action, flow and app also bind `probe_thing`. They passed before the seeding and still pass with it. ## What changed A view container's own `object` (`ViewSchema.object`) is the key the runtime indexes views by (`getViewsByObject()` / `GET /meta/view?object=`). Nothing resolved it at authoring time: `defineStack`'s `validateCrossReferences` reads a container's `list.data` / `form.data` bindings, never the container's own key. - `packages/lint/src/validate-object-references.ts` gains a view-container leg beside the relationship-target leg. It uses the same `check` ladder and the same `resolvable` set: the stack's objects plus what its `packages[]` provide. - An unresolved unprefixed name is an **error**, `object-reference-unknown` at `views[N].object`. - A known platform object passes. - A platform-shaped name that nothing registers gets the existing `object-reference-unregistered-platform` advisory. - The refusal is located and carries a prescription: - it lists the objects the stack declares (`Defined objects: ...`); - when the bound name is exactly a declared object minus the stack's `manifest.namespace` prefix, the hint names that object: `write "my_app_order_line", not "order_line"`. - It gates like its sibling. It is the same member of the same reference-integrity suite entry, so `os validate`, `os build` and `os lint` all run it at the same tier. - Not judged, on purpose: - a container with no `object` (its binding falls back to `list.data.object` / `form.data.object` / `name`, which is a different reference); - a runtime-authored container (objectstack-ai#13407 is out of scope). This member does not run on a `view` write at the runtime publish gate, and the runtime is untouched. - ⛔ No second copy in `packages/spec/src/stack.zod.ts`. ## Measured at the public door (CLI built at this branch) The scratch project has `manifest.namespace: 'my_app'`, an object `my_app_order_line`, and a view container with `object: 'order_line'`. | run | `os validate` | `os build` | |:--|:--|:--| | leg disabled (ablation, lint rebuilt, marker proven in `dist/`) | exit 0, "Validation passed", nothing about the view | not run | | this branch, `object: 'order_line'` | exit 1, `object-reference-unknown at views[0].object`, hint names `my_app_order_line` | exit 1, same rule and path | | this branch, `object: 'my_app_order_line'` (control) | exit 0 | exit 0 | `os generate view order_line` in the same namespaced project (after PR objectstack-ai#20214) writes `object: 'my_app_order_line'`, which passes. ## Census of producers (H2) The instrument is one TypeScript-AST scan at `0d60f88760`. It reads `examples/**`, `packages/**` (tests and fixtures included), `skills/**`, and the ts/js code fences in `content/docs/**` md/mdx (generated `references/` excluded). It covers 7242 files and counts object literals that carry a view-container slot (`list`/`form`/`listViews`/`formViews`). A name resolves when it is declared by an object literal (`name` + `fields`) anywhere in the corpus, or when it is a platform-provided object. | tree | containers | carrying `object` | unresolved | |:--|--:|--:|--:| | examples | 10 | 0 | 0 | | packages | 367 | 105 | 11 | | skills | 3 | 0 | 0 | | content/docs | 21 | 0 | 0 | - **Control from the same instrument:** 94 of the 105 containers carrying `object` resolve. - **No example or platform package ships a dangling container.** No example container carries `object` at all; they bind through `list.data.object`. - **The 11 unresolved:** - 10 are non-literal `object` expressions in code, not stored views: schema/`strictObject` definitions in `view.zod.ts`, walkers in `validate-translation-references.ts` / `validate-translatable-sections.ts` / `protocol.ts`, a helper in a rest measurement test, and the parameterised helper in this PR's own test. - 1 literal: `packages/cli/test/format-zod-union.test.ts` (`union_probe_obj`). That specimen fails schema parse first, which that file asserts as exactly one `invalid_union` issue. `os validate` exits at the schema step, so author-time rules never run on it. No pin flips. - **Pin sweep ①:** grepping `object-reference-unknown` and the rule's message across the repo found no pin on a view container. The pins in `packages/cli` (`artifact-packages.test.ts`, `build-multi-package-artifact.e2e.test.ts`, `union-fold-command-parity.test.ts`) have fixtures with no container `object`, so none flips. **②:** nothing flipped, so no load-bearing re-pin was owed. ## Tests (at `60808317dc` unless marked) - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2`: **109 files / 4242 tests passed**. - `pnpm --filter @objectstack/lint typecheck`: exit 0 (`tsc --noEmit` plus `check:test-typecheck: OK`). - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: **227 files / 3219 tests passed**. This includes the fixed `generate-scaffold-validates.test.ts` (19 of 19). - CLI integration tier, the 21 files that reference views or scaffolds (`--project integration`): **21 files / 196 tests passed**. - `pnpm --filter @objectstack/cli typecheck`: exit 0 (`check:test-typecheck: OK`). - Nightly tier (`OS_TEST_TIERS=nightly`), 7 e2e files on views or scaffolds: 6 files passed. One test failed in `generate-agent-retired.e2e.test.ts` ("`os g object … --dry-run` still previews a typed object file"). It expects `import * as Data from '@objectstack/spec/data'`, but the object template writes `import { ObjectSchema }` since objectstack-ai#20195. That failure is independent of this PR: neither file differs from the merge base (0 diff lines). - New pins in `validate-object-references.test.ts` (two new `describe` blocks, appended so they stay clear of objectstack-ai#20229's hunk): - `object: 'order_line'` in a `my_app` stack is refused, naming `my_app_order_line`; the prefixed container is the control; - the map form of `views` is read; - a non-prefix miss is refused without the namespace prescription; - a container over a `packages[]` sibling's object passes, and the same package alone is refused (control); - `sys_user` passes and `sys_approval_process` advises; - no views, `views: []`, and a container with no `object` stay silent; - the finding reaches the gating tier of `runAuthoringRules` for `validate`, `build` and `lint`. - **Unit ablation** (`scripts/ablation-replace.mjs`, anchor `const bound = strName(view.object);`, anchor count 1 to 0, blob `27699c9` to `03f6f47`): **8 refusal pins red; 48 green, including both controls (prefixed container, silence).** Restored with blob equal to HEAD and `git diff HEAD` empty. Taken at `9fa1ff4c61`. Since then only comment lines changed in `src`. - **Door ablation:** marker planted, `@objectstack/lint` rebuilt, `ablation-dist-preflight` found the marker in 4 built files, and `os validate` exited 0. Then the restore leg: blob equal to HEAD, lint rebuilt, preflight `--absent` confirmed the marker gone from all 14 built files with a clean tree, and `os validate` exited 1 again. ## Gates (derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `60808317dc`) - 60 families derived (one new: `check:cli-test-child-env`, green) and all run at `60808317dc`. `--ran` reconciliation with exit codes: 59 run, 1 NOT MEASURED, 0 unrun. - The ones this diff actually moves are green: `check-adr-0087-registration` (disposition `not-required (no-migration-prescription)` accepted), `check-changeset-no-major`, `check-empty-changeset`, `check:doc-authoring`, `check:nul-bytes`, `check-closing-keyword-parity`, `check:published-files`, `check:type-check-coverage`, `check:test-source-alias`. - `check:type-check-debt` now measures green (`none above its recorded number`). - NOT MEASURED: `check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT MET: it needs every package's `dist/`, and this box built the lint and CLI closures only). Declared to CI. - Correction to the first round: I declared the `packages/cli` suites to CI without running them, and the census missed `os g view` because its container sits inside a template string the AST scan cannot see. That is the red this round fixes. The CLI unit project now runs in full here. ## Changeset `.changeset/20216-view-container-object-refused.md`: - `@objectstack/lint: minor`, carrying a **BREAKING** banner and `Clause-②: no (narrowing)`; - a before/after accept-set table; - ADR-0087 `not-required (no-migration-prescription)`: nothing authorable moves in spec, and which object an author meant is a fact about their stack, not a mechanical conversion. The table is a behaviour table (door: FROM exit 0, TO exit 1). My first draft headed it "FROM → TO", and the ADR-0087 gate read that heading as a rewrite prescription and refused the exemption. The heading now says "before and after", and the table carries no rewrite row. ## Acceptance notes (not filed) - The namespace prescription is on this leg only. The other sites on the same rule (a field `reference`, action params, dataset base object) could give the same hint for the same missing-prefix miss. I noted it and did not widen it here. Carrier: none. - A container whose `object` and `list.data.object` name different objects is not judged by any rule. It is out of this card's scope, and I found no instance in the census. --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19926
Clause-②: no (narrowing)
Executes ruling
5805260775(letter A, class-1):packages: nullon a release artifact is malformed and refused, never read as absent.ObjectStackDefinitionSchema.packagesisz.array(ArtifactPackageSchema).optional(), and.optional()admitsundefined, notnull. The schema andcomposeStacks(two or more inputs) already refusednull; every runtime reader read it as absent. The readers now follow the declaration. ⛔ The schema does not change, and no error code is added:nullgets the envelope{},0and'x'already get,INVALID_ARTIFACT_PACKAGES/status: 422.Per reader, before and after (
packages: null)@objectstack/coreresolveArtifactPackageOrder[artifact](the absent branch)INVALID_ARTIFACT_PACKAGES/ 422@objectstack/runtimeresolveArtifactCollections@objectstack/plugin-devstackDeclaresTranslations(viadevI18nPluginOptions)false@objectstack/plugin-securityappSecurityPluginOptionsObjectStackDefinitionSchemainvalid_typecomposeStacks, two or more inputsSTACK_SCHEMA_INVALID/ 422Controls stay put at every reader: an absent
packages(no key, or an explicitundefined) is still the single-package branch, by identity; an array is still read as its entries.The ruling's three items, and where each landed
resolveArtifactPackageOrderdrops itsdeclared === nullbranch, so its@throws/ header wording 「present but is not an array」 is now literally true. The runtime reader's guard and the plugin-dev guard are spelled exactly as the resolver's absent branch (undefinedonly), and they moved in the same commit. plugin-security has no guard of its own; it inherits the refusal, and its docblock no longer names anullabsent branch. The core message names the valuenullinstead oftypeof null(which readsobject).nullis refused at the schema (invalid_typeatpackages), atcomposeStackswith two inputs in both positions (STACK_SCHEMA_INVALID, 422, issue pathpackages), and at each reader (code+status), each beside its absent and array controls. The plugin-dev LOCKSTEP pin asks the private guard andresolveArtifactPackageOrderdirectly and asserts they return the same envelope on{ packages: null }and agree on an absent key. That is the pin the PR fix(runtime): refuse a non-arraypackagesin resolveArtifactCollections #19924 re-review asked for.AssembledPackageBodySchemathat carvednullout ("the one value this rule does not settle") now namesnullas malformed. It is TSDoc only; the schema bytes are unchanged.Pin sweep
git grepover every test forpackagesnext tonullfound three pins asserting the old absent reading:packages/runtime/src/artifact-collections.test.ts,packages/plugins/plugin-dev/src/dev-i18n-packages-reader.test.tsandpackages/plugins/plugin-security/src/app-default-permission-set.test.ts. All three are flipped in this PR. Eachnullrow now asserts the refusal's substance (codeandstatus); none of the flips just deletes an assertion.packages/lint/src/validate-object-references.test.tsalso iteratesnulloverpackages. It pins the lint reader, which is sibling #20206's surface and is fenced out of this claim. It is not touched here.Producer census (mechanism hypothesis H3)
No in-repo producer writes
packages: null. The census coveredexamples/, fixtures, tests and generated artifacts, withpackagesfollowed by:andnull, quoted or bare. The only hits were the three test pins above and the pending.changeset/15293-non-array-packages-refusal.mdsentence below.os build/os validaterefuse the value at the schema before any reader runs.Clause-② — measured, and it differs from the claim's line
The claim reads
Clause-②: no. Measured: the accept set of published exports narrows.resolveArtifactPackageOrder(@objectstack/coreroot export),devI18nPluginOptions(@objectstack/plugin-dev),appSecurityPluginOptions(@objectstack/plugin-security) andcarriedPackageIds(@objectstack/runtime) all returned an answer for{ packages: null }and now throw. So the line isno (narrowing): breaking, gradedminorunder the launch-window convention, in this body and in the changeset. The ADR-0087 disposition isnot-required (no-migration-prescription). Nothing authorable moves, because the schema already refused the value. This follows the.changeset/18239-merge-objects-refusal.mdprecedent (a runtime narrowing on inputs that bypassed the parse).check-adr-0087-registrationreads it green.Deviations from the claim's file surface (declared, not silent)
packages/spec/src/stack.zod.ts: the claim fences this file ("the schema already refusesnull"). The edit is ruling item 3's rule text, and it is TSDoc only; the schema is unchanged, which is what the fence protects. Without it, the one statement of the rule that all four readers cite would still say the readers treatnullas absent, and this PR would make that false.packages/spec/src/stack-artifact-packages.test.ts: ruling item 2's schema andcomposeStackspins.packages/core/src/artifact-packages.test.ts(new): core had no in-package test for the resolver. Its broader pins live in@objectstack/objectql'sartifact-load-path.test.ts..changeset/15293-non-array-packages-refusal.md: a DELIBERATE CORRECTION of a pending release note (next section).Pending release note corrected, confirmation requested
.changeset/15293-non-array-packages-refusal.md(PR #19924, still pending) said under "What does not change": "an absentpackages, andpackages: null, still return the caller's own object by identity". This PR makes thenullhalf false in the same release, so the sentence now reads "an absentpackagesstill returns the caller's own object by identity …packages: nullis not absent: it is malformed, and it is refused the same way (#19926)."check-empty-changesetrefuses this by design ("DELIBERATE CORRECTION -- ... say so on the PR and get it confirmed"). Check Changeset stays red until a person confirms the correction here; it is not a required context.skip-changesetis not applied.Verification (every reading below is at head
ec9402ad05)All runs were serialized behind
scripts/pm/os-verify-lock.sh, and each read itsVERDICT command-exitline.pnpm turbo run build --filter='@objectstack/plugin-dev^...' --concurrency=2(the closure of core, runtime, plugin-security, spec and the rest): 34/34 tasks successful,VERDICT command-exit 0.@objectstack/core(projectlocal): 55 files, 1426 tests passed.@objectstack/plugin-security: 137 files, 2756 passed.@objectstack/plugin-dev: 8 files, 82 passed.@objectstack/runtime(projectlocal): 279 files, 3910 passed, 1 skipped.@objectstack/spec:stack-artifact-packages,assembled-package-bodyandcompose-stacks-concat-shape-refusal, 3 files, 160 passed. Resolver consumers: objectqlartifact-load-path14/14, metadataplugin-artifact-packages-attribution11/11, verifyartifact-collections8/8, cli (unit)stack-collections16/16. The pin sweep found no other test that feedspackages: null.typecheckfor core, plugin-security, plugin-dev and runtime: all exit 0, each echoingtsc --noEmitandcheck:test-typecheck: OK.pnpm --filter @objectstack/spec check:generated: all 15 generated artifacts up to date (itscheck:test-typecheckincluded), measured against the specdistbuilt above.scripts/ablation-replace.mjs: anchor hit 1 to 0, blob moved, and after the restore the blob equalsHEAD's andgit diff HEADis empty.|| declared === nullback into the core resolver's absent branch (src). Red: corenullrow (1 of 6 failed), runtimenullrow (1 of 22), plugin-devnullrow plus LOCKSTEP (2 of 20).ablation-dist-preflightfound the marker in 2 built files. Red: plugin-securitynullrow (1 of 28). Restore leg: rebuilt,--absentpassed (marker in 0 of 14 built files, tree clean), 28 of 28 green.|| packages === null. Red: thenullrow and LOCKSTEP (2 of 20). That is the lockstep pin catching a guard that disagrees with a fixed resolver.nullrow (1 of 22).HEAD, and a--reporter=verbosere-run of all five files lists every new or flipped case green.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ranreconciles 87 derived, 84 run, 3 NOT MEASURED, 0 unrun. Of the 84 run, 83 exit 0 andcheck-empty-changesetexits 1: the deliberate correction above, left red on purpose.check-adr-0087-registrationpasses: one declared-breaking changeset, dispositionnot-required (no-migration-prescription).check-changeset-no-major: nomajor(the level axis reads the PR, so it is judged in CI).check:dual-build-cjs-loads,check:i18nandcheck:type-check-debt. Reason: each exited 3, PREREQUISITE NOT MET. They read a whole-repo build (dist/of packages outside this closure), which CI builds.eslint --no-inline-config --format jsonover the 10 changed.tsfiles: 10 files linted, 0 errors, 0 warnings.eslint.config.mjsenables no type-aware linting (noparserOptions.project, stated in its own header), so this diff cannot move any untouched file's verdict. The fullpnpm lintis CI's.origin/mainis 5 commits past this branch's base, and none of them touches a path in this diff (git diff --statof those paths is empty). The merge queue validates the merged generation.Acceptance notes
packages/lint(validate-object-references) still ignorespackages: null, like any non-list. Sibling lint: fourrecordsOf(stack.packages)readers treat a non-arraypackagesas "no packages" instead of refusing it — the packages/lint half of #19925 (ruling #15293-A) #20206 owns the lint readers; carrier: lint: fourrecordsOf(stack.packages)readers treat a non-arraypackagesas "no packages" instead of refusing it — the packages/lint half of #19925 (ruling #15293-A) #20206.packages/cli/src/utils/stack-collections.tspackageBodies,artifact-packages.tsartifactPackages) testArray.isArrayand read every non-array as "no packages". They run after the strict schema parse has already refusednull,{},0and'x', sonullnever reaches them throughos build/os validate. Noted, not filed; carrier: none.Generated by Claude Code