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.
Validity stays compile's job.
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. Validity stayscompile's job.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.