Canonical, versioned id for a spec — simultaneously the column
name, the cache key, and the provenance citation.
Params are sorted by key and materialized post-defaults, so two
spellings of one computation collide deliberately.
Identity is separable from validity — [PND-PROCTOTAL]
By default this validates as it goes, because it resolves params to
canonicalize them and an id built from a rejected param would be a
cache key for a node that cannot exist.
But the moments a consumer most needs an id for an invalid spec
are exactly the failure paths: labelling the chip it is skipping,
keying the "this one is broken" UI state, logging which persisted
entry was rejected. Coupling the two left the consumer
re-implementing canonicalization — the one thing this function exists
to own — or carrying a second key beside a correct one (Tidal,
docs/notes/tidal-process-adoption-friction-2026-08.md).
So specId(registry, spec, { validate: false }) is total: an
unknown op keeps its given params verbatim, a known one still gets
its defaults applied and its keys sorted, and nothing throws — a
malformed shape is named (marked p1?:), not rejected. Validity
stays compile's job, with one exception decidable from the registry
alone: arity, which strict mode judges here too (ArityError).
A valid spec has one id under either mode. Canonicalization is
the same code path and checkParam never coerces, so the lenient id
of a legal spec is the strict one — a consumer may key on it without
a second cache line.
An unvalidated id cannot collide with a valid one, and that takes
more than putting the params in: it is marked p1?: rather than
p1:, and its params are encoded type-preservingly. Without both, a
spec whose param arrived as "20" instead of 20 — a JSON round
trip, the very case this mode is for — named the working node, since
String(v) erases the difference checkParam would have caught. A
valid id is unaffected by either measure, which is why they are
confined to this branch.
The mark rides up a chain: a spec whose nested input did not
validate cannot compile either, so it is unvalidated too.
Canonical, versioned id for a spec — simultaneously the column name, the cache key, and the provenance citation.
Params are sorted by key and materialized post-defaults, so two spellings of one computation collide deliberately.
Identity is separable from validity — [PND-PROCTOTAL]
By default this validates as it goes, because it resolves params to canonicalize them and an id built from a rejected param would be a cache key for a node that cannot exist.
But the moments a consumer most needs an id for an invalid spec are exactly the failure paths: labelling the chip it is skipping, keying the "this one is broken" UI state, logging which persisted entry was rejected. Coupling the two left the consumer re-implementing canonicalization — the one thing this function exists to own — or carrying a second key beside a correct one (Tidal,
docs/notes/tidal-process-adoption-friction-2026-08.md).So
specId(registry, spec, { validate: false })is total: an unknown op keeps its given params verbatim, a known one still gets its defaults applied and its keys sorted, and nothing throws — a malformed shape is named (markedp1?:), not rejected. Validity stayscompile's job, with one exception decidable from the registry alone: arity, which strict mode judges here too (ArityError).A valid spec has one id under either mode. Canonicalization is the same code path and
checkParamnever coerces, so the lenient id of a legal spec is the strict one — a consumer may key on it without a second cache line.An unvalidated id cannot collide with a valid one, and that takes more than putting the params in: it is marked
p1?:rather thanp1:, and its params are encoded type-preservingly. Without both, a spec whose param arrived as"20"instead of20— a JSON round trip, the very case this mode is for — named the working node, sinceString(v)erases the differencecheckParamwould have caught. A valid id is unaffected by either measure, which is why they are confined to this branch.The mark rides up a chain: a spec whose nested input did not validate cannot compile either, so it is unvalidated too.