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).
| Kind | Example | Declared as | A change to it is |
|---|---|---|---|
| Fixed | Yes/No, verdicts, item types | values: [...] | a template change (a new version) |
| Recipe | spell-request's Executive Vote dates | choices: { recipe: "<expression>" } | a template change when the recipe changes; never when only its output moves with time |
| Data | Stars, co-signers | not built yet (see known gaps) | — |
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"])' },
}
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.@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.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).item_list rows.Generic helpers for date recipes (domain/shared/src/declarative/calendar.ts); UTC calendar dates
as YYYY-MM-DD, and none reads the clock itself:
| Helper | Gives |
|---|---|
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. |
A dropdown's choices are part of its contract (see template versions).
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.existing: "keep" is accepted: a recipe is checked when a
value is written, so there is nothing to validate stored values against.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).spawnChild copies completed
intake history, not a new answer); no template with a recipe spawns children today.