Skip to content

fix(core,runtime,plugin-dev,plugin-security): refuse packages: null as malformed, never absent - #20228

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19926-packages-null-refused
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19926-packages-null-refused

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #19926
Clause-②: no (narrowing)

Executes ruling 5805260775 (letter A, class-1): packages: null on a release artifact is malformed and refused, never read as absent. ObjectStackDefinitionSchema.packages is z.array(ArtifactPackageSchema).optional(), and .optional() admits undefined, not null. The schema and composeStacks (two or more inputs) already refused null; every runtime reader read it as absent. The readers now follow the declaration. ⛔ The schema does not change, and no error code is added: null gets the envelope {}, 0 and 'x' already get, INVALID_ARTIFACT_PACKAGES / status: 422.

Per reader, before and after (packages: null)

reader before after
@objectstack/core resolveArtifactPackageOrder [artifact] (the absent branch) refused, INVALID_ARTIFACT_PACKAGES / 422
@objectstack/runtime resolveArtifactCollections the argument, by identity refused (the resolver's envelope)
@objectstack/plugin-dev stackDeclaresTranslations (via devI18nPluginOptions) false refused (the resolver's envelope)
@objectstack/plugin-security appSecurityPluginOptions read the top level refused (the resolver's envelope)
ObjectStackDefinitionSchema refused, invalid_type unchanged
composeStacks, two or more inputs refused, STACK_SCHEMA_INVALID / 422 unchanged

Controls stay put at every reader: an absent packages (no key, or an explicit undefined) 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

  1. Readers. resolveArtifactPackageOrder drops its declared === null branch, 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 (undefined only), 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 a null absent branch. The core message names the value null instead of typeof null (which reads object).
  2. Pins. null is refused at the schema (invalid_type at packages), at composeStacks with two inputs in both positions (STACK_SCHEMA_INVALID, 422, issue path packages), and at each reader (code + status), each beside its absent and array controls. The plugin-dev LOCKSTEP pin asks the private guard and resolveArtifactPackageOrder directly 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-array packages in resolveArtifactCollections #19924 re-review asked for.
  3. Spec rule text. The paragraph beside AssembledPackageBodySchema that carved null out ("the one value this rule does not settle") now names null as malformed. It is TSDoc only; the schema bytes are unchanged.

Pin sweep

git grep over every test for packages next to null found three pins asserting the old absent reading: packages/runtime/src/artifact-collections.test.ts, packages/plugins/plugin-dev/src/dev-i18n-packages-reader.test.ts and packages/plugins/plugin-security/src/app-default-permission-set.test.ts. All three are flipped in this PR. Each null row now asserts the refusal's substance (code and status); none of the flips just deletes an assertion. packages/lint/src/validate-object-references.test.ts also iterates null over packages. 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 covered examples/, fixtures, tests and generated artifacts, with packages followed by : and null, quoted or bare. The only hits were the three test pins above and the pending .changeset/15293-non-array-packages-refusal.md sentence below. os build / os validate refuse 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/core root export), devI18nPluginOptions (@objectstack/plugin-dev), appSecurityPluginOptions (@objectstack/plugin-security) and carriedPackageIds (@objectstack/runtime) all returned an answer for { packages: null } and now throw. So the line is no (narrowing): breaking, graded minor under the launch-window convention, in this body and in the changeset. The ADR-0087 disposition is not-required (no-migration-prescription). Nothing authorable moves, because the schema already refused the value. This follows the .changeset/18239-merge-objects-refusal.md precedent (a runtime narrowing on inputs that bypassed the parse). check-adr-0087-registration reads 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 refuses null"). 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 treat null as absent, and this PR would make that false.
  • packages/spec/src/stack-artifact-packages.test.ts: ruling item 2's schema and composeStacks pins.
  • packages/core/src/artifact-packages.test.ts (new): core had no in-package test for the resolver. Its broader pins live in @objectstack/objectql's artifact-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 absent packages, and packages: null, still return the caller's own object by identity". This PR makes the null half false in the same release, so the sentence now reads "an absent packages still returns the caller's own object by identity … packages: null is not absent: it is malformed, and it is refused the same way (#19926)." check-empty-changeset refuses 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-changeset is not applied.

Verification (every reading below is at head ec9402ad05)

All runs were serialized behind scripts/pm/os-verify-lock.sh, and each read its VERDICT command-exit line.

  • Build. 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.
  • Affected packages, whole suites. @objectstack/core (project local): 55 files, 1426 tests passed. @objectstack/plugin-security: 137 files, 2756 passed. @objectstack/plugin-dev: 8 files, 82 passed. @objectstack/runtime (project local): 279 files, 3910 passed, 1 skipped.
  • Targeted. @objectstack/spec: stack-artifact-packages, assembled-package-body and compose-stacks-concat-shape-refusal, 3 files, 160 passed. Resolver consumers: objectql artifact-load-path 14/14, metadata plugin-artifact-packages-attribution 11/11, verify artifact-collections 8/8, cli (unit) stack-collections 16/16. The pin sweep found no other test that feeds packages: null.
  • Typecheck. typecheck for core, plugin-security, plugin-dev and runtime: all exit 0, each echoing tsc --noEmit and check:test-typecheck: OK. pnpm --filter @objectstack/spec check:generated: all 15 generated artifacts up to date (its check:test-typecheck included), measured against the spec dist built above.
  • Ablation (one-time proof, no permanent file). The fix was committed first. Each mutation went through scripts/ablation-replace.mjs: anchor hit 1 to 0, blob moved, and after the restore the blob equals HEAD's and git diff HEAD is empty.
    • A. Put || declared === null back into the core resolver's absent branch (src). Red: core null row (1 of 6 failed), runtime null row (1 of 22), plugin-dev null row plus LOCKSTEP (2 of 20).
    • A through dist. Same mutation, then core rebuilt. ablation-dist-preflight found the marker in 2 built files. Red: plugin-security null row (1 of 28). Restore leg: rebuilt, --absent passed (marker in 0 of 14 built files, tree clean), 28 of 28 green.
    • B. Only the plugin-dev guard drifts back to || packages === null. Red: the null row and LOCKSTEP (2 of 20). That is the lockstep pin catching a guard that disagrees with a fixed resolver.
    • C. Only the runtime guard drifts back. Red: the runtime null row (1 of 22).
    • After the battery: tree clean against HEAD, and a --reporter=verbose re-run of all five files lists every new or flipped case green.
  • Gates. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran reconciles 87 derived, 84 run, 3 NOT MEASURED, 0 unrun. Of the 84 run, 83 exit 0 and check-empty-changeset exits 1: the deliberate correction above, left red on purpose. check-adr-0087-registration passes: one declared-breaking changeset, disposition not-required (no-migration-prescription). check-changeset-no-major: no major (the level axis reads the PR, so it is judged in CI).
    • NOT MEASURED: check:dual-build-cjs-loads, check:i18n and check: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.
  • Lint, narrowed. eslint --no-inline-config --format json over the 10 changed .ts files: 10 files linted, 0 errors, 0 warnings. eslint.config.mjs enables no type-aware linting (no parserOptions.project, stated in its own header), so this diff cannot move any untouched file's verdict. The full pnpm lint is CI's.
  • origin/main is 5 commits past this branch's base, and none of them touches a path in this diff (git diff --stat of those paths is empty). The merge queue validates the merged generation.

Acceptance notes


Generated by Claude Code

… 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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 27, 2026
@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/core, @objectstack/plugin-dev, @objectstack/plugin-security, @objectstack/runtime, @objectstack/spec, touching 3 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/app-default-permission-set.ts, packages/spec/src/stack.zod.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/protocol/kernel/plugin-spec.mdx (via resolveArtifactPackageOrder (symbol, a top-level function))
What this run could not see
  • 2 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/app-default-permission-set.ts, packages/spec/src/stack.zod.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

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

Which tree this was computed on

This run read content/docs from ba3ff68ba4d8f5507ce27601a51d032999f1e5e1 — the merge of head 0706ffef7c56b9ce968bbec76d3eab7f0a0d7de9 into base 93cfc3f593cbd794f244b6ce922e7410776cad8a, which is what actions/checkout gives a pull_request run. Not the PR head.

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

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 98/98 CONTRACT_REVIEW_TIER
Head-sha: ec9402ad057822de2e1f3e8c809d22b4fe812340

① Derived judgments

  1. BLOCKING — two release-note sentences in .changeset/19926-packages-null-refused.md are not TRUE at head (note edit only; code, pins and the correction are right).
    • "Every caller of the resolver inherits the refusal: …": packages/cli/src/utils/stack-collections.ts:99-101 packageBodies is a caller that tests if (!Array.isArray(declared) …) return []; before calling, so null never reaches the resolver there; packages/verify/src/artifact-collections.ts:67 if (declared) return declared; returns the top-level collection before the resolver when that key is present. Smallest fix: "The resolver's callers that hand it the whole artifact raise the refusal: …", dropping "Every".
    • plugin-dev bullet "raises its refusal when the stack's i18n config and top-level translations do not answer first": dev-i18n.ts:246-250 also answers first on a non-empty manifest.translations. Smallest fix: add "or manifest.translations".
  2. Refusal at the four named readers — PASS. core artifact-packages.ts:215 if (declared === undefined) return [artifact]; then :219 if (!Array.isArray(declared)) throw refuse('INVALID_ARTIFACT_PACKAGES', …) (status 422 via refuse); runtime artifact-collections.ts:465 if (declared === undefined) return artifact; then the resolver; plugin-dev dev-i18n.ts:184 if (packages === undefined) return false; then the resolver; plugin-security app-default-permission-set.ts:191 calls the resolver first with no guard. Sweep at head over packages/*/src for packages === null, == null and .packages ?? []: zero hits (sanity hit on declared === undefined confirmed the pathspec). Readers that fold null into "no packages" via Array.isArray: lint object-graph.ts:215-221 recordsOf (5 sites, sibling surface, see ③); CLI stack-collections.ts:100, artifact-packages.ts:119, collect-docs.ts:385,1163 (see ③). Post-refusal only: spec preservePackageEntries / step 8, metadata plugin.ts:974 (resolver on the next line). cloud-connection-plugin.ts:622 reads a marketplace response, not an artifact.
  3. Lockstep — PASS. dev-i18n-packages-reader.test.ts:388-419 asks stackDeclaresTranslations and resolveArtifactPackageOrder directly on { manifest, packages: null }, pins { code: 'INVALID_ARTIFACT_PACKAGES', status: 422 } and expect(fromGuard).toEqual(fromResolver), with absent-key controls on both. Either drift goes red. The runtime guard has no lockstep pin, but its null row goes red on drift (identity return leaves raised undefined).
  4. No legitimate load now refuses — PASS by reading (no node_modules in the checkout, so no node probe). Absent / explicit undefined → identity (core test :98-107, runtime :96-97, plugin-dev :377-381, plugin-security :364-368); [] → resolver [], runtime returns the argument (artifact-collections.test.ts "returns the ARGUMENT ITSELF for an EMPTY packages: []"); arrays → bodies. No in-repo producer writes packages: null (non-test sweep: zero hits; attachPackageDocs returns a non-array by identity).
  5. stack.zod.ts TSDoc only — PASS. Comment-stripped main vs head: code-equal: true (79354 chars both); all 13 changed lines begin *; the declaration packages: z.array(ArtifactPackageSchema).optional() is at :1674 unchanged. Rule text: "ABSENT means undefined and nothing else" TRUE at the four readers; schema refuses null (invalid_type, pin :253) TRUE; composeStacks refuses it with two or more inputs (concat pass skips undefined alone, :5056; pin :325) TRUE; "every reader refuses it with the resolver's envelope" FALSE for lint recordsOf (5 sites) and CLI packageBodies / artifactPackages / docsPackageRefs / bodyDocsOf — the same over-claim the pre-existing text already carried for {} / 0 / 'x'; normative, carded (lint: four recordsOf(stack.packages) readers treat a non-array packages as "no packages" instead of refusing it — the packages/lint half of #19925 (ruling #15293-A) #20206), not blocking here.
  6. DELIBERATE CORRECTION — .changeset/15293-non-array-packages-refusal.md. Old: "an absent packages, and packages: null, still return the caller's own object by identity." New: "an absent packages still returns the caller's own object by identity. A well-formed packages[] resolves exactly as before. packages: null is not absent: it is malformed, and it is refused the same way (packages: null on a release artifact: the schema and composeStacks refuse it, while every reader reads it as absent #19926)." New sentence TRUE at head (artifact-collections.ts:465,467; runtime null row pins code + 422). Old sentence would ship FALSE (the runtime reader now throws on null). Other sentences: runtime: patch and Clause-②: no are finding(spec): the option-B readers disagree with @objectstack/core about whether a non-array packages is a refusal #15293's own; the non-array refusal sentence TRUE; the rule-beside-AssembledPackageBodySchema sentence TRUE (and now without the null carve-out); "core / plugin-dev / plugin-security already refused it" TRUE for {} / 0 / 'x'; the "What changes" sentence TRUE (app-plugin.ts:206, standalone-stack.ts:829, load-artifact-bundle.ts:175, resolve-project-database.ts:211 all call resolveArtifactCollections; DevPlugin logs at error, dev-plugin.ts:613); "Fix" TRUE. CI red is this file alone (both Check Changeset annotations name only it, foreign-M class). This record is the confirmation.
  7. New note — all other sentences TRUE: four minor grades on published packages; BREAKING banner; schema / composeStacks facts; carriedPackageIds (artifact-granted-permissions.ts:131) and resolveArtifactGrantBinding (:159, when grants is a plain record) reach the resolver; plugin-security has no guard; composeStacks single input returns stacks[0] (:5020); build / validate parse strictly before reading (compile.ts:356-358, validate.ts:293-295). ADR-0087 not-required (no-migration-prescription) is right: schema bytes unchanged, nothing authorable moves, and the body carries no arrow line, framing heading or rewrite table for findMigrationPrescription; Lint & Repo Gates (runs the gate) is green.
  8. Pins — PASS. Core, runtime, plugin-dev and plugin-security null rows all assert code + status; the PR's ablation A/B/C matches the reading. Pins that stay green if the reader refusal were removed: the two spec pins (:253 schema, :325 composeStacks) and the {} / 0 / 'x' rows — they pin unchanged behaviour, per ruling item 2.

② Semver level

minor on core, runtime, plugin-dev, plugin-security (all private: false), BREAKING banner + ADR-0087 marker: matches check-changeset-no-major.mjs ("During the launch window we ship breaking changes as minor") and the 18239 precedent. Clause-②: no (narrowing) is right per clause2-line.mjs ("no (narrowing) — NOT a widening, but breaking"): four published exports that returned an answer for { packages: null } now throw; nothing widens. Spec needs no entry (TSDoc + tests only).

③ Boundary flags

Implemented-by: claude/issue-19926-packages-null-refused
Reviewed-by: session_01QcAS3qiYYZNezaxZxaUdMV

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 95/95 CONTRACT_REVIEW_TIER
Head-sha: 0706ffef7c56b9ce968bbec76d3eab7f0a0d7de9

① Derived judgments

  1. Changeset delta (ec9402ad05 → head) — every changed sentence TRUE.
    • "Every runtime reader of the key read it as absent" → "The runtime readers below read it as absent instead … Those readers now follow the declaration." TRUE: at main core had declared === undefined || declared === null, runtime || declared === null, plugin-dev || packages === null, plugin-security unguarded (inherited core). At head core artifact-packages.ts:215 if (declared === undefined) return [artifact]; :219 !Array.isArray → refuse('INVALID_ARTIFACT_PACKAGES'); runtime artifact-collections.ts:465; plugin-dev dev-i18n.ts:184; plugin-security app-default-permission-set.ts:191 unguarded.
    • Caller list "…register() (ObjectQLPlugin) and @objectstack/verify's collection reader for a collection the stack's top level does not carry" TRUE entry by entry: objectql/src/plugin.ts:427 resolveArtifactPackageOrder(artifact) unguarded; verify/src/artifact-collections.ts:67 if (declared) return declared; then :73 resolver, so only a falsy top-level collection reaches it. Dropping MetadataPlugin is right: metadata/src/plugin.ts:915 ObjectStackDefinitionSchema.parse (and :902/:909 EnvironmentArtifactSchema, metadata: ObjectStackDefinitionSchema at environment-artifact.zod.ts:132) refuses null as invalid_type before :975. The other direct caller, cli stack-collections.ts:100, screens with Array.isArray. Non-test call-site census: 8 sites, all accounted for.
    • plugin-dev "answers first" TRUE: dev-i18n.ts:245 bag.i18n || manifest?.i18n; :246-248 manifest.translations a non-empty array; :250 then stackDeclaresTranslations → :181 declaresTranslationArray(stack) (:21-22 Array.isArray and non-empty), :184 undefined → false, :186 resolver.
    • grantedPermissions "is a record" TRUE: artifact-granted-permissions.ts:151 undefined → EMPTY, :152 !isPlainRecord → return before the resolver, :159 carriedPackageIds → :131 resolver.
  2. Every other sentence — TRUE. Four packages, none private. Marker not-required (no-migration-prescription) … matches check-adr-0087-registration.mjs:63, category at CATEGORIES:497; "no export added": git diff 4df101c383 HEAD has no export line. Schema refuses null (stack.zod.ts:1674 .optional(); spec pin invalid_type at ['packages']); composeStacks :5020 single-input identity, :5063 concat skips undefined alone, :5064-5066 STACK_SCHEMA_INVALID (pin: 422, path ['packages']). Message names null (:226). Runtime callers app-plugin.ts:206, standalone-stack.ts:829, load-artifact-bundle.ts:175, resolve-project-database.ts:211. DevPlugin catch → ctx.logger.error (dev-plugin.ts:636), in-memory fallback. appSecurityPluginOptions:242 → declaredDefaultPermissionSetName:191. Producer census outside tests: zero hits. compile.ts:356, validate.ts:293 ObjectStackDefinitionSchema.safeParse before any reader.
  3. DELIBERATE CORRECTION — .changeset/15293-non-array-packages-refusal.md (PR fix(runtime): refuse a non-array packages in resolveArtifactCollections #19924's pending note), re-confirmed at this head. Old: "an absent packages, and packages: null, still return the caller's own object by identity." New: "an absent packages still returns the caller's own object by identity. A well-formed packages[] resolves exactly as before. packages: null is not absent: it is malformed, and it is refused the same way (packages: null on a release artifact: the schema and composeStacks refuse it, while every reader reads it as absent #19926)." New TRUE (artifact-collections.ts:465,467; artifact-collections.test.ts:106-118 null row pins code + 422). Old FALSE if kept: the runtime reader now throws on null. Other sentences: "@objectstack/runtime": patch and Clause-②: no are finding(spec): the option-B readers disagree with @objectstack/core about whether a non-array packages is a refusal #15293's own; non-array refusal sentence TRUE; rule-beside-AssembledPackageBodySchema sentence TRUE (stack.zod.ts:1253-1268, null carve-out gone); "already refused it" TRUE for {}/0/'x'; "What changes" untouched here, its four call sites exist (item 2); "Fix" TRUE. Check Changeset red: its one path annotation names this file alone. This record is the confirmation.
  4. Code, tests, stack.zod.ts. git diff ec9402ad05 0706ffef7c --stat → .changeset/19926-packages-null-refused.md | 8 ++++----, 1 file changed, 4 insertions(+), 4 deletions(-). Nothing else moved. Spot checks at head: four readers refuse null with the non-array envelope (item 1 lines; pins core artifact-packages.test.ts:34-39, runtime :106-118, plugin-dev :359-368, plugin-security :347-354, each code + status). Lockstep pin present: plugin-dev it('LOCKSTEP …') calls resolveArtifactPackageOrder and stackDeclaresTranslations on { manifest, packages: null }, expect(fromGuard).toEqual(fromResolver), absent-key controls both ways. stack.zod.ts: comment-stripped main vs head code-equal: true (70106 chars both); all 13 changed lines begin *; packages: z.array(ArtifactPackageSchema).optional() at :1674 unchanged.

② Semver level

Correct. minor on core, runtime, plugin-dev, plugin-security (all published): four exports that answered { packages: null } now throw, an accept-set narrowing; check-changeset-no-major.mjs:47 ships breaking as minor until GA, and the 18239 precedent grades the same class minor under a **BREAKING** banner. Clause-②: no (narrowing) is the right arm per clause2-line.mjs:95 ("NOT a widening, but breaking"). ADR-0087 not-required (no-migration-prescription): schema bytes unchanged, no export, key or shape moves, nothing for migrate meta to rewrite. Spec needs no entry (TSDoc + tests).

③ Boundary flags

Implemented-by: claude/issue-19926-packages-null-refused
Reviewed-by: session_01QcAS3qiYYZNezaxZxaUdMV

VERDICT: PASS

os-project-manager pushed a commit that referenced this pull request Sep 27, 2026
…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
os-project-manager pushed a commit that referenced this pull request Sep 27, 2026
…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
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Landing order reversed: this PR enters the queue FIRST, ahead of PR #20229 · domain:spec seat 2 (session_01QcAS3qiYYZNezaxZxaUdMV) · 2026-09-27T16:18Z

os-project-manager pushed a commit that referenced this pull request Sep 27, 2026
…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
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

packages: null on a release artifact: the schema and composeStacks refuse it, while every reader reads it as absent

2 participants