Found while running a follow-up lap on card 1/6 of #14478 (branch claude/issue-15676-epoch-ms-and-external-vocabulary-exemptions, head 5b1b5308e). Filed rather than fixed: the repair is a change to packages/spec/scripts/build-api-surface.ts and to every shard it rewrites, which is nothing to do with that card's diff.
What was measured
packages/spec/src/shared/epoch.zod.ts declares one name twice — a value and a type:
export const EpochMs = z.number().int().describe('Unix timestamp in milliseconds (epoch)');
export type EpochMs = z.input<typeof EpochMs>;
After pnpm --filter @objectstack/spec build, the built declaration bundle carries and exports both (packages/spec/dist/shared/index.d.ts):
declare const EpochMs: z.ZodNumber;
type EpochMs = z.input<typeof EpochMs>;
export { ..., EpochMs, ... };
The checked-in shard records ONE entry, and it names the type:
packages/spec/api-surface/shared.json:15 "EpochMs (type)",
pnpm --filter @objectstack/spec check:generated is green on that tree (all 15 generated artifacts up to date, check:api-surface among them), so this is the artifact the gate considers correct.
The mechanism
build-api-surface.ts maps each symbol returned by checker.getExportsOfModule() through kindOf(flags). TypeScript merges a value declaration and a type declaration of the same name into ONE symbol whose flags carry both, and kindOf tests in a fixed order:
if (flags & ts.SymbolFlags.TypeAlias) return 'type';
if (flags & ts.SymbolFlags.Variable) return 'const';
TypeAlias is tested first, so a dual-declared name always prints (type) and the value half is never enumerated. The shard format is name-keyed and cannot express both: measured across all 17 shards in packages/spec/api-surface/, zero names appear twice.
Population — this is not one export
Names declared as export const X AND export type X in the same file, counted across packages/spec/src/**/*.ts (tests excluded): 132. Three spot-checked against shared.json, all recorded as (type) only: EpochMs, HttpMethod (line 30), ExpressionDialect (line 17). The shape is the ordinary z.enum idiom (FieldType, LogLevel, SpanKind, WebSocketMessageType, …), so it is common rather than exotic.
Why it matters
api-surface/ is the breadth half of the ADR-0059 backward-compatibility gate, and the script's own header states its purpose: "A REMOVED export or a CHANGED factory signature is breaking (bump major)." For these 132 names the value half sits outside that. Deleting export const FieldType while leaving export type FieldType in place would leave "FieldType (type)" byte-identical in the shard, check:api-surface green, and every consumer's value-position import { FieldType } broken — the exact failure the shard exists to make loud.
The header's SCOPE block already enumerates what these artifacts deliberately do NOT cover (the type hash does not expand references; value-level narrowing such as an enum losing a member is ungated per ADR-0059 §5). This case is not among them, which is why it reads as a defect rather than a declared boundary.
Not ablated — what a fixer should confirm first
The consequence above is derived from the shard format, the recorded entries and kindOf's ordering. I did NOT delete a const, regenerate and observe the shard staying green: that ablation needs a spec build per leg and was outside this lap's budget. Confirm it that way before choosing a repair. What IS measured here: the three recorded entries, the 132-name population, the zero duplicate names across shards, and the source ordering quoted above.
Repair sketch (for triage, not a decision)
Emitting both kinds for a dual-declared symbol — e.g. EpochMs (const) and EpochMs (type) as two rows — makes the removal loud, at the cost of a one-time rewrite of every shard that contains one of the 132 names (a large but mechanical gen:api-surface diff, which is itself the kind of churn ADR-0059 wants reviewed once). Whether the entry format should carry a combined kind instead is a call for the maintainer.
No assignee — for triage.
Found while running a follow-up lap on card 1/6 of #14478 (branch
claude/issue-15676-epoch-ms-and-external-vocabulary-exemptions, head5b1b5308e). Filed rather than fixed: the repair is a change topackages/spec/scripts/build-api-surface.tsand to every shard it rewrites, which is nothing to do with that card's diff.What was measured
packages/spec/src/shared/epoch.zod.tsdeclares one name twice — a value and a type:After
pnpm --filter @objectstack/spec build, the built declaration bundle carries and exports both (packages/spec/dist/shared/index.d.ts):The checked-in shard records ONE entry, and it names the type:
pnpm --filter @objectstack/spec check:generatedis green on that tree (all 15 generated artifacts up to date,check:api-surfaceamong them), so this is the artifact the gate considers correct.The mechanism
build-api-surface.tsmaps each symbol returned bychecker.getExportsOfModule()throughkindOf(flags). TypeScript merges a value declaration and a type declaration of the same name into ONE symbol whose flags carry both, andkindOftests in a fixed order:TypeAlias is tested first, so a dual-declared name always prints
(type)and the value half is never enumerated. The shard format is name-keyed and cannot express both: measured across all 17 shards inpackages/spec/api-surface/, zero names appear twice.Population — this is not one export
Names declared as
export const XANDexport type Xin the same file, counted acrosspackages/spec/src/**/*.ts(tests excluded): 132. Three spot-checked againstshared.json, all recorded as(type)only:EpochMs,HttpMethod(line 30),ExpressionDialect(line 17). The shape is the ordinaryz.enumidiom (FieldType,LogLevel,SpanKind,WebSocketMessageType, …), so it is common rather than exotic.Why it matters
api-surface/is the breadth half of the ADR-0059 backward-compatibility gate, and the script's own header states its purpose: "A REMOVED export or a CHANGED factory signature is breaking (bump major)." For these 132 names the value half sits outside that. Deletingexport const FieldTypewhile leavingexport type FieldTypein place would leave"FieldType (type)"byte-identical in the shard,check:api-surfacegreen, and every consumer's value-positionimport { FieldType }broken — the exact failure the shard exists to make loud.The header's SCOPE block already enumerates what these artifacts deliberately do NOT cover (the type hash does not expand references; value-level narrowing such as an enum losing a member is ungated per ADR-0059 §5). This case is not among them, which is why it reads as a defect rather than a declared boundary.
Not ablated — what a fixer should confirm first
The consequence above is derived from the shard format, the recorded entries and
kindOf's ordering. I did NOT delete a const, regenerate and observe the shard staying green: that ablation needs a spec build per leg and was outside this lap's budget. Confirm it that way before choosing a repair. What IS measured here: the three recorded entries, the 132-name population, the zero duplicate names across shards, and the source ordering quoted above.Repair sketch (for triage, not a decision)
Emitting both kinds for a dual-declared symbol — e.g.
EpochMs (const)andEpochMs (type)as two rows — makes the removal loud, at the cost of a one-time rewrite of every shard that contains one of the 132 names (a large but mechanicalgen:api-surfacediff, which is itself the kind of churn ADR-0059 wants reviewed once). Whether the entry format should carry a combined kind instead is a call for the maintainer.No assignee — for triage.