Prompt versioning

`definePrompt` makes a prompt an identified, versioned artefact — and stamps that identity onto the step row, the spend ledger and a `_voltro_prompts` table, so "which prompt produced this run" is a lookup.

Your schema is versioned. Your rows carry provenance. Your prompts — the part of an AI feature that changes weekly and is edited by whoever is nearest — were code with no identity at all.

definePrompt closes that asymmetry. It reuses the digest aiStep already recorded on step rows rather than inventing a second identity scheme.

Declaring a prompt

import { definePrompt } from '@voltro/ai'

export const triage = definePrompt({
  id:       'support.triage',
  system:   'You triage support tickets. Answer only with the category.',
  template: 'Ticket:\n{{body}}\n\nCategories: {{categories}}',
  label:    'v3-shorter-system',
})
  • id is the stable identity across revisions, dotted like a descriptor tag.
  • template is a string with {{name}} placeholders — not a function. A function has no stable content to hash, so a function-built prompt could only be versioned by hashing its output, which would make every distinct customer message a new "version".
  • digest is computed from the template + system at definition time. It is the version, and it exists before any database does — a test, an eval, or a CLI can identify a prompt version with no connection.
  • label is metadata and deliberately not part of the digest, so renaming a revision does not fork it.

Render it to get the text plus a stamp:

const rendered = yield* triage.render({ body: ticket.body, categories: 'hardware, software, billing' })
// { promptId: 'support.triage', digest: 'sha256:…', prompt: 'Ticket:\n…', system: '…' }

A missing variable fails rather than sending the literal {{body}} to a model.

Provenance you get by using the primitive

Pass the rendered prompt straight to aiStep — the string form still works, it just carries no stamp:

const result = yield* aiStep({
  name:   'triage-ticket',
  prompt: rendered,
  store:  ctx.store,
  offload: true,
})

That one call now writes the same promptId + digest to three places:

Where What it answers
_voltro_workflow_run_steps.input Which prompt version produced this run
_voltro_ai_usage What each prompt version cost
_voltro_prompts What the template was, at that version

Nothing has to be remembered at call time, and offloaded calls are covered too — the stamp rides across the suspend on the queue row, so the dispatcher attributes the spend to the same version.

recordPrompt: 'none' still records the provenance. That is deliberate: the reason to record nothing about a prompt is that its text is sensitive, and an id plus a content digest is neither the text nor derivable from it. Suppressing the identity along with the content would mean the most privacy-conscious setting is also the one where you cannot tell which prompt version ran.

Reading it back

import { aiSpendUsd, promptVersionByDigest, promptVersionsFor } from '@voltro/ai'

// From a run's step row → the artefact.
const version = yield* promptVersionByDigest(ctx.store, stepInput.promptVersion)
version?.template   // the template, as it was

// The history of one prompt, newest revision first.
const history = yield* promptVersionsFor(ctx.store, 'support.triage')

// Did revision 4 cost more than revision 3?
const spend = yield* aiSpendUsd(ctx.store, { promptDigest: version.digest })

recordPromptVersion assigns the human-facing revision (1, 2, 3 …) on first sight of a (promptId, digest) pair and is idempotent afterwards. Under a two-replica race two different versions can land on the same revision number — accepted deliberately: the digest is the exact identity, the revision is a label for humans, and order by firstSeenAt when you need the true sequence.

Retention

_voltro_prompts is bounded by the standard retention sweep on lastUsedAt, defaulting to 365 days and tunable with VOLTRO_AI_PROMPTS_TTL_HOURS. The table is self-healing under it: a version that ages out is one nothing has run in a year, and the next run re-registers it.

The registration is framework-precedence, so an app that registers its own window for the table wins without having to know the framework's exists.

The table

_voltro_prompts
  promptId       stable identity across revisions
  digest         content digest of template + system — the version
  revision       human-facing counter within a promptId
  label          the author's name for this revision
  template       the TEMPLATE (code) — never a rendered prompt (data)
  system
  firstSeenAt / lastUsedAt

Storing the template is safe precisely because it is code — it is in your repository already. The rendered prompt, which may contain a customer's message, is never written here.