Choice lists

A dropdown offers a list of choices. Most lists are fixed; some are not. This page covers the kinds of list a template can declare, and what the platform guarantees for each (issue #104).

KindExampleDeclared asA change to it is
FixedYes/No, verdicts, item typesvalues: [...]a template change (a new version)
Recipespell-request's Executive Vote dateschoices: { recipe: "<expression>" }a template change when the recipe changes; never when only its output moves with time
DataStars, co-signersnot built yet (see known gaps)—

Recipes

A recipe is a closed platform expression whose only input is the current instant. It returns the list of choices: distinct, non-empty strings of at most 256 characters, at most 500 of them.

{
  key: "execDate",
  type: "dropdown",
  title: "Targeted Executive Vote",
  mandatory: true,
  // The next 10 cadence Thursdays at least 22 days out, skipping a no-vote date.
  choices: { recipe: 'cadenceDates("2026-07-30", 14, isoDateAddDays(todayIsoDate(), 23), 10, ["2026-12-31"])' },
}
  • Closed. The recipe may read the clock helpers (todayIsoDate(), and anything built on the instant) and nothing else: not the process, the user, permissions or the form. The compiler refuses a recipe that reads anything else (compileChoiceRecipe), and registration validation refuses a compiled recipe whose input is anything but now. So the same instant always gives the same list, in the browser and on the server.
  • The digest covers the rule, not its output. The compiled recipe is part of the execution definition; the dates it produces are not. A template with a recipe compiles to the same digest whatever day it is compiled, so a rolling list no longer means a new build every window.
  • Evaluated when shown and again when written. The form evaluates the recipe when it renders the field (@processos/forms/choices, which uses the server's evaluator, @processos/expressions/choices). The server evaluates it again, at the instant of the write, whenever a request writes a new value to the field: a step edit or completion, or a value seeded when a process starts (initial values and defaults). A value outside the list is refused with 400 invalid-choice, naming the value and the choices offered at that instant. This holds for every input policy (compatible and strict): the rule is the field's contract.
  • The instant is recorded. Each accepted value is appended to the process's choice audit (choiceAudit: at, step, field, value). Replaying the recipe at at reproduces the acceptance, so revalidation is deterministic. A step or field rename rewrites these entries like the field audit (originals kept in the migration audit).
  • Stored values are kept. A value accepted earlier stays valid when it leaves the list (a past vote date). A form re-sends it unchanged and the server keeps it; the dropdown shows it first, marked "(not currently offered)". Clearing a value is always allowed. Choosing it again is a new write and is checked.
  • No recipe in rows. Rule-defined choices are for top-level fields only, not item_list rows.

Date helpers

Generic helpers for date recipes (domain/shared/src/declarative/calendar.ts); UTC calendar dates as YYYY-MM-DD, and none reads the clock itself:

HelperGives
todayIsoDate()Today's date at the instant of evaluation.
isoDateAddDays(date, days)date plus days whole days (negative goes back).
cadenceDates(anchor, everyDays, onOrAfter, count, exclude)The first count dates of the cadence anchor + k × everyDays on or after onOrAfter, skipping the dates in exclude. At most 100 dates and 100 exclusions.

Versions and migrations

A dropdown's choices are part of its contract (see template versions).

  • Changing a fixed list, changing a recipe, or turning a fixed list into a recipe (or back) is a contract change: the migration declares changeField(step, field, existing). A shape records a recipe by its digest (choiceRule: "recipe:<sha256>"), never by its output, so a frozen shape does not depend on the day it was taken.
  • When the new choices are a recipe, only existing: "keep" is accepted: a recipe is checked when a value is written, so there is nothing to validate stored values against.
  • A recipe's output moving with time is not a template change at all.

Known gaps

  • Data lists are not built (a scope decision). A list of particular things (spell-request's Stars, the Merkl and IB payout co-signers) should come from a source the deployment provides, with stable ids and labels, so that adding a Star is a data change rather than a template version. No existing source fits: the document database is operator-only (database:* is barred from application roles), its collections are deployment-global and schema-less, and nothing seeds them; Auth0 organizations are affiliations, not Stars. The likely design is a named list the host registers in its deployment configuration ({ source: "<name>" } beside { recipe }, which ChoiceRule leaves room for): the server checks writes against it and serves its entries with the form, with nothing in the digest. Still to decide: whether a stored value is the stable id or the label, and how a renamed or removed entry displays; and the rule that a change to the list's data is not a template change (no version, no migration). Until then the Stars stay a fixed list (and matchOrgPrime keeps its own copy).
  • Values a migration writes to a recipe field are type-checked only (a string), not checked against the recipe. Nor are values a parent seeds into a spawned child (spawnChild copies completed intake history, not a new answer); no template with a recipe spawns children today.
  • Clock skew. The form evaluates the recipe with the browser's clock and the server with its own. At the moment a choice leaves the list (UTC midnight for a date recipe), a choice shown a moment earlier can be refused; the error names the current choices.
  • The template editor shows a recipe's current output but cannot author one; recipes are written in code-owned templates.