# smrt-prompts

SMRT prompt registry and tenant-aware prompt override package. Code defines defaults; config layers and DB-stored overrides personalize at app and tenant levels.

## Core pieces

- `definePrompt()` registers code defaults in a global process registry (`globalThis.__smrtPromptRegistry`)
- `resolvePrompt()` merges code defaults, file/config overrides (via `@happyvertical/smrt-config`), stored app-level overrides, stored tenant-level overrides, and a runtime override
- `PromptOverride` (`_smrt_prompt_overrides` table) stores partial app-level and tenant-level overrides with write-time validation
- `PromptOverrideCollection` exposes the standard SmrtCollection CRUD surface

## Resolution layers (priority low → high)

1. Code default — registered via `definePrompt({ key, template, ai })`
2. File/config override — `getPackageConfig<PromptPackageConfig>('prompts', defaults)`
3. App-level stored override — `PromptOverride` row with `tenantId = null`
4. Tenant-level stored override — `PromptOverride` row with current tenant
5. Runtime override — passed to `resolvePrompt({ overrides })`

Each layer can override any subset of fields (template, profile, model, params). Inheritance is field-by-field.

## Conventions

- **Namespace prompt keys** by package or domain: `projects.issue.incorporateFeedback`, `content.summarize.headline`
- **Stored overrides use nullable fields** so inheritance stays field-by-field — null means "use the lower layer"
- **Provider selection is indirect** in v1: prompts select named profiles, and profiles resolve to provider/model in `smrt-config`
- **`editable` flags are enforced on `PromptOverride.save()`** — definitions can lock specific fields against tenant override

## Caching

`resolvePrompt()` results are cached per `(key, tenantId)` with a TTL. The cache is invalidated on `PromptOverride.save()` and `.delete()`. Use `clearPromptCache()` for manual invalidation in tests.

**A monotonic per-`(db, key)` invalidation generation guards the cache write.**
A resolution captures `getPromptCacheGeneration(key, db)` before its
asynchronous layer loads and hands it back to `setCachedPromptBase()`; a
concurrent `save()` / `delete()` bumps the generation and the in-flight
resolution is then refused the cache write instead of repopulating the key it
just invalidated with the pre-write value. Without it, "a stale entry is never
served after a write" held only until a read raced a write, and then failed for
the full 30s TTL (a raced `delete()` resurrected the deleted override).
Generations are tracked per `(db, key)`, not per tenant, because an app-level
row is inherited by every tenant. `clearPromptCache()` raises a single floor
(`clearedThrough`) rather than resetting or per-key bumping: a key that has
never been invalidated has no map entry and reads as generation 0, so a per-key
bump cannot reach it and a resolution that started before the clear would write
its pre-clear value back. `smrt-languages` carries the same mechanism, keyed
additionally by locale. `smrt-playbooks` has the generation but not the floor
(#2716).

## Related

- `@happyvertical/smrt-languages` — parallel package for language strings (uses the same architecture pattern)
- `@happyvertical/smrt-features` — parallel package for feature flags
