Catalog keys

The shape

<domain>/<path>

<domain> is a capability’s name — the same segment that is already the pithy add argument, the migration namespace, the pithy_<name>_* table prefix, and the error-code domain.

<path> is that capability’s own name for the string — lower case, _ inside a word, . between levels.

auth/sign_in.title
email/magic_link.expiry.one
payments/pricing.buy.label

A tag is at most 64 characters and a key at most 129.

A capability may only declare keys under its own name

Composing messages throws otherwise, naming the offending key and the domain.

This is the table-prefix rule and the error-code rule for the third time, for the same reason: the domain segment is what makes one capability’s contributions incapable of colliding with another’s — so merge order stops being something anyone has to reason about.

Declaring versus overriding

Which of the five layers a key is answered from is the layer order.

These are different acts, and only one of them is refused — overriding one sentence is the one you will actually do:

Overriding a kit keyOne entry in i18n({ messages }). Always allowed — any locale, any key the kit ships
Declaring a new key under a kit domainRefused, with the key and the domain named

board/nav.settings is yours to declare. auth/sign_in.title is not — but changing what it says is.

Where a kit sentence lives, and why it varies

The i18n package holds what no capability can: the error taxonomy, whose domains are not capability names, and the screens, which are copied into your repository rather than imported.

A capability holds its own domain in every language it is written in. The email capability carries its email/ translations beside its English, because the send Worker is a separate deploy that has to be built with them.

Either way it ships in a package and reaches you as an upgrade. Neither is ever copied into your tree.

For an error, the key is the code

auth/invalid_token is a catalog key and an error code and the same string.

There is no second identifier to keep in sync, and the kit’s closed error union is the exact checklist a locale has to cover.

Which is also the argument for modeling a failure as code plus params rather than as a message alone. "That board does not exist: weekly." is one string a client can only print. The same failure as leaderboard/board_not_found with { board: "weekly" } is a sentence any client can rewrite in any language — and the English still arrives on the wire for the ones that will not.

Every throw-sugar constructor in the kit accepts params, and a repo-wide gate keeps it that way.

The catalogs are never copied into your repository

If the Spanish for auth/sign_in.title lived in your tree — rather than in a locale you wrote deliberately — a typo fix or a new locale could never reach you, and every adopter would be a fork on the day they scaffolded.

Passing a whole locale object to messages is that fork, which is why no eject command is offered for this and none is needed.

Screens are the opposite, and the difference matters

pithy ui add writes a file once, and from that moment the file is yours. Each screen carries its own English in a catalog block, because that block is the catalog that survives being copied.

The same is true of the generated client types. pithy ui add copies them, so an existing project’s copy is hand-maintained — a new declare module block will not arrive on its own. Add it by hand, or scaffold a throwaway Worker and copy the block across. Until you do, the virtual module is unresolved to your compiler and serves correctly at runtime, because the plugin builds it from your composed config either way.

No catalog value may carry markup

Subjects and the plain-text part of an email are precompiled with escaping off, so a value substituted there is substituted verbatim.

The HTML body escapes what it renders, and interpolated parameters are escaped with the sentence that carries them.

ESC