Caches and proposals
The cache: a reviewable git artifact
Each scenario caches to one JSON file under .saffron/cache/, a per-step
list of actions with semantic targets: ARIA role + accessible name
first, then label / text / testId / placeholder, with CSS selectors only as
fallbacks. Caches are meant to be committed and reviewed in PRs, like
snapshots.
Every cache and proposal is validated on read: the schema version,
the step shape, every action type and field and the operands each action
needs are checked, and the first thing wrong is named by path. A recording that cannot be read makes its
scenario red with that message rather than replaying as garbage or being
recorded over; saffron status marks it invalid with the reason; a file
from a newer runner says to update. Fields added within a schema version
are optional, so newer caches still read on older runners.
Deleting a scenario, or the whole feature file, leaves its recording
behind: nothing reads it, but it stays in git, and a reviewer cannot tell a
dead recording from a live one. saffron prune lists every cache,
pending proposal and failure-screenshot folder no scenario owns any more, and says why (the scenario is
gone from the file, or the file itself is gone). It deletes nothing until
you add --yes, and --check exits 1 without deleting, so CI can keep the
directory honest. Ownership is decided by path, never by content: a cache
that is stale or even unreadable is kept as long as a live scenario claims
it, because stale means re-record and unreadable means a bug, and neither
is a reason to throw a recording away. While any feature file fails to
parse, prune refuses to judge anything at all, since a syntax error hides
that file's scenarios.
Two mechanisms keep caches valid over time:
<param>placeholders: a Scenario Outline records once and replays every Examples row; literal example values are factored out automatically. A row value that the agent quoted inside a longer page sentence becomes the placeholder outright for assertions and text locators (they match by containment), so every row can satisfy it. Within one run only the first row records; later rows replay the pending proposal at zero AI, and a row the proposal does not fit turns red with an explanation instead of overwriting the recording.{date±N[:fmt]}templates, literal ISO dates near today are rewritten to relative templates at record time (Select 2026-07-04 …becomesSelect {date+1} …), so date-picker locators survive the calendar moving. Templates resolve insidenameRegexpatterns too. A format writes month and weekday names in a locale ({date+7:D. MMM:de-DE}), and a range writes what its two days share once ({date+7 - +9:MMM D, YYYY}is "Oct 24 - 26, 2026"), counted on one clock per scenario run in the configuredtimezone.{data:file.key}test data: a value kept indata/*.jsoninstead of the feature file. The recording stores the token and replay reads the file, so editing the data changes the next replay at zero tokens; a recording that still spells out the old value is honestly stale, and a missing file or key stops the run by name before a browser opens.expectEach, the list assertion: "should list every{data:enums.OrderStatus}" records one assertion naming the list and its container. Replay iterates the file's list, so a value added to the data is checked on the next run at zero tokens, and a missing one fails by name.- Data tables: steps like "I enter the following guest information"
carry their Gherkin table into the recording. For 2-column key/value
tables, recorded values become
<table:key>references resolved from the scenario's own table at replay: change the table values and the cache replays unchanged at zero tokens; change the keys and the step is honestly stale and re-records. - Multi-row record tables: wider tables (header row + data rows, a
list of records) parameterize per cell: recorded values become
<table:1:firstName>,<table:2:email>, … with 1-based data-row indices. Editing any cell value replays free; adding/removing a row or renaming a header column changes the key set and is honestly stale. A 2-column table is always key/value, never header-mode. - Doc strings: a step's
"""block becomes a<docstring>reference in the recording, so editing the content replays at zero tokens. A recording that baked the content in literally (e.g. hand-written caches) stays protected by exact-content staleness. - Secrets:
{env:VAR}tokens resolve from the environment at action execution and never persist: recordings mask referenced values back to tokens, including one a data file holds as{env:VAR}, and narratives, errors, reports and history get the same pass. Missing variables fail fast by name before the run; a git-ignored.envfills gaps (real env always wins). The agent never holds the value, not even while recording: it types the token, Saffron's browser tools put the value into the page, and every tool result comes back with the value as its token. What remains is documented in HOW-TO-USE: values under three characters, a page that shows the value transformed, and same-text matches masked along with it. - Cross-step references: unambiguous table/docstring params are
visible to every step of the scenario, exactly like Examples params.
So "Then the saved note should be shown" records as an assertion on
<docstring>even though that step carries no doc string itself, edit the note, and both the fill and the assertion follow. A key claimed by two steps with different values is ambiguous and stays step-local.
Every recorded action also carries a page fingerprint (SHA-256 of the
normalized accessibility snapshot, numbers masked) used for token-free drift
detection, surfaced as a drift chip in reports.
Navigations are cached relative to the baseURL, so one recording
replays against any environment (--base-url). The baseURL is treated as
a prefix, path and query included: under http://host/app?flow=standard,
a visit to http://host/app/rooms?flow=standard is cached as /rooms,
and replaying with --base-url "https://stage.test/booking/?flow=checkout-guest"
goes to https://stage.test/booking/rooms?flow=checkout-guest. A visit to
the baseURL itself is cached as /. A cached URL with a query of its own
keeps it, URLs on other origins are cached as they are, and caches from
earlier versions (plain paths such as /rooms) replay exactly as before.
Proposals: the review gate
saffron diff is the evidence. A proposal's narrative is the agent's
account of what it did; the action list is what you are approving.
saffron diff [files...] prints the committed recording against the
proposed one, step by step: a rewritten action appears as one
before-and-after pair with the fields that moved named underneath, a
changed assertion is counted separately because that is the scenario's
verdict, and a first recording says plainly that there is nothing to
compare against. Actions are compared by their fields, never by how they
read, so a dropped response-body check or a selector repointed from
#save to #delete cannot pass as "no change"; steps are aligned in
order, so a scenario that does the same thing twice does not hide the loss
of one of them. When the proposal also carries feature file edits, the
step rewrites saffron accept --with-feature-edit would apply follow the
actions, each step as the proposal recorded it against its new text, and
a step from a step set marked as one (accept rewrites it in the step set,
for every scenario that uses it). A proposal may come from a CI run, so
its text is printed without terminal control characters. The same diff
appears in the HTML report and in both IDE panels, so every surface shows
one answer.
AI output never lands in a cache directly. Recordings and heals write
proposals to .saffron/proposals/, carrying the new cache plus metadata:
narrative, adaptations, token usage, and suggested feature-file edits.
Verified proposals close the recording loop: before a proposal is
filed, Saffron replays the candidate cache zero-AI in a fresh browser
context. If the proof replay fails, one bounded refinement pass feeds
the failure (and the live browser state) back to the still-warm agent,
"the app works; your recording doesn't replay; fix the recording", then
re-verifies. Proposals arrive stamped verified: true, or honestly
verified: false with the replay error attached (and saffron accept
warns before promoting an unverified one). Heal candidates are never
refined around assertion failures; committed assertions stay the
contract. Disable with --no-verify / "verifyProposals": false (e.g.
for scenarios with non-idempotent side effects, since verification
re-executes the scenario).
Bound proposals make the approval mean what it says. A proposal
carries digests of the scenario's steps, of the committed recording it
was diffed against and of its own recording. saffron accept recomputes
them and refuses a proposal, by reason, when any moved since it was filed:
a step edited, a colleague's heal accepted, a locator fix propagated, a
pull, a hand edit. Data tables, doc strings and the feature edits accept
can apply are bound; Examples values, fingerprints and the narrative are
not. There is no override; a fresh run files a proposal
bound to what is there now. saffron diff, saffron status and both IDE
panels show STALE with the same reason. Fingerprints are advisory and are
not bound.
saffron acceptpromotes a proposal to the committed cache;saffron rejectdiscards it (the agent retries next run).--with-feature-editalso applies the agent's step rewrites to the feature file itself (.saffronor.feature): located via Gherkin AST line numbers, keeping indentation and keyword, skipping shared Background steps, and syncs the cache's step text so it doesn't go stale. Documented truth tracks executable truth.--propagateapplies an accepted heal's locator fixes to every other cache whose target shares the same semantic core (role/name/nameRegex/ label/text/testId: CSS fallbacks excluded). Identical core means an identical Playwright query, so the identity proof is exact, not heuristic: one heal repairs N scenarios before they ever fail. Fan-out is printed per scenario; nothing is silent. A healed API request propagates the same way, to every other cache sending that same request under the same step text: not when the fix writes a value of the healed scenario's own (a table cell, a doc string, its Outline row), adds a<column>or{captured:...}value, drops a value the request extracted, sends the request to another API, or when the heal kept the request in one place and rewrote it in another; accept says which.