Prompt registry
Prompts hardcoded three layers deep in a route handler, versioned like everything else that ships.
What it is
Prompt registry is a package that stores prompt templates as append-only versions and resolves them by name@version or name@alias. Every edit mints a new row instead of mutating one (the database revokes UPDATE and DELETE outright) and a mutable alias pointer (prod, canary) lets you promote a prompt to production without a redeploy or touching a version row.
What ships in the module
Browser-safe entry point
Import @caisson/prompt-registry/browser inside a client bundle for name@version addressing and the injection-safe render boundary with its strict variable schemas. The registry functions and the schema stay off that entry on purpose, each takes a TenantExecutor and runs SQL, so tenant isolation stays on the server. The main entry keeps the full surface, and every browser-entry export is also on it.
Append-only versioning, not a mutable prompts table
registerPrompt derives the current tip from the kernel's versioning chain and supersedes it, the first call to a name is v1, each later call is tip.version + 1. A concurrent mint of the same (name, version) hits the unique index and throws ConflictError instead of silently overwriting.
name@version and name@alias addressing
parsePromptRef reads a bare name as the current tip, a numeric suffix as an exact version, and anything else as an alias. resolvePrompt takes that parsed reference straight to the matching row, one function call from a string ref to an immutable PromptVersion.
Promote without a redeploy
setAlias points prod or canary at a specific version number. It resolves the target version first, so an alias can never point at a version that doesn't exist, and it only ever writes the prompt_alias pointer row, the version rows themselves are never touched.
Injection-safe rendering, not string interpolation
renderPrompt validates raw vars against the version's own varSpec (a strict Zod schema, unknown vars rejected, missing vars fail), then substitutes {{name}} placeholders in a single non-recursive pass. Every inserted value is brace-escaped, so a variable's own content can never open a new placeholder or forge a message role.
Every table is tenant-isolated by default
prompt_version and prompt_alias both go through buildTenantPolicySql (force-RLS), and every registry function takes a TenantExecutor, a query outside a withTenant scope sees nothing, not an empty result you have to remember to check for.
The render contract is pinned, not just tested
The single-pass, brace-escaped rendering behavior is locked against a golden fixture (src/__golden__/render.json), a change to the substitution logic that shifts the output has to update the fixture deliberately, it can't drift silently through a passing test suite.
function renderContent(template: string, vars: Record<string, string>): string {
const rendered = template.replace(PLACEHOLDER_RE, (_match, name: string) => {
const value = vars[name];
if (value === undefined) {
// A placeholder with no bound variable is a template/schema mismatch — never emit it raw.
throw new ValidationError("Unbound prompt variable", { name });
}
return escapeValue(value);
});
// Escaping can inflate a value (every `{`/`}` doubles), and several per-cap-bounded values can
// still sum past the cap in one template — re-check the rendered total, not just each input.
if (rendered.length > MAX_CONTENT_LENGTH) {
throw new ValidationError(
"Rendered prompt content exceeds the content cap",
{
length: rendered.length,
max: MAX_CONTENT_LENGTH,
},
);
}
return rendered;
}- escapeValue runs on every substituted value, a variable's own content can never forge a new {{placeholder}} or escape into the surrounding template.
- The length check runs AFTER escaping, not before, escaping can inflate a value, so the cap has to catch the real rendered total, not the pre-escape input.