fix(lint): refuse a present non-array packages at all five stack.packages readers - #20229
Conversation
…rdsOf(stack.packages) readers Ruling A on #15293 (comment 5634034754): a `packages` that is present but not an array (`{}`, `0`, `'x'`, a keyed object) is malformed, not absent, and every reader refuses it. Four `packages/lint` readers fell through `recordsOf(stack.packages)` to `[]` instead of refusing: `validate-object-references.ts`'s `artifactProvidedObjectNames`, and `validate-translation-references.ts`'s `contributedNavItemsByApp`, `objectExtensionsByTarget` and `artifactProvidedRecords`. A fifth copy of the same pattern (`validate-mapping-target-fields.ts`'s `extensionFieldsByTarget`) landed via #20208 after this ruling's site census and is fixed the same way. All five now share one small reader, `packagesOf(stack)` (`object-graph.ts`): absent stays `[]`, an array is read exactly as `recordsOf` read it (junk entries dropped, unchanged), and anything else present throws `INVALID_ARTIFACT_PACKAGES` (ADR-0112, status 422) -- the same registered code `@objectstack/core`'s `resolveArtifactPackageOrder` already raises for the identical defect. `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. Pins: `packagesOf` is pinned exhaustively (array control, absent/null silence, refusal on `{}`/`0`/`'x'`/a keyed object, with code + status). Each of the three public functions these readers sit behind (`validateObjectReferences`, `validateTranslationReferences`, `validateMappingTargetFields`) gets one throw-pin proving the wiring reaches the shared reader. One existing pin asserted the old fall-through semantics (`validate-object-references.test.ts`, "ignores a `packages` value that is not a list of entries") and is flipped: `null` stays a silence control, `42`/`'core'` now assert the refusal. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
📓 Docs Drift CheckThis PR changes 2 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 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 cd8fcf47b9bf2b9fae0233211a7b2e91d8e00e76 && git checkout cd8fcf47b9bf2b9fae0233211a7b2e91d8e00e76
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 17bd31877109b7cc692e7e54c4fe39f82a5c32d5 a38a259df60e106370f2ea9e9060e12bab8f59b6 && git checkout -B drift-repro 17bd31877109b7cc692e7e54c4fe39f82a5c32d5 && git merge --no-ff a38a259df60e106370f2ea9e9060e12bab8f59b6
node scripts/docs-audit/affected-docs.mjs --json 17bd31877109b7cc692e7e54c4fe39f82a5c32d5
|
…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
…nt-packages-non-array-refused
Contract reviewServed-tier: 86/86 ① Derived judgments
② Semver levelCorrect. Probe of ③ Boundary flags
Implemented-by: VERDICT: FAIL |
…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
Contract reviewServed-tier: 145/145 ① Derived judgments
② Semver levelCorrect. ③ Boundary flags
Implemented-by: VERDICT: FAIL |
REWORK round 3 for #20206 (in-seat ruling 5857515836, at-tier record 5857513507, item 1 only): 1. `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 :97). Add the keyed object `{ a: { manifest: {} } }` to the loop, and a dedicated `packages: undefined` control test beside it, matching what the other two validators already pin. 2. Reword the one place that over-claimed the result before this fix landed: `validate-object-references.test.ts`'s comment said "pins the same set `packagesOf` and the other two validators do" -- now "pins the same shape classes ...", since the concrete number/string representatives differ across validators (42/'core' vs 0/'x') even though the shape classes now match everywhere. The PR body carries the matching correction. Nothing else moves: packagesOf semantics, the five readers, the ledger row, the door sentences and the null-parity conditioning are all unchanged since 68398a0. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
…nt-packages-non-array-refused
Contract reviewServed-tier: 69/69 ① Derived judgments
② Semver levelUnchanged and correct. ③ Boundary flags
Implemented-by: VERDICT: FAIL |
… named Addendum on the merge-only round for #20206: the round-3 CONTROL test's comment said `sla_tier` "only resolves via the objectExtensions a package supplies" -- in the fixture it actually cites (:173-174), `sla_tier` comes from the STACK's own top-level objectExtensions, and `region` is the package-supplied one. Reword to say that. The `full_name` retarget itself was already correct and is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
Contract reviewServed-tier: 43/43 ① Derived judgments
② Semver levelCorrect. ③ Boundary flags
Implemented-by: VERDICT: FAIL |
…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
Contract reviewServed-tier: 79/79 ① Derived judgments
② Semver levelUnchanged and correct. The delta to ③ Boundary flags
Implemented-by: VERDICT: FAIL |
Contract reviewServed-tier: 107/107 ① Derived judgments
② Semver levelUnchanged and correct. The delta to ③ Boundary flags
Implemented-by: VERDICT: PASS |
…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 #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 redcheck:error-code-provenance).narrowing:packages/lintis published, andos lintnow refuses astack.packagesshape it used to accept silently — the specpackagesarray declaration itself does not move; only this reader now honours it (os validateandos buildalready refuse a malformedpackagesearlier, atObjectStackDefinitionSchema.safeParse, before ever reachingpackages/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 #15293 (comment 5634034754): a present
packagesthat is not an array ({},0,'x', a keyed object) is malformed, not absent, and every reader must refuse it. Fourpackages/lintreaders fell throughrecordsOf(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:[]for an absentpackages(undefinedONLY — see thenullleg below);recordsOfdid (junk entries dropped, unchanged);INVALID_ARTIFACT_PACKAGES(ADR-0112,status: 422) for anything else present.recordsOfitself is untouched: it stays the shared map-or-array readerobjects/sections/tabsneed, where a keyed map is legitimate.packagesnever 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/mainThe card's site census was taken at
origin/main1c8b320. This worktree forked from a laterorigin/mainthat already carries #20208 (merged), which added a fifth copy of the identicalrecordsOf(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 (#20208 is merged), same gate family, no new verification surface. This report amends the claim's declared file surface to includevalidate-mapping-target-fields.tsand its test.Rework round 1 — the
nullleg (ruling A on #19926,5805260775)nullis malformed, everywhere. This card originally putpackages: nullout of scope with a pointer to #19926, but #19926's own claim fencedpackages/lintout as its surface — the lint leg had no owner. Per the seat's review comment on #20206 (5855890525), that leg moves here as the execution of an existing ruling, not a new decision:packagesOfnow treats onlyundefinedas absent;nullfalls to the sameINVALID_ARTIFACT_PACKAGESrefusal as{}/0/'x'/ a keyed object.nullas itself (`packages` of type null) rather thantypeof null's'object', which would name a{}the author never wrote — the namingpackages: nullon a release artifact: the schema andcomposeStacksrefuse it, while every reader reads it as absent #19926 (PR fix(core,runtime,plugin-dev,plugin-security): refusepackages: nullas malformed, never absent #20228) givesresolveArtifactPackageOrderonce it lands, adopted early here.nullpin flipped from a silence control to a refusal assertion:packagesOfdirectly (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.|| declared === nullrestored into the absent branch viascripts/ablation-replace.mjs— all fournullpins (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 HEADempty). See the report comment for the full readings.Rework round 1 — CI fix (error-code provenance)
CI was red on the prior head (
bd29ec386f), jobLint & 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:packagesOf'serr.code = 'INVALID_ARTIFACT_PACKAGES'is a genuine, independent stamp of an already-registered code (deliberately reused from@objectstack/core'sresolveArtifactPackageOrder, never minted new) — not a case where "a door in another package names the wire vocabulary" (the gate's waiver shape), sincepackages/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 inpackages/spec/src/api/error-code-ledger.zod.tslistingINVALID_ARTIFACT_PACKAGES, with a comment recording the wire path (door: 'none', the #16449 reading already used for@objectstack/spec's ownSTACK_*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-②: yeschangeset shape for a new owner-key row.Rework round 2 — pin gap and wording (at-tier record
5856823202, items 1–2)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 shaperecordsOfread as a map). (Round 3 foundvalidate-mapping-target-fields.test.tsstill lagged both on the same front — see below; only once that landed did all three validators reach parity.)packagesOfdocblock and theobject-graph.test.tsdescribe-block comment now read "five … three files", matching the fifth site (validate-mapping-target-fields.ts, above) this PR already fixes.main,resolveArtifactPackageOrder(packages/core/src/artifact-packages.ts:208) still readsnullas absent and names a refusal withtypeof; PR fix(core,runtime,plugin-dev,plugin-security): refusepackages: nullas malformed, never absent #20228 (packages: nullon a release artifact: the schema andcomposeStacksrefuse it, while every reader reads it as absent #19926) changes both, and has not landed. Every sentence claiming lint readsnull"the wayresolveArtifactPackageOrderdoes" or raises "the SAME code … for the identical defect" was only ever true for{}/0/'x'/ a keyed object today — fornullit is qualified with "oncepackages: nullon a release artifact: the schema andcomposeStacksrefuse it, while every reader reads it as absent #19926 (PR fix(core,runtime,plugin-dev,plugin-security): refusepackages: nullas malformed, never absent #20228) lands" instead, in thepackagesOfdocblock, its inline naming comment, this changeset and the ledger-row comment. Wording only; no code changed.validate.ts:293,compile.ts:356,lint.ts:673/1140, andformat.ts:369(printError) directly.os validateandos buildboth runObjectStackDefinitionSchema.safeParsebefore the lint readers ever run, and a malformedpackagesrefuses there first — the lint reader is unreachable from those two doors for this defect. Onlyos lintreaches it: exit 1, the message on stdout viaprintError(not stderr),codepresent 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".68398a0e7e, which carries every round-2 edit):|| declared === nullrestored intopackagesOf's absent branch — all fournullpins (one per test file) went red, 228/232 still passed, mutation and restore both verified on disk (blob hash back toHEAD,git diff HEADempty). The two new pins from item 1 ({}and a keyed object invalidate-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 isnull-specific. Full readings in the report comment.Rework round 3 — the mapping validator's own pin gap (in-seat ruling
5857515836, at-tier record5857513507, item 1)validate-mapping-target-fields.test.ts's non-array refusal loop pinned only[{}, 0, 'x', null]— no keyed object, and no explicitpackages: undefinedcontrol (only the array control at theobjectExtensionstest above it). Added{ a: { manifest: {} } }to the loop, and a dedicatedpackages: undefinedcontrol test beside it — targetingfull_name(a fieldcontactdeclares directly), notsla_tier(which resolves via the STACK's own top-levelobjectExtensions, not a package's —region, in that same fixture, is the package-supplied one — and this control's minimal fixture declares noobjectExtensionsat all, sofull_name, a fieldcontactdeclares directly, is the one target that resolves regardless). All three validators now pin the same shape classes:{}, a number, a string,nulland a keyed object, plus an array control and an explicitundefinedcontrol.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.tswas still short two cases. Reworded to "the same shape classes" in both places; true now that this round closes the gap.scripts/ablation-replace.mjsatpackagesOf's array-vs-everything-else branch (if (Array.isArray(declared)) return declared.filter(isRec);), replacing it with a version that also accepts anyisRecvalue the oldrecordsOf-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 keyedit.eachcases 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'/nullstayed refused throughout, untouched by this anchor. Mutation and restore both verified on disk (blob hash back toHEAD,git diff HEADempty,git statusclean). Full readings in the report comment.Landing order: PR #20228 landed as
a9fb83ef06; merged into this branch at82dc0c9c01(round 4 below, merge commit3fef33e23bplus one wording-fix commit) —null-packages-follows-resolver.test.tsleg 1 is green now.Pins
packagesOfis pinned exhaustively inobject-graph.test.ts: an array is the control (junk-dropping behaviour unchanged), an absent (undefined)packagesstays silent, and{}/0/'x'/ a keyed object /nullare each refused withcode: 'INVALID_ARTIFACT_PACKAGES',status: 422(thenullcase additionally pins the message namesnull, notobject). 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 —undefinedstays the silence control, andnull/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
packagesfixture reaching any of the five readers: none besides the one flipped test above.packages/spec/src/stack-artifact-packages.test.tstests 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 unrelatedorigin/mainmovement — see "Post-merge" below).pnpm --filter '@objectstack/lint^...' build(dependency closure incl.@objectstack/specDTS +@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 runthe 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 familiesdispatch-gates.mjsnewly derives once the diff touchespackages/spec/src/api/error-code-ledger.zod.ts).check:adr-0087-registration/check:changeset-no-major— green with the updatedyes (narrowing)declaration (verified with a syntheticpull_requestevent 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/mainmoved onpackages/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 --ranreconciliation this round: 13 of 86 now-derived families measured locally (the ones above,check:error-code-provenanceincluded — this is the family whose local absence let the CI failure through last round); the rest are left to CI, mostly repo-wide--self-testchecker-health invocations and generated-artifact sub-checks already covered wholesale by the greencheck:generatedrun above.Round 2 (head
68398a0e7e, wording-only + the item-1 pin addition —packagesOfsemantics unchanged): re-deriveddispatch-gates.mjs --commandsafter a freshgit fetch origin main— identical 86-family list to round 1 (diff empty), so nothing new to run and nothing skipped.origin/mainre-checked three times this round (before the commit, before the ablation, and again here): still has not touched any file this PR touches (onlyvalidate-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 (as7be6204521, 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; twoqueue-timeout (exit 99)attempts before it landed — recorded as NOT MEASURED, not as failures, per contract).check:commit-card-trailersthen 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 as68398a0e7e: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 runthe fourpackagesOf-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, thenscanned 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/mainandcheck-changeset-no-major.mjs --base origin/main --event(a syntheticpull_requestpayload carrying this PR's real body, byte for byte) — both green, re-run post-commit;readClause2Lineon 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 sincea4b05d6e15; their surrounding comments moved in round 2,68398a0e7e): PR #20246 (443b2f4fdc) entered the merge queue at 15:58:56Z and reachedmainat 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 slotissue-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 runthe fourpackagesOf-reaching test files — first pass caught a bug in the new control test itself (its mapping targetsla_tierresolves via the STACK's ownobjectExtensions, not a package's — the control's minimal fixture declares noobjectExtensionsat all, sopackages: undefinedcorrectly produced a real finding rather than staying silent — fixed by retargeting the control atfull_name, a fieldcontactdeclares directly, amended into the same unpushed commit); re-run 233/233 passed.{}and keyed pins across all four files,0/'x'/nullunaffected), 228/233 passed, restore verified byte-identical toHEAD.check:commit-card-trailers— green (model-free trailers carried through the amend).packages: nullas malformed, never absent #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 #20228 landed as
a9fb83ef06)PR #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:main: all 287 files — verified by set-equality againstgit diff --name-only a9fb83ef06 origin/maincomputed from the pre-merge merge-base (ab820016b): identical file lists both directions (comm -23/comm -13both empty). Nothing else moved.git statuswas clean immediately aftergit 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 #20246 (443b2f4fdc, "an import mapping target may name a declared part of a compound field") reachedmainat 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 #20246 adds sit above our round-3 additions in the test file, which are untouched by the diff (confirmed directly:git diff 42b3bfbf2e HEAD -- packages/lint/src/validate-mapping-target-fields.test.tsshows only PR #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 whichobjectExtensionssource resolvessla_tier— fixed in82dc0c9c01, its own commit, folded into this same push: the fixture's STACK-levelobjectExtensionssuppliessla_tier;regionis the one that needs a package. Thefull_nameretarget 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: fourqueue-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 (`#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 thatresolveArtifactPackageOrdergenuinely refusesnullpost-fix(core,runtime,plugin-dev,plugin-security): refusepackages: nullas malformed, never absent #20228, matchinglintConfig'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 runthe fourpackagesOf-reaching test files — 237/237 passed (up from 233: PR feat(spec,rest,lint): an import mapping target may name a declared part of a compound field (mailing_address.street) #20246 added 4 tests tovalidate-mapping-target-fields.test.ts; none of the new tests touchpackages).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/mainmoved once more after this merge (17bd318771, unrelatedInlineAction/ViewMetadataParsedspec 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 #20228 in the head, everynull-parity sentence now states core's refusal in the present tense: thepackagesOfdocblock, 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. Ata38a259df6none of the PR's files carries a conditional claim about #20228 (git grepsweep: 0 hits). The seat corrected this body's #20246 timing (the queue build at 15:58:56Z versus the landing onmainat 16:17:46Z).Acceptance notes
None. This PR's scope is exactly the five
recordsOf(stack.packages)readers described above, theirnullleg (ruling A on #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