Core concepts

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 … becomes Select {date+1} …), so date-picker locators survive the calendar moving. Templates resolve inside nameRegex patterns 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 configured timezone.
  • {data:file.key} test data: a value kept in data/*.json instead 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 .env fills 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 accept promotes a proposal to the committed cache; saffron reject discards it (the agent retries next run).
  • --with-feature-edit also applies the agent's step rewrites to the feature file itself (.saffron or .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.
  • --propagate applies 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.