Symptom
collectMetadataStats counts every collection at the top level only, so on an ADR-0130 D4 / option-B project — every definition inside packages[], none flattened to the top — all three commands that print the metadata summary report a project that declares nothing, and os validate raises two warnings that are false on their face.
Under --strict the false warning is not cosmetic: it is a gating error.
Minimal repro
objectstack.config.ts, no top-level objects, one clean packages[] entry (the lookup resolves, so nothing else can gate):
export default {
manifest: { id: 'com.example.ob', name: 'ob', version: '1.0.0', type: 'app', namespace: 'ob' },
packages: [
{ manifest: { id: 'com.example.ob', name: 'ob', version: '1.0.0', type: 'app', namespace: 'ob',
objects: [
{ name: 'ob_order', label: 'Order', sharingModel: 'private',
fields: {
number: { type: 'text', label: 'Number' },
ghost: { type: 'lookup', label: 'Ghost', reference: 'ob_order' },
} },
] } },
],
};
Measured on branch claude/issue-17069-union-fold-validate-lint at 7191b900, through the real binaries:
os validate exit 0 Data: 0 Objects
⚠ No objects defined — this stack has no data model
⚠ No apps or plugins defined — this stack may not do much
os validate --strict exit 1 ✗ Strict mode: warnings treated as errors
os build exit 0 Data: 0 Objects
os info exit 0 Data: 0 Objects
Control — the identical object authored at the TOP LEVEL, same command:
os validate --strict exit 1 Data: 1 Objects 2 Fields
⚠ No apps or plugins defined — this stack may not do much
One warning instead of two, and a non-zero object count. The extra No objects defined line and the zeroed summary are produced by the reader, not by the stack.
Cause, located
packages/cli/src/utils/format.ts:548
export function collectMetadataStats(config: any): MetadataStats {
...
const objects = Array.isArray(config.objects) ? config.objects :
(config.objects && typeof config.objects === 'object' ? Object.values(config.objects) : []);
...
return { objects: count(config.objects), objectExtensions: count(config.objectExtensions), ... };
Every member is count(config.KEY) against the top level. Three call sites, all affected:
packages/cli/src/commands/compile.ts:832 const stats = collectMetadataStats(config);
packages/cli/src/commands/info.ts:43 const stats = collectMetadataStats(config);
packages/cli/src/commands/validate.ts:462 const stats = collectMetadataStats(config);
packages/cli/src/utils/stack-collections.ts already ships the fold this reader needs — authoringRuleUnionStack fills exactly the package-owned collections the top level does not carry, and returns a top-level stack by identity — but whether the summary should be folded, or should learn to report per-package counts, is a presentation decision this card does not make. That is why it is filed rather than folded into #17069's PR.
Why it is not covered today
⭐ test/option-b-reader-acceptance.pin.test.ts is the acceptance pin for the option-B reader program and its OPTION_B_LOSSES ledger is empty — "every subsystem must see its collections in BOTH shapes". It stays green here because its probe (test/fixtures/option-b-reader-probe.ts) carries no row for collectMetadataStats. That pin's own header names this exact failure mode as the thing it exists to catch: "a NEW reader nobody enumerated, arriving in a later change ⇒ RED on arrival". This reader is older than the probe and was never enumerated, so it arrived under the floor instead of over it. A row for it belongs with whatever fix lands.
Relationship to #17069
Found while landing #17069 (PR #17524), and not created by it: #17069 wires the union fold into the two commands' author-time rule table input, deliberately scoped the way compile.ts scopes it — a rule input that reaches no writer and no printer. The summary is a different reader on a different path, and it reads config directly, before any fold. os build shows the same 0 Objects on the same stack both before and after that change.
Where it was found
Driving the #17069 non-vacuity control — a CLEAN option-B project, which must exit 0 on all three commands. It does; it just reports the project as empty while doing so.
⛔ Deduplication: one targeted search_issues call over open and closed (repo /search/* is refused by this session's egress proxy, HTTP 403 — verified this session). 13 results read out. Nearest neighbours rejected: #14512 (open — the option-B EMITTER half, "stop serializing the metadata twice"; this is a reader, and it is broken in the shape #14512 produces, not by it), #17069 (this card's parent finding — rule-table input, a different reader), #4698 (closed — "declared but never read" metadata, an unrelated class). No card names collectMetadataStats or the metadata summary.
Generated by Claude Code
Symptom
collectMetadataStatscounts every collection at the top level only, so on an ADR-0130 D4 / option-B project — every definition insidepackages[], none flattened to the top — all three commands that print the metadata summary report a project that declares nothing, andos validateraises two warnings that are false on their face.Under
--strictthe false warning is not cosmetic: it is a gating error.Minimal repro
objectstack.config.ts, no top-levelobjects, one cleanpackages[]entry (the lookup resolves, so nothing else can gate):Measured on branch
claude/issue-17069-union-fold-validate-lintat7191b900, through the real binaries:Control — the identical object authored at the TOP LEVEL, same command:
One warning instead of two, and a non-zero object count. The extra
No objects definedline and the zeroed summary are produced by the reader, not by the stack.Cause, located
packages/cli/src/utils/format.ts:548Every member is
count(config.KEY)against the top level. Three call sites, all affected:packages/cli/src/utils/stack-collections.tsalready ships the fold this reader needs —authoringRuleUnionStackfills exactly the package-owned collections the top level does not carry, and returns a top-level stack by identity — but whether the summary should be folded, or should learn to report per-package counts, is a presentation decision this card does not make. That is why it is filed rather than folded into #17069's PR.Why it is not covered today
⭐
test/option-b-reader-acceptance.pin.test.tsis the acceptance pin for the option-B reader program and itsOPTION_B_LOSSESledger is empty — "every subsystem must see its collections in BOTH shapes". It stays green here because its probe (test/fixtures/option-b-reader-probe.ts) carries no row forcollectMetadataStats. That pin's own header names this exact failure mode as the thing it exists to catch: "a NEW reader nobody enumerated, arriving in a later change ⇒ RED on arrival". This reader is older than the probe and was never enumerated, so it arrived under the floor instead of over it. A row for it belongs with whatever fix lands.Relationship to #17069
Found while landing #17069 (PR #17524), and not created by it: #17069 wires the union fold into the two commands' author-time rule table input, deliberately scoped the way
compile.tsscopes it — a rule input that reaches no writer and no printer. The summary is a different reader on a different path, and it readsconfigdirectly, before any fold.os buildshows the same0 Objectson the same stack both before and after that change.Where it was found
Driving the #17069 non-vacuity control — a CLEAN option-B project, which must exit 0 on all three commands. It does; it just reports the project as empty while doing so.
⛔ Deduplication: one targeted
search_issuescall over open and closed (repo/search/*is refused by this session's egress proxy, HTTP 403 — verified this session). 13 results read out. Nearest neighbours rejected: #14512 (open — the option-B EMITTER half, "stop serializing the metadata twice"; this is a reader, and it is broken in the shape #14512 produces, not by it), #17069 (this card's parent finding — rule-table input, a different reader), #4698 (closed — "declared but never read" metadata, an unrelated class). No card namescollectMetadataStatsor the metadata summary.Generated by Claude Code