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.

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 and pending proposal 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.
  • 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, 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). Honest limitation: during recording the agent types the real value once, replays never involve it. Record with rotatable staging credentials.
  • 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.

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. 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 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).

  • 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: 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.