Skip to content

Changelog

Included from CHANGELOG.md at the repo root, so this page is always the whole record. The newest entries are open; the earlier ones, and each past release, are folded below them.

All notable changes to this project are documented here. Format follows Keep a Changelog. Internal milestone labels track the arc order: 0.1 (API) → 0.2 (browser) → 0.3 (performance) → 0.4 (pen-test) → 1.0.0. The P#n/D<n>/M<n> citations below name blocks in design records this repository does not publish; each one resolves in DECISIONS.md, which lifts the block verbatim. None of 0.1–0.4 is ever actually published — the first npm publish is 1.0.0, gated on all four arcs plus one final integrated acceptance pass against the real dogfood app (PLAN.md P#112). The shipped API grammar is frozen additive-only from 1.0.0 on: no existing syntax changes, only new syntax.

Everything below is built and verified but not yet published — it ships as part of 1.0.0, which is gated on the pen-test arc plus one final integrated acceptance pass (P#112). The performance arc closed 2026-08-02 and is included below.

Changed — the docs site's long pages are read by their rail (M270)

Section titled “Changed — the docs site's long pages are read by their rail (M270)”
  • Diagnostic codes: one entry per code, under the stage that reports it (lexer, parser, checker, config, load, runtime). Each opens with what the code means, shows every snippet that triggers it and what tflw check prints for it, and has a link of its own (…#tf041). The "On this page" rail lists every code, and a filter at the top narrows the page by code or by word.
  • Grammar: each section opens with the lines you can write, highlighted; the formal productions follow, folded. Every production is still on the page.
  • CLI flags: each command opens with its synopsis, and each flag is on a line of its own with its effect beneath. Matchers and Generators are one entry each, with their example as a line of tflw.
  • Playground: an editor with highlighting, line numbers, each mistake marked on its line and linked to its diagnostic, and four starting points. It runs every check tflw check judges a single file by, and says which codes need your project to be reported.
  • Changelog: the newest ten entries are open; the earlier ones are folded, and a link to one opens its fold.
  • Fixed: bold and emphasis in the reference pages' generated text showed as raw ** and *.
  • Fixed: in tflw ui, opening a capture … matching "<regex>" statement and closing it untouched no longer drops its pattern, which turned the capture of an id into a capture of the whole value.

Fixed — the docs site's layout after the move (M269b)

Section titled “Fixed — the docs site's layout after the move (M269b)”
  • The UI section's screenshots no longer run over the sidebar or the "On this page" rail. They widen only into space the page has to spare.
  • The home page no longer scrolls sideways.
  • Each page's canonical link and og:url are its real address (…/guide/config), not one that does not exist (…/guide/config.html/).
  • The playground and the editor demos have their surfaces back: the playground's editor has its own ground, an edge and a monospace face in both themes.

Changed — the docs site runs on Starlight (M269)

Section titled “Changed — the docs site runs on Starlight (M269)”
  • The documentation site is built with Astro Starlight instead of VitePress. VitePress 1.x is held to a vite release line with open advisories and no fix, and its successor is still an alpha.
  • Every published URL still resolves: the same 61 pages at the same addresses, and every heading anchor with the id it had, held by a gate against a record of the last VitePress build.
  • The pages read the same and gain Starlight's layout: one rail per section, the top nav, a table of contents beside each page, the light and dark themes in tflw's colours, and search across every page. The playground and the editor demos are unchanged and still run live.

Changed — tflw doctor's ✓ means the run starts (M267)

Section titled “Changed — tflw doctor's ✓ means the run starts (M267)”
  • Doctor makes the checks tflw run makes before its first request, through the same code: a tflw check error under the env — a TF060 scan target the env does not authorize, a service it does not declare, a file that does not parse — fails doctor as it stops run. The problem line quotes the first error and the tflw check command that lists the rest. A project with no test files fails it too.
  • The browser check names the engine run will launch: Chromium, or --browser's. With only Firefox downloaded, a plain run launches Chromium, and doctor now says so.
  • Doctor takes run's flags that can stop a run: file arguments, --browser, --allow-public-target, --forbid-insecure and --no-helpers, each with run's meaning.
  • Warnings, marked ⚠, never change the exit code: a .env git does not ignore, the warnings tflw check reports, and insecure true.
  • tflw doctor --all-envs prints one verdict per env and exits 1 if any env cannot run.
  • --json gains checks and warnings, and browsers.engine; suite.unparsed is gone, since a file that does not parse is now a checks error.

Added — an env block may require its own secrets (M266)

Section titled “Added — an env block may require its own secrets (M266)”
  • require env NAME inside an env block requires the name only when that env runs. A secret only staging sends no longer has to be set — or faked in .env — for a local run. The top-level line keeps its meaning: required under every env. Not allowed in defaults (TF025).
  • TF077 asks whether a name is declared for where it is read. An env block reads the top level and its own lines; a session or signer scoped for env reads names every env in its scope declares; everything else, test files included, reads the top level only, since a test runs under every env. When some env does declare the name, the message says which and the hint names the move.
  • TF097, a warning: an env block requiring a name the top level already requires.
  • The startup refusal names the env for a name only it requires — SHOP_TOKEN (required by env staging) — and tflw check's not set here note counts what the selected env requires.
  • Every declared secret is masked, whichever env declares it, not only the selected env's.
  • tflw doctor lists the env's required secrets by name and whether each is set, reads the project's .env as run does, and exits 1 when one is not set.
  • A run no longer writes report/.last-run.json. tflw run --failed has read the kept runs under report/runs/ since M255, and nothing else read the file. It is gone from the report directory, from each kept run, from the page's Run header and from tflw run --help.
  • The docs site's walkthrough pictures of a live run are now cut reproducibly: the shop runs on the port the walkthrough names, and the run's clock is fixed.

Changed — flag, diagnostic and keyword descriptions are written for the reader (M264)

Section titled “Changed — flag, diagnostic and keyword descriptions are written for the reader (M264)”
  • Every flag effect, diagnostic meaning, generator note and keyword summary is plain user text. These strings are printed by tflw --help, the editor's hover and completion, the docs site's reference pages, tflw docs and SPEC.md's generated tables, and 119 of them carried milestone and decision ids, review-finding numbers and the history of how each rule came to be. Each now says what triggers it, why that matters, and how to fix it; the history stays in DECISIONS.md and this file.
  • tflw docs' check that no design id reaches the terminal now reads the reference tables too.

Changed — tflw docs prints the docs pages, not SPEC (M263)

Section titled “Changed — tflw docs prints the docs pages, not SPEC (M263)”
  • tflw docs prints this site's Guide and Reference pages, grouped as the site's sidebar groups them, instead of SPEC.md's sections, which are a design record. tflw docs cli prints the CLI reference with every flag table filled in from the same manifest the site reads; tflw docs cli run prints one section; a word that is not a topic is looked for among every page's section titles, so tflw docs unique prints unique vs. random. Links to another printed page read (tflw docs <topic>), others as a URL.
  • The .tflw repro files a scan writes open # emitted by tflw — <rule>, without a milestone id.

Changed — the glossary is the walkthrough's, and troubleshooting grows by symptom (M262)

Section titled “Changed — the glossary is the walkthrough's, and troubleshooting grows by symptom (M262)”
  • The glossary defines the words the walkthrough uses, and each is linked where a chapter first uses it. Seventeen terms, each its own heading: nine new (authorized target, clock, crawl, env, flaky, seed, threshold, token, workload), and five the walkthrough never uses dropped. verify-sidebars fails a term no chapter links, and a link to a term the glossary does not have.
  • Troubleshooting gains the failures writing the walkthrough hit, by what you see: a run that ends INCONCLUSIVE, a test that passes once and fails the next time, a reuse hint not offered, TF060, pick/record/watch with no display, pick /path with no web, and the 404 from npm install before 1.0.
  • tflw docs no longer prints Distribution (P#35–39, amended by P#41–50) 🔧. A status marker between two trailing parentheticals survived one pass of each strip; the heading is now Distribution (topic distribution), and a test holds every title and group the real SPEC produces to carry no P#, decision id or status marker.
  • Fixed: chapter 8 and The editor packaged the extension to packages/vscode/tflw.vsix and then installed tflw.vsix from the clone's root, which does not exist; the .vsix is now written where the install reads it. Found by a second walk-through that copied every command from the published pages.
  • Chapter 7 teaches the page with a failure: it starts tflw ui --no-open, changes a status in Compose, writes it, runs it with ▶, reads expected status to equal 200, but got 202 on Run, and puts it back, with a tflw run that proves the file is clean. Four new pictures of that sequence (walk-edit, walk-failed, walk-run, walk-config), shot after every other view against a shop the script starts itself.
  • verify:runbook sends a page a background step printed GET /api/project with its token, and fails when nothing answers. verify-sidebars checks every /ui/ page is linked from chapter 7 or runbook/page.md, and that chapters are numbered 1–N.
Show the 80 earlier entries, newest first

Added — the walkthrough's CLI chapters (M260)

Section titled “Added — the walkthrough's CLI chapters (M260)”
  • Chapters 3–6 and 8–10 on the Coffee Shelf: the first run and its report, an API test of your own (fmt, check, refactor apply), a browser test (pick, record, watch), load and scan, the editor, CI, and your own service. Every command is run by verify:runbook and every output block is copied from a run. runbook/index.md is the contents page.
  • tflw pick and tflw record take a path (/order) and open it against tflw.config's web.
  • A load run's first line names the workload, not the old keyword scenario; tflw check's hint header no longer prints a plan id; --trace has its row in the flag reference, and a test holds every flag run parses to having one.
  • Fixed: the Coffee Shelf's checkout tests mint their idempotency keys with unique, so a second run against the same shop no longer replays the first order (M260-01).

Added — tflw init --example, and the walkthrough's commands are run (M259)

Section titled “Added — tflw init --example, and the walkthrough's commands are run (M259)”
  • tflw init --example writes the Coffee Shelf, a small shop and its suite, into the current directory, refusing to overwrite unless --force, and adds a shop script to package.json. The shop and its config both read SHOP_URL (D1417).
  • verify:runbook runs every sh runbook fence of Start here, in chapter order, in one fresh directory against the CLI packed from this tree, and compares each output block after a listed set of normalisations (D1416). Chapters 1 (Install) and 2 (The example) are its first corpus.
  • A run prints authz coverage only once the config declares a session (D1421).
  • Fixed: npm run shop exited 0 without starting on Windows and through a symlink (M259-06); a docs page's roadmap declaration did not match on Windows (M259-07).
  • The theme picker offers Paper and Terminal, with a compact switch on either (D1411). Instrument and Ribbon stay reachable through ?theme= and are never stored.
  • Every ui/ page and runbook/page.md was read against the built page and corrected, and the screenshots re-cut.

Changed — a run is read as a tree by verdict, and draws itself where you are (M257)

Section titled “Changed — a run is read as a tree by verdict, and draws itself where you are (M257)”
  • Run is a tree by verdict (D1409). One headline, one verdict — 1 FAILED · 16 passed · 3.1 s, 17 PASSED, INCONCLUSIVE or CANCELLED, with every other group a count and no count of zero. On the left the tests grouped failed (open) · inconclusive · skipped · passed (folded, with a count); the page opens on the first failure with its failing step open. Picking a test draws its steps on the right, the compared run's same step one line under each, with open in Compose and rerun this test. The chip row of runs is a picker, run 5 min ago ▾; the filter keeps the name and the file, and failed is the tree's own first group.
  • security and history are rows of the tree (D1409). The findings block opens from a security · 3 findings · worst high ▸ row that is there only when the run has something to say about security; history ▸ lists each test across the kept runs, failures first.
  • A run draws itself in the rows (D1407). While a run reaches the open file, a line under the file's name reads running · step 3 of 7 · 1.2 s · cancel, each step's mark lands on its row as it is judged, and the right-hand column follows the step the run has reached and stays on the one that failed. A cancelled run leaves the steps it never reached not run. The playback region under the columns is gone; a browser test's trace is a link in the screenshot tab and opens in the Run tab's viewer.

Changed — Compose edits a test where it is, and the right column shows what came back (M256)

Section titled “Changed — Compose edits a test where it is, and the right column shows what came back (M256)”
  • A picked step edits in place (D1405). Clicking a row, or pressing Enter on it, turns that row into its own editor — a request into its method and path with headers · body · more folded under it, an assertion into subject · matcher · value with its verdict at the end, every other statement into its own fields — and Esc closes it. The editor card beside the steps, its four tabs and its second drawing of the request's assertions are gone: each statement is drawn once. A test's clauses (tags, retries, a with each table, the workload) open as a card under its row.
  • The right column is evidence, and it opens on the last run (D1406): the picked request's status, headers and body as the newest run recorded them, under a line that dates them, with ⚠ this step changed since · send to refresh when the request's words have been edited. Below evidence full it says the body was not kept. Tabs — response · plan · scan · screenshot — are drawn only for what the picked step earns; a browser step shows the page the last run saw. Ticking a value writes the assertion or the capture under its request and opens it.
  • + record lands rows under the picked step (D1408), each with keep · drop; ▶ try runs the test with them in it, and stop keeps what was kept and drops the rest. The session panel is gone. A locator field's picker is ⌖ pick.
  • Below 1100 px the evidence folds under the picked row (D1412); the divider that sized the response under the editor is gone with the editor.

Changed — tflw run --failed replays what is failing, and the file list carries verdicts (M255)

Section titled “Changed — tflw run --failed replays what is failing, and the file list carries verdicts (M255)”
  • tflw run --failed replays the tests whose newest verdict in the kept runs is a failure (D1414) — pytest's --lf rule — instead of the previous run's failures from .last-run.json. A narrower run in between (--tag smoke, one file, the page's Send) no longer redefines the set, so the which was filtered by warning is gone; a --no-keep run does not move it. Nothing failing still falls back to the full suite with a note. .last-run.json is still written. The VS Code command is now tflw: Re-run the failing tests.
  • Every test in the page's file list carries a dot (D1404) — passed, failed, or hollow for skipped and not run yet — read from each test's newest kept run; a file's dot rolls its tests up, and the line under the chips says how old the dots are and how many are red. The Run tab carries the newest run's verdict.
  • A failed chip beside the kinds narrows the list to the failing tests, opens each file onto them, and makes ▶ tflw run --failed — in the address as failed=1.
  • Requests are no longer rows in the file list — a request is a step, and Compose lists the steps. + on a row shows on hover or focus, and the row's menu carries the same gesture.

Changed — the page's shell: one header, the kinds as chips (M254)

Section titled “Changed — the page's shell: one header, the kinds as chips (M254)”
  • The four doors are gone; the four kinds are chips at the head of the file list (D1399): all, API, BROWSER, LOAD, SCANS, each with its count, and a pressed chip filters the list to the files holding a test of its kind. What Compose can do with a file is read off the file's own kinds, never off the chip, so switching chips never changes the pane. + new file and + new test ask which kind to scaffold, and + new crawl shows on a file with a crawl or under the SCANS chip, so an API file can gain its first one.
  • One header (D1400): the project, the env, ▶ run, more… (now holding workers and headed too), then Auth, Config, the version, ? and the theme. The door bar and the run strip are gone; the tab strip under the header carries Compose · Source · Run. Chrome above the pane: 193 px → 109 px at 1440 × 900.
  • Auth and Config open as panels over the pane from the header (D1401), closed by ✕ or Esc, and keep their addresses (#/auth, #/config).
  • No landing (D1402): the page opens on the file you had open, else the file declaring the most. The kind choice before tflw init stays, on the page shown for a directory with no project. The last-file memory is one key per project; the old per-door keys are read once.
  • ▶ runs exactly what the list shows (D1403), and says how many: ▶ run all · 38, ▶ run API · 19, ▶ run checkout.tflw · 7.
  • The address (D1413) is #/<tab>/<file>/L<n>?kind=…; a link from before (#/api/compose/…) opens the same file with that kind's chip on.
  • tflw run --kind api,browser runs the tests and crawls of the listed kinds, as their own statements classify them — the same derivation the page's chips count with (D1403). OR within the list, AND with --tag, --only and --failed; an unknown kind, an empty list, or a kind no test is of exits 2.

Fixed — the .vsix release workflow parses

Section titled “Fixed — the .vsix release workflow parses”
  • release-vsix.yml did not parse (M251 D): a : inside a plain scalar made GitHub refuse the file on every push, a failed run with no jobs. The step is a block scalar now, and verify-contributing.test.mjs hands every workflow to a YAML parser as well as reading it line by line, so a workflow that does not parse fails the suite rather than the push.
  • RUNBOOK.md, in both repositories (D1350, amended). It described one maintainer's machines and routine rather than the project, so it is no longer published. What a contributor needs from it — the sibling's envs, its sweep flags, regenerating the files tflw pins, and merging the sibling's PRs with a merge commit (D1397) — is in each repository's CONTRIBUTING.md, which no longer describes one person's offload setup either.

Added — the runbooks, and a site ready for 1.0 (M253)

Section titled “Added — the runbooks, and a site ready for 1.0 (M253)”
  • The adopter's runbook, assembled (D1350). The site's Runbook section opens on an index of the operator's day, and gains Troubleshooting — by symptom: TLS, a proxy, allow hosts, missing browsers, the page's 401, TF083, Windows — a Glossary of the page's and report's own words, and an upgrade section with tflw migrate. The sidebar gate asks about every page in runbook/. The sixteen annotated *-explained.tflw examples are linked from the first guide page.
  • The maintainer's runbooks. RUNBOOK.md in both repositories (removed again before release; see Removed, above).
  • SPEC's badges tell the truth (X7). Seven of the eight open section badges no longer described their section; the parking lot listed the recorder, the docs site and tflw fmt as out of v1. A gate now holds every 🔮/🔧 heading to an owner that resolves in DECISIONS.md.
  • Publishable (D1351). Every sentence the site declares true only before 1.0 sits behind a PUBLISHED flag beside the text 1.0 day needs, held by a test; publish day flips one constant. private stays true (D1379).
  • This file gains M233–M246.
  • Fixed: the page suite's temp-directory cleanup retries Windows' transient EBUSY (M252-02).

Added — the editor becomes a run surface (M251)

Section titled “Added — the editor becomes a run surface (M251)”
  • The Test Explorer (D1368). The VS Code extension lists every .tflw file and its tests; a file or a test runs as tflw run with --format ndjson, each verdict lands beside the test and a failure at the step that failed, and the run's report.html is linked from its output. tflw: Re-run the previous run's failed tests is --failed. The ▶ lenses run through it.
  • An outline, references and folding (D1366). tflw lsp answers documentSymbol (tests, crawls, actions, elements and hooks; a config's envs, sessions and signers), references (the rename's own grouping, across importers for an action, session or element, with or without the declaration) and foldingRange (every block the lexer's offside rule opens, and comment runs).
  • Code actions (D1367). Each reuse hint is Extract into action / Extract into element, built by @tflw/lang's planReuseApply — the planner tflw refactor apply now calls too, so the editor's edit and the command's write are the same bytes, and a test holds them equal. A diagnostic whose help says did you mean x? offers the one-word fix.
  • Distribution, prepared (D1389). CI packages the .vsix on every PR as an artifact; release-vsix.yml attaches it to a tag's release, run by hand on the owner's word (D1379). The listing (categories, CHANGELOG.md, README) is complete for the Marketplace and Open VSX. Nothing is published.
  • Docs. Working in the editor in the runbook; the editor page's install and run sections.

Added — WebKit, the Action, the flake table (M252)

Section titled “Added — WebKit, the Action, the flake table (M252)”
  • WebKit, once: CI runs a real UI test under --browser webkit on its Linux jobs and checks the engine is recorded in results.json. Chromium and Firefox stay the engines the whole suite uses.
  • A GitHub Action (.github/action/): installs tflw and its peers into the project, downloads the browsers, runs, uploads the report, annotates each failing test's file, and exits with tflw's code. CI dry-runs it on every pull request against the example storefront, with the CLI packed from the commit under test. It is published as its own repository on 1.0 day, not before.
  • A flake policy (D1375): a flake is logged at first sight and filed at the second.
  • The runbook's In CI section names the three platforms and the Action.
  • A macOS job (D1377): build, typecheck and the whole test suite on macos-latest, Node 22, beside Linux and Windows. It is not a required check until it has been green for a week.

Fixed — what macOS's first run found (M252)

Section titled “Fixed — what macOS's first run found (M252)”
  • Large output is no longer cut off when piped (D1395): the CLI waits for stdout and stderr to drain before it exits. On macOS a pipe is asynchronous, and tflw spec --json | … arrived cut at 64 KiB; Linux writes pipes synchronously, so no Linux run could show it.
  • A symlinked TFLW_SOURCE_ROOT anchors SARIF results again (D1396): the named root is resolved to its real path. A symlinked spelling of the run's own directory rebased every result outside the root, and the SARIF shipped with no file annotations — without an error.
  • A file named through a symlinked directory checks as itself (D1398): file arguments are spelled the way the working directory is. Every use "./helpers/…" in such a file was refused with TF083 as loading from outside helpers.

Added — the page completes and explains (M250)

Section titled “Added — the page completes and explains (M250)”
  • Completion in the page's editor (D1361): Source and Config offer the language server's own list as you type — steps, subjects, matchers, generators, config keys, the file's values and elements, the project's sessions. Ctrl+Space asks where nothing is typed; Enter takes the entry.
  • Announcements and reduced motion (D1365): one polite status region says a run started, how it ended, and which file was saved; the page's only animation stops for a reader who asked the system for reduced motion. A VoiceOver runcard (packages/cli/test/MANUAL_VOICEOVER.md) covers what no automated suite can hear.
  • element declarations on the page: the file row lists the file's element lines, each a name and a locator you can edit; + element adds one under the imports, and a blank name removes it. A name the parser would refuse is said in the pane and never written.
  • A test's rows block on the page: the band lists each judgement under a with each test, with its count (exactly, at least, at most, no, every, or just the number) as a field and a remove button; removing the last one takes the rows line with it.
  • A control's tooltip is its description all the time (D1387): a screen reader hears a control's hover sentence whenever it reaches the control, not only while the tip happens to be on screen, and hiding the tip no longer leaves the control described by nothing.
  • Rows move (D1391): each row of a test on Compose has ↑ and ↓ (Alt+↑/Alt+↓ on a focused row); a request moves with the assertions under it, and nothing leaves its own test or hook.
  • A refused save offers a way back (D1393): when the file changed on disk under a draft, the refusal carries re-read from disk, which shows the file as it is and drops the draft.
  • Ctrl-C and Cancel stop every run the same way (D1394): no new test starts, the ones running finish, and the partial report is kept, marked aborted; a second Ctrl-C quits at once. A run with no workload used to exit with nothing, and a mixed one ran every functional test to its end.
  • The page follows a run started from a terminal (D1392): tflw run writes report/.running.json and a live report/.running.ndjson while it runs and removes both once the run is kept; tflw ui draws that run on the Run tab as it goes.
  • The page's coverage floor is stated (D1364): packages/ui's unit floor is a ratchet, and the page is graded by the suites that drive it in a browser. No number moved.
  • Docs: the runbook gains Working on the page; What the page will not do no longer sends a cross-file move to a tflw refactor that does not do one.

Added — a script can drive record and pick (M247)

Section titled “Added — a script can drive record and pick (M247)”
  • --cdp-port <n> on tflw record and tflw pick (D1385): the headed browser's DevTools endpoint on 127.0.0.1:<n>, announced on stderr, so a script can connect with Playwright's connectOverCDP and act in the window. A recorded session can now be checked against a golden file without a person at the keyboard. Chromium only; any other --browser, or a port outside 1–65535, is a usage error.

Added — the CLI and the reports remember (M249)

Section titled “Added — the CLI and the reports remember (M249)”
  • Every run is kept (D1362). tflw run copies what it wrote into report/runs/<id>/, the newest runs keep N (50 by default) — the layout the page already kept its own runs in, so one reader serves every run however it was started. --no-keep for a scratch run. Each test in results.json carries its file's sourceHash.
  • History, read from the kept runs (D1362). A failing test's summary line says failed in 3 of its last 10 kept runs, and flaky when the verdict changed between two runs of the same file. The page's Run tab draws a dot per kept run, a flaky across runs pill, and a sparkline per load threshold.
  • tflw merge <report-dir>... --out <dir> (D1369). Joins the report directories of a sharded job or a sweep's groups into one: tests in the order given, counts re-derived, findings deduplicated by fingerprint, mergedFrom naming the inputs, and an exit code that is the merged verdict.
  • tflw doctor (D1370). What this machine and project will run with, offline: versions, the env's services, proxy and TLS facts, the suite, the downloaded browsers. --json for scripts. It exits 1 only for no config, Node below 22, or browser tests with no browser.
  • tflw <verb> --help prints that verb's flags; tflw --help lists the verbs, one line each.
  • Schemas for results.json and events.ndjson, generated from the types and published at /schema/; both files name theirs in $schema.
  • Docs: the runbook gains Installing tflw and Running a suite; the CI guide's shard section ends in tflw merge.

Added — a session that signs in through a browser (M248)

Section titled “Added — a session that signs in through a browser (M248)”
  • session <name> oauth2 code (D1354): the authorization-code grant with PKCE, for identity providers whose only grant is a person signing in on a page. The block holds authorize url, token url, client id, an optional client secret and scope, a loopback redirect (port 0 for one the OS chooses), and the sign-in's browser steps. tflw opens the authorize URL, runs the sign-in, takes the code at the redirect, checks state, and exchanges the code with the verifier. Tests as it carry the bearer. expires_in sets the TTL and a refresh_token is spent before signing in again. The code, the verifier and both tokens are masked in every report.
  • TF093: a sign-in step that makes a request of its own (api, wait until api, capture). TF094: a missing redirect, or one that is not a loopback http URL.
  • The page's Auth panel marks the session oauth2 code and says what it does; the runbook's Sessions section and the sessions guide show the block.

Added — the language joins its sessions to the browser (M247)

Section titled “Added — the language joins its sessions to the browser (M247)”
  • A session signs the browser in (D1352, amending D10). test "…" as shopper starts its browser context with the session's cookies — HttpOnly, Secure and SameSite kept — so the first open is signed in and a mixed API-and-page test needs no form login. The first browser step's report line says what was seeded, or that the session carries headers only. Hosts are never rewritten (localhost and 127.0.0.1 stay two sites), and a login made through the page still does not flow back to the api steps. The page's Auth panel says the new rule.
  • skip "reason" on env ci, staging (D1353): a skip that holds only in the named env blocks. Reported with its env (… (on env ci)), skippedOn in results.json, N skipped (M by env) on the summary; an undeclared env is TF088. The page's skip row takes the env list.
  • element <name> = <locator> (D1356): name a locator once and use the bare name wherever a locator goes — click checkout, expect cartCount is visible, within cartCount, drag, mask. Importable like an action; an unknown name is TF089, a repeated one TF035. tflw check offers a reuse hint for a css/xpath string written in two or more files, and tflw refactor apply writes the element line into shared/elements.tflw and rewrites every site. The language server resolves, renames and completes element names; the page's locator pickers offer them.
  • with each concurrently (D1359): a table's rows run at once, each still its own case in row order; a failing row cancels nothing. One row marked concurrently is TF090.
  • Docs: the patterns page is nine sections, one per thing the language refuses and the shape that works instead (D1360); the runbook starts with Setting up a project.
  • --tag's exclusion form (--tag !slow) was already shipped (M242); a planned --skip-tag flag was withdrawn as a second spelling of it, and the stale comment that hid it was corrected.
  • In a subject position, a bare word that is not one edit from a subject keyword is now read as an element name. expect total equals 3 meaning a bound value is TF089 at check time — whose hint says to write {total} — rather than a parse error.

Fixed — what the dogfood found building on M247

Section titled “Fixed — what the dogfood found building on M247”
  • A crawl can carry expect over a real API (M247-02). D285 (no power to fail) is now asked of the whole surface. A route where every rule stood down reports not applicable on this route, and an assertion that applied on no route fails once, after the walk. Before this, a crawl over any real API failed on its /health.
  • --baseline-write creates the baseline the config declares (M247-01). The missing-file refusal prescribed that command, and the command met the same refusal. Writing any other path is still refused.
  • An extracted action imports the elements it uses (M247-03). tflw check offered to lift a step window naming an imported element into shared/…, and refactor apply refused its own proposal with TF089. The extraction now imports the declaring file. A window whose element is a test file's own declaration, or means two selectors across occurrences, is not offered.
  • An imported action finds the elements its own file imports (M247-05). The checker read an action file's imports for element names and the runtime did not, so a shared action whose elements came from another file checked clean and failed at its first step.
  • A test skipped on an env is not held to what only another env declares (M247-04). Under that env, TF026, an unknown session and an unknown signer inside the test are not reported, so a skip written because the env lacks a service no longer blocks the run it exists for.
  • Rows of a race can set up, then fire together (G1, D1381). together is a new step: the rows of a with each concurrently test each run up to it, wait for one another, and go on at once. Each row's own setup no longer spreads the racing requests apart. A row that ends before the barrier stops holding the others. Anywhere but the top level of a concurrent test it is TF092.
  • What before file makes, the whole file reads (G3, D1382). A let or capture in before file is now readable, read-only, by every test, row, each-scope hook and after file. So a race and the test that judges it can name the same coupon. Binding the name again is TF091. Breaking, deliberately: a file that relied on the old isolation to reuse a name now gets TF091 and must rename one of them.
  • A race whose outcome is only in the responses can be judged (G10, D1384). A rows block under a with each test counts the rows whose last response or bindings satisfy a line: expect exactly 1 row status equals 201. It runs once after the last row and reports as its own entry. Before this, a race that left nothing to read back (one email registered five times at once) could only be proved with a JS helper. No table is TF095; a page subject is TF096.
  • any/all quantify into a nested array (G8, D1383, the sibling's TFLW-GAPS.md row 11). expect any body.orders.items.quantity equals 3 over orders that each carry items[] read .items.quantity off the second array as undefined and reported none of N matched with the matching item present. The path now fans out across every array it crosses, and a failure names both indices.
  • signer in tflw.config (D1344–D1349): an HMAC template — signs "{timestamp}.{body}", a header line carrying {signature}/{timestamp} — or AWS SigV4, written on node:crypto with no dependency. A step opts in with sign with <name>, a session with signed with <name>. The bytes that are signed are the bytes sent, multipart boundary included. Negatives are overrides on the line: secret "wrong", at now - 10 minutes, then body { … }. GitHub, Stripe, Slack and a partner scheme are recipes in Signed requests.

Added — an upload names a list of files (M245)

Section titled “Added — an upload names a list of files (M245)”
  • upload "./a.png" as "files", "./b.pdf" as "files" type "application/pdf" sends one multipart request with one part per file, in the order written, then the form fields (D1343). A one-file upload prints byte-identical. An unreadable file fails the step naming file i of n, and nothing is sent.

Fixed — the ledger after the enterprise arc (M244)

Section titled “Fixed — the ledger after the enterprise arc (M244)”
  • An imported action's steps are reported at the action file's own lines, under the test that called them (M240-03); StepResult.file says which file.
  • Nine decision ids that two records each defined are renumbered (D1334–D1342).
  • A screenshot compared under a different font set is named as the likely cause, with the baseline's key; the key itself is unchanged (M243-15).
  • CI builds, typechecks and runs the whole suite on native Windows under Node 22 (D1333); what it found was repaired or excluded by name with its reason.
  • Matchers (D1326): has count at least N, has count at most N, is empty, is not empty.
  • skip "reason" (D1327) — skipped is a third outcome in every reporter; --tag !x excludes.
  • GraphQL (D1328): body graphql "…" [variables {…}] [operation "…"].
  • Strings (D1329): length of x, x joined with ", ", capture … matching "re" as n.
  • --shard i/n (D1330) and tflw export otlp (D1331), which posts a run as OTLP/HTTP spans with no SDK. A cookbook of patterns, each kept true by a sibling plant (D1332).

Added — the page authors every declaration (M241)

Section titled “Added — the page authors every declaration (M241)”
  • Source and Config are a real editor — undo, the language's colours, errors underlined in place (D1321); an action is a declaration with its own band (D1322); a crawl is built on SCANS (D1323); the run strip offers only the flags a run can take (D1324).
  • Every control is at least 24 px and every text 11 px; the file list collapses, filters and virtualises; every report records who ran it, where, with which tflw; runs keep N (D1325).

Added — the page's first five minutes (M240)

Section titled “Added — the page's first five minutes (M240)”
  • A landing that opens on the project, one Tab stop per region with arrow keys inside it, six shortcuts and a ? legend, and an axe gate over every view in both themes (108 violations → 0).
  • Product text gives a reason or a docs link, never an internal identifier, held by verify:no-internal-refs (D1309–D1315).

Added — the page gets a boundary, use gets a fence (M239)

Section titled “Added — the page gets a boundary, use gets a fence (M239)”
  • tflw ui serves on loopback behind a per-start token in its URL (D1316–D1318).
  • helpers "./dir" in tflw.config names where a use may load from; TF083 elsewhere; tflw run --no-helpers; tflw check lists every helper a run would load (D1319).
  • SECURITY.md, a supply-chain CI job (npm audit, a CycloneDX SBOM) and Dependabot (D1320).

Fixed — a baseline names what it no longer matches (M238)

Section titled “Fixed — a baseline names what it no longer matches (M238)”
  • A run with a baseline names the accepted entries that matched no finding, in the console and in results.json's baseline block — advisory, never the exit code. A narrowed --baseline-write says which entries it dropped.

Fixed — the page suite, and the ledger (M235–M237)

Section titled “Fixed — the page suite, and the ledger (M235–M237)”
  • The page suite's reads that answer against one frame are found by a classifier (verify:settled-reads) and repaired; the twelve tests that were red in CI for that reason alone are green, and the count is a ratchet.
  • drag holds its source; the cross-repo gates learn a clock; a coverage run no longer loses a file's second transpilation to the source map.

Added — the docs site photographs the example (M233, M234)

Section titled “Added — the docs site photographs the example (M233, M234)”
  • The site's UI section: one page per door, pictures cut from examples/storefront, the runnable example, in both themes, with zoom.
  • Per-package coverage floors in coverage-floors.json, each one point under its measured value.
  • Session hardening: refresh-on-401, per-session TTL, session <name> oauth2 client-credentials sugar, and per-env cert/key mTLS client certificates (P#99–P#100).
  • Safety and evidence controls: the allow hosts allowlist, --forbid-insecure as a CI policy gate, evidence full|headers only|none, and redact (P#101).
  • matches schema contract validation against a JSON Schema or OpenAPI document (cached per run), and Retry-After-aware retry (P#102).
  • The documentation site (VitePress) plus spec-data.ts — one structured manifest of matcher, generator and CLI-flag signatures that SPEC.md's tables, the Reference pages and the LSP's hover/signature help are all generated from, instead of four hand-maintained copies (P#103).
  • A real Language Server (tflw lsp, packages/lsp-server): diagnostics, hover, go-to-definition, completion, rename, signature help and semantic tokens, with the VS Code extension reduced to a client over it (P#104–P#105).
  • Connection-failure assertions — expect request connects / expect request fails (P#108).
  • CI ergonomics: --failed (replay last run's failures), --bail, --format ndjson, --no-timestamps, --log-file (P#111).

Added — browser arc (0.2 internal milestone)

Section titled “Added — browser arc (0.2 internal milestone)”
  • Browser interaction steps (open/click/fill/fill form/select/tick/untick/hover/ press/scroll), a tiered locator model (button/text/list/field cascade, css/xpath escapes, within scoping, strict-ambiguity errors), auto-retrying UI expects, and dialog handling (accept/dismiss).
  • Frames (within frame), tabs, downloads, drag-drop, and wait until <ui-locator>.
  • The report/ directory: failure-first screenshots + Playwright trace, --browser/--headed/ viewport flags.
  • Network observation (expect request to "<url>" was made) and stub route mocking.
  • Accessibility assertions (expect page has no [<severity>] a11y violations, axe-core).
  • Visual regression (expect page matches snapshot "<name>" [mask <locator>]*, --update-snapshots).
  • tflw watch (headed re-run on every save, one shared browser, same seed) and tflw pick (opens a real browser, prints a verified locator per click).
  • The log statement: log [debug|info|warn|error] "message with {var}" [to console|html|both] — narrates what a test is doing, in the author's own words; always succeeds, never an assertion.
  • Two tflw.config keys (log destination, log level, override semantics like evidence) plus --log-output/--log-level CLI overrides for a single run — an explicit per-statement to … clause always wins over both. Every log step is always recorded in results.json/--format ndjson regardless of level/destination; only console text and report.html filter what renders.
  • Full LSP/VS Code support: hover, completion, and semantic highlighting for log.
  • tflw check now surfaces advisory duplication hints (RF0xx) and tflw refactor apply <id> mechanically extracts a flagged window into a shared action, rewriting every call site.
  • tflw migrate: the rewrite machinery (Diagnostic.deprecation + collectMigrations/ applyMigrations) for checker-flagged deprecations, wired end to end and proven via synthetic diagnostics. It has nothing to migrate and cannot have: no checker rule emits a deprecation, because the grammar has been additive-only since the first release (P#45), so every run reports no deprecated syntax found and touches no files. Shipped ahead of the first real deprecation, not as a working migration path.
  • A 10-test mixed UI/API acceptance suite against a purpose-built dogfood target (webV2: React/Vite SPA storefront + SSR admin console) plus a side-by-side comparison vs. raw Playwright + node:test — found and fixed 4 real, previously-shipped bugs in the process.

Added — performance arc (0.3 internal milestone, M29–M49)

Section titled “Added — performance arc (0.3 internal milestone, M29–M49)”
  • Load testing with no second language and no second command. A test becomes workload-bearing the moment it contains a workload line; the body is ordinary steps, so an action written for the functional suite is reusable unchanged.
  • Five workload shapes: ramp to N users over D, hold N users for D, step …, spike …, and run N iterations across M users (D97/D98/D102).
  • threshold assertions (p50/p90/p95/p99 duration, and error rate; optionally scoped to one step via for "label") — a workload-bearing test's verdict comes from its thresholds, and a workload-bearing test with none is a checker error, since it could never fail.
  • pause <duration> (VU think-time; legal only inside a workload-bearing test).
  • Multi-process load generation — --workers N forks generator processes, each taking an equal striped share of the target population/rate, merged back into one report — plus a generator self-diagnosis that warns when tflw itself, not the target, was the bottleneck.
  • A live ~1Hz console line (iterations/rps/error rate), Ctrl-C flushing a partial report rather than losing the run, and exit code 3 for an inconclusive result.
  • Validated against k6 and Artillery on a real contended target across a rung ladder — the investigation closed a genuine ~3.2× gap (a per-iteration session-refresh storm, M37), added pinned-per-VU connections and a Nagle fix, and replaced AbortSignal.timeout() with a manual deadline timer after it was found to distort tail latency. Two real bugs in the dogfood app were found by the acceptance rungs themselves.

Added — one test keyword, one report (M50–M58)

Section titled “Added — one test keyword, one report (M50–M58)”
  • scenario blocks were removed and collapsed into test, with the kind inferred from the presence of a workload clause (D93–D96, D103). scenario is now a hard TF033 error naming its replacement.
  • tflw load was removed and folded into tflw run, which drives functional and workload-bearing tests alike in one pass, in file declaration order (D105–D115).
  • A parallel/sequential test-header modifier controls a test's concurrency with its file-siblings; --parallel N (file concurrency) and --workers N (load generation) are separate, unrelated axes, and --skip-workload drops workload-bearing tests for fast functional iteration.
  • Workload results render inline with the functional ones in the same report.html/junit.xml/ results.json — there are no separate load-* artifacts (D116–D122).
  • An exclude config directive for file discovery (D127).

Added — pen-test arc, Tier 1 (0.4 internal milestone, M128)

Section titled “Added — pen-test arc, Tier 1 (0.4 internal milestone, M128)”
  • expect/check response has no [<severity>] security violations — a built-in pack of twelve HTTP hygiene rules (cookie flags, HSTS/CSP/X-Frame-Options/nosniff, credentialed CORS wildcards, cacheable authenticated responses, version disclosure, TLS protocol version and cipher suite) run over the response the last api step received, reusing the Finding/severity model the a11y scan introduced (D283, D289, D290).
  • Applicability is a third state. Every rule declares a precondition; a rule whose precondition is unmet reports not applicable, which is neither a violation nor a silent pass. Without it, hsts-missing and cookie-not-secure would fire on every response of every plaintext suite (D284).
  • An assertion where no rule applied fails, with a dedicated message naming each precondition that went unmet — a scan that could not have failed must not report a pass (D285). A severity floor narrows which rules run at all, so the printed counts describe the work actually done (D296).
  • A session block's own login response is scanned once, at establishment, with findings attributed to the session by name and folded into that test's security assertions — otherwise a suite whose session cookie lacks HttpOnly reports clean (D287).
  • authorized target "<url>" reason "<text>" in tflw.config — the declaration layer of the safety model, required before any security assertion (TF060) and rejecting wildcards and scheme-less targets (TF061). The reason is printed in the CLI summary and embedded in report.html, so every artifact records the claim that permitted the scan (D21, D291).
  • sec/tls-version-old and sec/tls-weak-cipher, from a stdlib tls.connect() probe to the same host — the runtime drives Node's fetch, which exposes neither the negotiated protocol nor the cipher, and the dependency that would is the one the zero-runtime-dep bundle declined. One handshake per host:port per run, obeying allow hosts and re-checking the authorized target declaration against where the run actually ended up rather than the base URL the checker could see (D288).
  • The probe deliberately offers a TLS 1.0 floor, below Node's own DEFAULT_MIN_VERSION — a host speaking nothing but a deprecated protocol would otherwise refuse the handshake, leaving tls-version-old unable to fire in the one case it exists for. Measured: offering an old floor never drags a healthy server down, because the server still picks the newest version both sides speak. Cipher suites are not widened the same way, since reaching a legacy-cipher-only peer needs OpenSSL's @SECLEVEL=0, which also lowers what counts as an acceptable certificate (D298).
  • Both TLS rules answer "what does this host give a current client?" — not what the asserted request negotiated (the probe is a second connection), and not the server's whole offer (a host supporting RC4 alongside AES-GCM negotiates AES-GCM and is correctly silent). Enumerating everything a server would accept takes one handshake per suite, and belongs to tflw scan (D299).
  • No handshake at all when the severity floor has already excluded both TLS rules — the floor narrows the pack before applicability, so a critical-floor assertion never consults them, and a connection opened for an assertion that cannot use it is one nobody asked for (D302).
  • A probe that cannot connect makes both rules not applicable, never an error — a network failure is not a security verdict. But it is announced on every result line, passing ones included: a rule blocked by a failed instrument is reported differently from one blocked by its precondition, because the first means the assertion did less than it was asked to (D300).

Added — pen-test arc, Tier 2 (0.4 internal milestone, M130)

Section titled “Added — pen-test arc, Tier 2 (0.4 internal milestone, M130)”
  • expect/check response has no [<severity>] authorization violations — OWASP API #1 (BOLA/IDOR). The request the last api step actually made is re-issued under every other declared principal and the responses are compared. A separate matcher from security violations, deliberately: folding them would make every already-shipped Tier 1 assertion start sending cross-identity traffic the moment its author upgraded (D303, D304).
  • The unit of input is an observed request, not a route. 79% of api paths in a real suite carry a {interpolation}, so a static endpoint × session cross-product yields addresses nobody can dial. The probe is rebuilt from the observed trace rather than re-run through the step, which would re-evaluate unique(…)/random(…)/{var} and ask about a resource the owner never touched (D303, D323).
  • The oracle is differential on resource identity, not on status codes. An admin legitimately gets a byte-identical 200 on another user's order, and a collection endpoint's correct answer for a non-owner is a filtered 200 — both invisible to a status oracle. Extraction is narrow (bare root id only; an envelope yields no resource identity found, a failure rather than a pass) and containment is wide (a scalar-leaf walk at any depth, exact equality), because a leak returned under a different key is still a leak (D305, D321, D322).
  • Five probe outcomes, and clean has to be earned. 429, any 5xx, a non-JSON body and a CSRF-shaped 403 are all inconclusive, so a suite that trips its own rate limiter can never report the throttle as an authorization boundary. 404 sits with the refusals, because returning 404 rather than 403 to avoid revealing a resource's existence is the more careful of two correct implementations (D324, D325).
  • session <name> [oauth2] privileged — a principal that is meant to reach other principals' resources is left out of the probe set. A claim about authority, not a speed knob: marking every session privileged is refused (D307, D310).
  • probe mutating, an optional indented sub-clause under authorized target — permission for a probe to re-issue a POST/PUT/PATCH/DELETE against that host. Without it a mutating step reports not probed rather than silently sending writes somewhere nobody said it could. The one-line declaration is unchanged (D311, D330).
  • TF062/TF063/TF064 — a step naming its own credential, an assertion with no principal to judge with, and an assertion inside wait until api (which re-polls until its expects pass, so a real finding would be re-probed on every poll and reported as a timeout). TF033 covers the same rule inside a workload. TF062/TF063 are each a lexical refusal plus an exact runtime guard, because calls bind late and an action body's owner is not statically knowable (D315, D328, D329).
  • Every finding emits a runnable .tflw repro under report/authz-repro/, with per-rule templates: a collection leak's correct answer is a filtered 200, so a single always-403 template would emit a regression that goes red the moment somebody fixes the bug. Names are derived from rule + method + path + principal, so duplicates collide into one file instead of racing (D314, D332).
  • The run states its own blind spot. authz coverage: N of M api steps in the suite sit in a test that declares an owner — a static census over every discovered file, printed beside the authorized target reason, so "we probed everything we asserted on" cannot be read as "we probed everything"; plus this run's own declines, aggregated (D331).

Added — pen-test arc, Tier 3 (0.4 internal milestone, M134)

Section titled “Added — pen-test arc, Tier 3 (0.4 internal milestone, M134)”
  • expect/check response has no [<severity>] input handling violations — the request the last api step actually made, re-sent once per (mutable input × payload) with exactly one input replaced. Two bare words, not input-handling: the lexer's identifier rule is [A-Za-z0-9_] and - is the minus operator, so a hyphen cannot appear in a tflw keyword, and the hyphenated spelling the plan proposed is a parse error (D366, as corrected in build).
  • It changes no identity, and that is the whole difference from Tier 2. Tier 2 strips Authorization/Cookie and applies a different principal; this scan moves only the payload, so the observed request's own X-CSRF-Token travels with every probe and the probe reaches the code it was sent to test. Consequently there is deliberately no TF062/TF063 analogue — no owner is required, and a step carrying its own credential is fine (D370, D375).
  • A fixed, enumerable corpus — no sampling, no seed. 15 payloads in four classes, applied to every mutable input in a fixed order, so a run's request count is a number you can put in a report and it is the same number tomorrow. A seeded random fuzzer would need a corpus-coverage story tflw has no machinery for, and would produce findings whose fingerprints moved with the seed (D367).
  • Three kinds of mutable input read off the observed request: an identifier path segment (UUID, all-digits, or 24+ hex), a query parameter, and a JSON body leaf. Type-confusion payloads apply to body leaves only — a path segment and a query value are strings by construction, so there is no type there to confuse. Path and query payloads are percent-encoded, or ../ would be normalised away by the URL parser before the request left the process (D371).
  • The bar is disclosure, not status. A bare 5xx is not a finding — plenty of correct applications answer 500 to a type they never expected, and Tier 1's zero-false-positive bar is not renegotiated. Four rules read for evidence instead: sec/error-detail-disclosure (serious), sec/reflected-input-unescaped (moderate), sec/path-traversal-read (critical), sec/oversized-input-accepted (minor). Each subtracts the control response's own hits, so a finding means this payload caused it rather than that the string was always there (D373).
  • Three outcomes, not Tier 2's five — answered (any status the host produced, 5xx included), inconclusive (429 only), not probed. A 5xx is a first-class answer here because the app demonstrably did process the payload, which is the thing being asked about.
  • probe oversized and probe traversal, two new indented sub-clauses under authorized target, siblings of probe mutating and each granting only itself. Off by default because one class is exhaustion-shaped and the other's positive finding is the act of reading the file. This is where D21 safety layer 4's "per class" language first has literal classes to apply to; Tier 2's single boolean discharged the layer for a tier that had one class, and the layer was re-opened rather than assumed discharged (D372).
  • TF067 — an assertion on a request with nothing to mutate (no id segment, no query, no JSON body leaf) had no power to fail, and fails. A checker code with a runtime twin that reuses it, so the interpolated cases the checker deliberately stays silent about are still caught. The checker answers false on every uncertainty: a {var} in the path may bind to an id, a body from file is not the checker's to read, and raw text may well be JSON (D382).
  • TF064 widened, not duplicated. The wait until api refusal now covers both pentest scans under one code, renamed SCAN_ASSERTION_REPEATED_REQUEST, because the repair is one identical sentence for both — what makes the construct wrong is a property of wait until api that does not know which scan is asking. TF033 covers the workload case with a blunter hint: the multiplication here is one probe per payload per mutable input.
  • Probes stay strictly sequential, one in flight, so safety layer 5's deferral condition ("the first change that permits two probes in flight") is still not met and probe rate does not come due. Every result line, pass or fail, states what it cost (3 sites, 30 requests sent, 10.0 per site) and what it declined to send, because a green run that skipped two classes and a green run that ran them are otherwise indistinguishable (D381, D291).

Added — pen-test arc, findings & the gate (0.4 internal milestone, M134b)

Section titled “Added — pen-test arc, findings & the gate (0.4 internal milestone, M134b)”

One contract for all three scans, not three — a per-tier answer is how --fail-on would come to mean a different thing depending on which matcher a file happened to use.

  • Every finding reaches the report. results.json grows a findings array and report.html grows a Security findings block carrying, per finding, a stable 16-hex-character fingerprint computed from the scan, the rule, the endpoint, the location within it and the invariant violated — and deliberately not from the payload that triggered it or the response text proving it, so rewording an error message does not invalidate an acceptance for a change that fixed nothing (D376, D385).
  • --fail-on <severity>, --baseline <file> and --baseline-write <file>. A scanner that goes red on its first run against an existing codebase gets turned off; these are what make "adopt now, fix on a schedule" possible. Both gates are applied inside the assertion, before its pass/fail decision, and both obey one rule: the gate can only relax, never tighten, and never silently. A test that wrote its own severity floor keeps it — the stricter of test and flag wins — because a command-line flag that could turn a green suite red produces a failure nobody can locate from the source. A withheld finding still renders, badged with which relaxation withheld it, and the passing line names the count: a report that agreed with the gate would describe the gate rather than the run (D377, D386, D387).
  • Neither gate applies to the negated form (not has no … violations), where a finding is what makes the assertion succeed — suppressing findings there would fail an assertion for having found something, which is not a relaxation in any sense (D386).
  • Baselines match on the fingerprint alone. The rule and endpoint beside each entry are for the human reading the file; matching on the name would let a rule renamed in a tflw release silently un-accept every entry that mentioned it. A malformed baseline is refused rather than degraded to "accepted nothing", because every failure mode of the file makes a build greener and an empty acceptance set looks exactly like a codebase that fixed everything. Stale entries are reported and never removed — a --tag run legitimately produces a subset of the suite's findings, so pruning on absence would delete acceptances the next full run still needs (D387).
  • --probe-seeded <n> adds n generated mutation payloads per already-granted class on top of Tier 3's fixed corpus. It cannot widen what authorized target permitted — seeding is a capability of the run and a mutation class is a claim in the config, so no number reaches traversal on a target that never granted it. Its findings are reported and never gate: a generated payload has no stable fingerprint, appearing under one seed and vanishing under the next, so gating on it would either churn a baseline every run or fail a build on a coin flip. Each renders with the payload and the seed that drew it under the call to action promote this payload into the corpus, which makes the layer self-liquidating. Accepted consequence, stated rather than hidden: a real weakness found only this way does not fail CI until somebody promotes it — a finding you must read beats a gate you cannot trust (D369, D388).
  • Which rules ran, on passing runs too. Every report carries a per-scan census of the rules that applied and the rules that stood down with the reason. A rule that stands down produces no finding, so before this the only run in which that information existed was one where something else had already failed — and the run where it disappeared was the ordinary green one. Closes the long-open row about a report that could not name its not-applicable rules (D389).

Added — pen-test arc, remediation (0.4 internal milestone, M135a)

Section titled “Added — pen-test arc, remediation (0.4 internal milestone, M135a)”
  • Every finding now carries how to fix it. All eighteen rules across the three scans have an authored knowledge-base entry — what the weakness is, why it is worth repairing, the repair in framework-neutral terms and again concretely in NestJS, a CWE id and the OWASP document the fix is traceable to — rendered as a collapsed possible fixes disclosure inside each row of the report's findings block. An alert that names a weakness and says nothing about repairing it is a task handed to somebody with the research still to do (D402).
  • A rule cannot ship without one. Each pack exports its ids as a closed tuple and the KB is typed over their union, so a nineteenth rule fails the build until an entry exists for it. The failure this prevents is quiet — remediation missing because nobody wrote it looks exactly like remediation omitted because the fix was thought obvious, and it surfaces to whoever is triaging (D409).
  • Severity stays in the rule modules, against the original report design putting it in the KB. It is already stated twice per input-handling rule; a third home is how the rule that fails a build and the rule shown in a dashboard come to disagree with nothing to say which is right (D408).
  • Groundwork, not yet user-visible: the SARIF severity mapping that M135b's exporter will publish — four tflw levels onto SARIF's three, each with the numeric GitHub actually ranks on (D406).

Added — pen-test arc, findings.sarif (0.4 internal milestone, M135b)

Section titled “Added — pen-test arc, findings.sarif (0.4 internal milestone, M135b)”
  • report/findings.sarif — SARIF 2.1.0, ingested directly by GitHub code scanning. Each alert anchors to the .tflw line that made the assertion, carries the endpoint as a logical location (the endpoint is the finding's subject, but its source is usually not in the repository being scanned), and arrives with the remediation, the CWE tag and the references from the knowledge base above (D403, D405, D407). Paths are relative to the repository root — found by walking up for .git — rather than to the directory tflw was invoked from, because GitHub anchors an alert by matching the path against the checked-out tree and an unanchored alert uploads without an error.
  • Written only when the run actually scanned — not written empty. An empty SARIF document is not neutral: upload-sarif reads an empty results array as everything previously reported is fixed and resolves the matching alerts, so a functional-only CI job emitting one would silently close the security job's whole backlog. Absence is a signal a workflow can test; emptiness is a signal that reads as good news (D404).
  • A baselined finding uploads suppressed; one below --fail-on uploads as an ordinary alert. Accepted and unranked are different states, and a team that later lowers the floor should not watch a pile of alerts un-dismiss themselves with no change in the application (D410).
  • rules[] declares what applied; rules that stood down are listed with their reasons under runs[].properties["tflw/notApplicable"], so the three-state coverage model survives into the machine-readable artifact instead of collapsing into one empty state (D412). Seeded payloads are absent entirely — they have no stable identity, and a tracking system keyed on identity would mint a new permanent alert on every reseed (D411).
  • Authorization alerts link the runnable .tflw repro the run already wrote (D413).
  • The document is validated against the real SARIF schema in the test suite, because this format's failure mode is silence: an invalid document uploads successfully and produces no alerts, with no error to read (D414).

Added — the declaration family in tflw spec (M154c)

Section titled “Added — the declaration family in tflw spec (M154c)”
  • tflw spec now emits a seventh family, declaration — the seven words a file can open a top-level block with (test, crawl, action, import, use, before, after) and the five clauses a test header takes (tags, with each, as, retry, parallel/sequential). The manifest goes from 166 constructs to 178.
  • This was a gap in M154a, not a widening of scope. M154a recorded three deliberate departures and this was not among them; its stated reason for adding generators applies unchanged here — a construct a conformance gate plants "cannot be demanded by a gate that cannot see it", and retry and after hooks are exactly that. Measured in the dogfood corpus when the gap was found: after file used once, a bare after twice, retry five times. Thin, and structurally invisible to any coverage gate keyed on the manifest.
  • Held to the parser behaviourally, not by comparison. The other two-way tables assert that a manifest array equals a parser array, which proves the arrays agree and nothing more. Each declaration and header clause is instead parsed as a minimal file and the parser asked what it did, with a control asserting a non-declaration is still refused.
  • A hook's two scopes are one construct with two forms, matching how switch to new tab and switch to tab N are one switch.
  • The list of declarations the top-level error message names is now rendered from that same array. It was spelled out twice, three lines below the dispatch chain it describes — so a new declaration could ship with an error message denying it exists.
  • SPEC_MANIFEST_VERSION is unchanged at 1. The document's shape did not change, and adding constructs is explicitly not a version bump. The one consumer that exists reads ids flat rather than filtering by family, so the twelve new ids reach it as an unaccounted-construct failure naming each one — a louder and more precise red than a version mismatch would have produced.

Added — the subject family in the construct manifest (M174)

Section titled “Added — the subject family in the construct manifest (M174)”
  • tflw spec gains a subject family — 16 constructs, and SPEC_MANIFEST_VERSION moves 1 → 2. The left-hand side of every assertion in the language was outside the manifest: status, duration, header "…", the five body forms, request, request to "…", a locator in subject position, page, response, dialog message, dialog type and the {value} subject were in none of the seven families. A conformance gate keyed on the manifest could not demand any of them.
  • This is a version bump, where adding twelve declarations in M154c was not. Adding a family changes the document's shape, and a consumer that buckets by family and has never heard of subject drops sixteen constructs while staying green — the exact failure the number exists to make loud.
  • Two hand-maintained copies of the subject vocabulary are deleted, not corrected. parser.ts held thirteen of the sixteen and packages/lsp-server twelve of the nineteen written forms. Both now read the manifest, which they could always have done — the parser already imports it. What a user sees: TF013's "expected one of …" names every subject rather than omitting dialog, and editor completion offers response, dialog message, dialog type and the four body sub-forms it never had.
  • The roster is held to the AST by the compiler. The table is typed Record<Subject['type'], SubjectEntry>, so a seventeenth subject is a compile error rather than a wordlist someone forgets; each row is additionally parsed and asked what node it produced, because a table and a type agreeing proves only that both were edited.

Added — tflw spec, the construct manifest (M154a)

Section titled “Added — tflw spec, the construct manifest (M154a)”
  • New subcommand tflw spec [--json] — prints the construct manifest of this build: every step keyword, matcher, generator, locator, tflw.config word and diagnostic code the parser dispatches (166 today), plus a build stamp naming version, commit, dirty state and build time.
  • It exists for conformance testing. A suite that wants to prove it exercises the whole language has to be able to ask what the whole language is, and a consumer of the published package cannot read that out of @tflw/lang — the tarball is one self-contained bundle with no @tflw/* packages to import. --json is the form such a gate reads.
  • A 🔮 planned construct is absent, not listed as planned. Building one therefore makes it appear in the manifest, which turns a downstream coverage gate red on its own the day it ships — no badge to remember to flip, and no hand-maintained list of things that do not exist. MATCHERS is the one table carrying its own status field and is emitted verbatim, so a planned matcher would say so rather than quietly claiming to work.
  • LOCATORS joins STEP_KEYWORDS and CONFIG_KEYWORDS as a manifest held two-way to the parser's own dispatch list, so tflw spec cannot offer a locator the parser rejects nor omit one it accepts. element (SPEC §9.3, planned) is in neither, which is the rule's worked case.
  • Diagnostics contribute their codes and none of SPEC §17's prose — that table is ~78 KB of markdown written for a rendered spec section, and the code is the construct.
  • The build stamp answers which tflw produced this output. commit is a short sha, or null where there was no git to ask (a published tarball, a vendored checkout) — never invented, because an invented one would be believed. dirty is null when there is no commit for it to be relative to. source is dev under the unbundled npm run dev, where the bundle-time values do not exist, so a consumer can refuse a build with no provenance to check.

Added — editor support for tflw.config as its own dialect (M136b)

Section titled “Added — editor support for tflw.config as its own dialect (M136b)”
  • tflw.config files now have their own VS Code language id (tflw-config) and their own TextMate grammar (source.tflw.config). The config dialect's declaration-only vocabulary is highlighted for the first time: allow, hosts, insecure, cert, key, evidence, web, redact, viewport, oauth2, token, client, id, secret, scope, destination, level and query — eighteen words, in both the grammar and the language server's semantic tokens.
  • The split is what made this possible rather than an implementation detail: key, web and destination are ordinary identifiers in a .tflw test file, and a single shared wordlist could never colour them in one dialect without colouring them in the other. The config grammar is a delta that includes the shared one, so the two dialects cannot drift apart.
  • No change to .tflw files, by construction and by test.
  • The README described security testing as unbuilt and named a tflw scan mode that will never exist — the three scans ship inside tflw run, and the roadmap line said otherwise for two milestones after they landed.

Added — pen-test arc, the un-asked subject (0.4 internal milestone, M136a)

Section titled “Added — pen-test arc, the un-asked subject (0.4 internal milestone, M136a)”
  • A scan now reports what it could not put a question to, not only what its rules declined. The two are different facts and were reported as one: a rule that stood down looked at an observation and declined it; an un-asked subject means no observation ever arrived. Tier 2 has carried this since M130b for principals; Tier 3 announced it on the console and carried it nowhere, so a run whose entire mutation matrix was refused before it left the process wrote a results.json indistinguishable from one that probed everything.
  • Both halves now reach findings.sarif, in the run.properties["tflw/notApplicable"] bucket the three-state model already used, with a kind discriminator ("rule" / "subject") and namespaced ids (principal:shopper, endpoint:POST /notes) so a consumer grouping by id never compares a principal against a rule.
  • The built dist/cli.cjs now runs all three scans in its own end-to-end suite. Only Tier 3 did; Tier 1 read as covered because its scan also runs at session establishment, where nothing asserts on it.
  • report.html's security findings block and rule census are styled. They had shipped with a full set of class names and no matching stylesheet rule, so the one section a reader scans by severity rendered as an unstyled browser table.

Added — pen-test arc, Tier 4: the crawl (0.4 internal milestone, M137)

Section titled “Added — pen-test arc, Tier 4: the crawl (0.4 internal milestone, M137)”

The first three tiers judge a response the suite asked for. Tier 4 is the other half — it finds the requests itself.

  • crawl "<name>" [as <principals>], a top-level declaration, sibling to test and run by plain tflw run. Not a sixth workload kind (a workload kind is a scheduling policy over an unchanged body; here the body is not what repeats — the surface is), and not a tflw scan subcommand, which stays deleted. --tag, --only and --failed select a crawl the way they select a test.
  • It adds a source of requests, not a kind of judgement. A crawl body takes only the same three violations assertions Tiers 1–3 define, applied per response the crawl issues. No fourth matcher family and no new subject keyword — anything else in the body is TF070, because an api step there would be a request nobody will send under a principal nobody chose, and expect status equals 200 names one response where a crawl has many.
  • Three seeds, which find different things. seed openapi "<source>" reads the documented surface and invents path parameters, required query values and bodies for it; seed traffic re-issues every distinct route the run's own tests touched and invents nothing; seed spider "<path>" walks a page's links and forms, with max pages and max depth as optional indented bounds. Findings carry via — openapi, traffic or spider — as provenance, not identity: the same weakness reached by two seeds is one finding with one fingerprint, so adding a seed never churns a baseline.
  • seed spider fetches and parses; it does not render. No browser engine, and that is a safety statement before it is a scope one — allow hosts, the blocked-port list, authorized target, the public-target refusal and the sequential pacing all live on the request path, so a spider issuing ordinary requests inherits every one of them. A page's links are joined against the URL it finally resolved to, so a console answering /admin with a redirect does not have its relative links read against the wrong base. Same-origin: a link off-site is reported as a skip.
  • It is the only seed whose enumeration is itself traffic, so the walk is a phase of its own and discloses its cap before it walks — two lines rather than one. walked and walkCapped are reported beside discovered = withheld + sent, never inside it: a fetched page is not an operation, and folding it in would break the identity. A walk stopped by a bound sets walkCapped, so a truncated surface can never read as a complete one.
  • Everything discovered is accounted for. discovered = withheld + sent and reached ≤ sent hold always, and every route in discovered - reached reaches results.json's scanBlindSpot.declines and report.html's blind-spot block with the reason it is there. A crawler that quietly dropped the routes it could not build would report a smaller denominator and look like better coverage.
  • A response that did not reach your code is not scored. 2xx/3xx/5xx are judged; 400, 422, 404, 405, 410, 401, 403, 415 and 429 are not. The 400 row is the important one — a validator's refusal is indistinguishable from a hardened endpoint. The 401/403 row is the subtle one: if the owner was turned away at the door there is nothing to compare against, and reading that refusal as clean is the most common false negative this kind of tool produces.
  • TF068 — a crawl with nothing to crawl. Refused at check time when no seed is declared, and at run time when a seed came back empty or when a crawl sent requests and reached none of them. One code with a branching hint rather than two rows, because a runtime-only diagnostic has no check-time door for a gate to verify.
  • TF069 is withdrawn, not renumbered, and the number is skipped permanently. It was allocated and then withdrawn; by the time TF070 was minted, six comments across three packages already used TF069 as a pointer to that withdrawal, so the number was spent without ever being allocated. A shipped code is never reused.
  • A third ReportEntry kind, declared loudly. The union had exactly two members since M56, so 13 dispatch sites across 8 files could test it as a binary and be right by accident — junit.ts alone had four, where a crawl entry would have been counted as one test case, timed as a functional test and rendered as <testcase> with no consumer able to notice. exhaustiveEntry now turns each of those into a type error at the moment a member is added.
  • session … csrf from <subject> send as header "<name>" — the token a credential is issued at login, attached to every mutating request that credential later makes, probes and ordinary api steps alike. It deliberately does not live in the session's ordinary header map: the token has to stay distinguishable for sec/csrf-not-enforced (critical) to probe a principal defined as this credential minus its CSRF headers. Merged into the header map it would be indistinguishable from the Authorization header beside it, and withholding both would measure authentication rather than CSRF.
  • probe ciphers — a fourth authorized target sub-clause, and the one whose purpose is many connections to one host. sec/tls-weak-cipher has shipped since M128a but could only judge the suite tflw's own client negotiated, so a server offering TLS 1.0 and RC4 while happily negotiating 1.3 with us read clean. It now enumerates the host's offer. One rule id, not two: what tflw's client happened to negotiate is a fact about tflw's client, and the offered reading is the more accurate measure with the negotiated one as its special case. Withheld, the rule says so rather than passing quietly; absent is never rendered as an empty offer.
  • A repro per originating scan. Tier 3 input-handling findings now ship a runnable .tflw the way Tier 2 authorization findings have since M130b — report/input-repro/ beside report/authz-repro/, and SARIF's tflw/repro join reaches both.

Fixed — pen-test arc, five defects that shipped green (M137c1, M137c2, M137d, M137f)

Section titled “Fixed — pen-test arc, five defects that shipped green (M137c1, M137c2, M137d, M137f)”

Four of these predate Tier 4 and had shipped. The transferable lesson, and the reason they lasted: this arc checked its artifacts for shape everywhere and for effect nowhere. Twelve rendered repro files passing tflw check proves they parse. It does not prove any of them reaches the endpoint its finding names. All were found by running a repro or a crawl, not by reading one.

  • A crawl resolved documented paths against the wrong base, and reported green. Every synthesized URL was built as the api base plus the document's path, so an app behind a global prefix was dialled twice — /v1/v1/health. Measured against the live dogfood stack: 81 discovered · 50 withheld · 31 sent · 0 reached, exit 0, passed. 31 requests, none of which touched the application, reported green. This is spec-incorrect and not merely unlucky: under OpenAPI 3.0 an absent servers, [] and [{"url": "/"}] all mean a server of url /, so a document's paths are host-root-relative. A crawl now reads servers[0], keeps the origin the api names and joins the document's declared prefix — the path, never the host.
  • A repro dialled a path that did not exist, and passed because of it. The emitter wrote new URL(finding.url).pathname, which against a base carrying /v1 emits a path tflw then resolves against that base a second time. The repro took a 404, found no leak in the body and passed. Latent since M130b — seven milestones, in the one artifact whose entire purpose is to fail until the bug is fixed, and SARIF's tflw/repro links pointed at those files. Nothing in the suite could have caught it: every fixture server here is http://127.0.0.1:<port> with no path prefix, where the buggy and the correct answer are byte-identical.
  • A repro was green under an env that cannot reproduce its finding. A base-relative path with no way to pin an env is a silent false green by construction. Every repro now carries # re-run: tflw run --env <env> <file>, and the env is a required field rather than an optional one.
  • tflw discovered tests inside its own output directory. discoverTests walked report/, so emitted repros were collected and run as part of the suite that emitted them. Also latent since M130b; it surfaced only when the number of emitted files doubled.
  • A resource the anonymous principal also receives is public, not leaked. A route that hands the built-in anonymous principal the same collection it hands everyone else has no owner and therefore no boundary to cross, so neither leak rule has anything to say about it. Before this both fired: a crawl of a public API produced 20 critical findings that were all false.
  • A followed redirect now carries the cookies the chain itself set — a login answered with a redirect was throwing away the session it had just been granted.

Fixed — latency carries a float, and rounding happens at render (M160)

Section titled “Fixed — latency carries a float, and rounding happens at render (M160)”
  • Durations are no longer rounded to a whole millisecond at the point of measurement (D807). performance.now() is sub-microsecond; Math.round at the five precision-critical sites (http.ts, httpPinned.ts, mtlsWorker.ts, and both per-iteration load sites) discarded that before the value reached any accumulator, so a request answering in 0.37 ms could only ever be recorded as 0 or 1. Measured against k6 on the parity ladder, tflw reported p95s of 1, 3, 12, 30, 31, 86 ms where k6 reported 0.37, 3.27, 11.74, 28.86, 29.74, 63.66; it could not resolve its own generator's overhead at all. The sixteen wall-clock spans (whole test, whole run, crawl, contract compile, progress ticks) deliberately stay whole-millisecond.
  • Rounding moved to the render boundary and now happens in exactly one place (D809): at or above 10 ms to a whole number — bit-for-bit what was printed before — and below 10 ms to two significant digits, so that ladder now reports 0.37, 3.3, 12, 29, 30, 64. The JSON report carries these rendered values so a consumer diffing two runs never meets 12.000000001 where it expects 12. Histogram bucket keys are the one exception and stay at full precision: rounding them would collapse 901 distinct buckets in the 10–100 ms range onto 91 keys.
  • expect duration is less than N compares the unrounded value (D810). This changes the outcome for durations within half a millisecond of their bound, and it changes it in the direction that removes a surprise: a 0.6 ms response now passes is less than 1, where before it rounded to 1 and failed.
  • The precision contract moved into the two places that state contracts, since durationMs was already typed number and TypeScript cannot notice this change (D808): types.ts's field docs now say "do not assume an integer", and histogram.ts's header was rewritten. That header had claimed its bucketing carried "~0.1% relative error" and that min/max/avg were "never approximated". Both were wrong — the real bound is 0.5%, and the scalars were exact about numbers that had already lost up to 0.5 ms before the histogram saw them. The header now states what the code does, and a test pins the bound.
  • tflw now publishes its own rounding rule in dist/artifact-contract.json (D834), as a durations block naming D809 and the largest relative error it can produce — exactly 1/21, reached where the render lattice widens by ten while the value has not. A consumer that judges whether a latency reading is precise enough to compare had no way to ask: testFlow-tests derived that from a hard-coded "tflw reports whole milliseconds", which this release makes false, and nothing would have gone red. Additive, so the contract version stays 1 and a consumer written against version 1 is unaffected.

Fixed — a wait outlives its credential the way a step already does (M187)

Section titled “Fixed — a wait outlives its credential the way a step already does (M187)”

A wait until api poll that answered 401 sent the same dead credential to the deadline. A session's login runs once per run and is cached; on a 401 an api step re-establishes the test's opted-in sessions and retries (P#99a). The poll loop was the one request site in the runtime that received a 401 and consulted nothing — so a wait late in a long suite met an expired session on its first poll and could only time out, and the line it produced blamed the condition (testFlow M181-02).

  • A poll is a request on the same terms as a step (D961): a 401 poll in a test running as <session> re-establishes the session and the next poll carries the fresh credential. The re-establish is the wait's own evidence, reported ahead of the wait's result (D964), so a wait that recovered never looks like one that silently passed.
  • The bound is per expiry, not per step (D962): one refresh per run of consecutive 401 polls, re-armed by the first poll that is not a 401. A wait over several expiries refreshes at each; a permanently-bad credential refreshes once and times out on its own deadline. Exactly once was the right bound for a site that makes one request; here it would have left a wait longer than two TTLs exactly as dead after its first refresh.
  • The timeout line names the credential (D963): when the last poll was a 401 after a refresh — timed out after 5000ms (7 attempts): last poll 401 after 1 session refresh; …. The wait's one failing exit is still the timeout (D936); a re-login that itself fails is recorded and the wait polls on.
  • The boundary, stated rather than built around (D965): a wait outlives a bearer only through a session. A credential a test captured is a value, re-sent verbatim on every poll, and the runtime does not renew values — it cannot, because nothing told it which login produced the string. Anonymous waits and waits carrying a captured {token} header are byte-identical to before. SPEC §5.5, and the guide's A wait that outlives its credential.

Fixed — a wait that gave up early and blamed the matcher (M182)

Section titled “Fixed — a wait that gave up early and blamed the matcher (M182)”

A single transient response ended a wait until api outright, reporting a subject type. The subject of a poll's condition changes with the response — a 200 body is the array being waited for, a 401 or 503 problem+json body is an object — and has count throws a RuntimeError on the second. Nothing in the poll loop caught it, so the throw escaped the wait entirely. Measured on the dogfood suite against one live stack: a wait died 742 ms short of its 5 s budget with the detail has count expects an array (or string, or body bytes) subject, got object, which names the shape and neither the budget, the attempts, nor the expiry that produced it. Under tflw's own 30 s default the same transient kills a wait with 25 s left.

  • A RuntimeError raised while evaluating a poll's condition is now a poll that did not satisfy, not a failed step (D936). It is recorded as that poll's message and the wait keeps polling to its own deadline, so the failure — if there is one — arrives through the timeout exit that was already there: timed out after 5000ms (7 attempts): followed by the matcher's own text. The complaint is relocated, not swallowed. The cost is stated rather than hidden: an author who writes has count against a body that will never be an array now waits the full budget instead of failing on one poll — D248's trade, taken the way D248 took it.
  • Scope is declared rather than widened (D896): the poll loop only. An expect outside a wait still throws immediately, because there is no second observation there to be patient for.
  • wait until api gained the progress line the browser wait has had since M125c (D937), on the same 3 s threshold and the same guard — it speaks only when at least as much waiting would remain as has already passed, so a wait whose whole budget is 5 s stays silent rather than announcing itself a blink before it fails. Console only, never buffered, and no event in the stream: a progress line is not a result (C4). It names what the locator line names — the target, how long it has waited, why the last poll did not satisfy, and how much longer it will go on.

Fixed — unique is scoped to a run, and the constraint it feeds is not (M181)

Section titled “Fixed — unique is scoped to a run, and the constraint it feeds is not (M181)”

unique restarted at zero on every tflw run, against columns that outlive the run. SPEC §7.2 promised distinctness "across tests/workers within a run" and, two lines below, prescribed the family for anything with a uniqueness constraint — and a uniqueness constraint in a database outlives the run. Measured on the dogfood suite against one live stack, three consecutive runs with no restart between them: 323 pass, then 5 failures, then 8. Twelve of the thirteen failing steps were one column — POST /auth/register, email already registered, on user17@example.test, user33@…, user160@… — because unique email renders user<counter>@example.test from a counter that begins again at 0 each invocation. A scope sentence and a usage prescription that disagree are a defect in the promise, not in the tests that believed it.

Every unique value now carries a run namespace beside the run-wide counter: the counter separates draws inside a run, the namespace separates one run from the next.

  • The namespace comes from the run clock, never the run seed (D929). --seed means replay the RNG, and this family's whole contract is that it never consults the RNG — keying it off the seed would make --seed N re-issue the same emails on every run, which is the measured defect wearing a flag. --now is already the documented way to pin the clock.
  • The promise gains an axis rather than a caveat (D931): random moves with --seed and not with the clock; unique moves with --now and not with the seed. That is now the discriminator between unique like and random like, which share a pattern language and were previously told apart on one axis alone.
  • unique like says it is probabilistic across runs, because it is (D932). Its namespace permutes the pattern's value space rather than offsetting into it — so capacity, bijectivity and the too-narrow-pattern refusal are all untouched — but a permutation of a finite space cannot promise cross-run distinctness. Within a run it is still one bijection and still a guarantee. The other four members guarantee both axes.
  • unique uuid's namespace lands in the half that carries the guarantee (D930), bytes 8-11, beside the counter in bytes 12-15. It had been named the family's escape hatch for cross-run distinctness and was not one: its trailing digits were the counter, identical on the next run, and the only bytes that differed between runs were the shape half its own docstring says carry no part of the guarantee — bytes that are byte-identical run to run under --seed N.

Value shapes changed. unique("Widget") now renders Widget-<token>-<n>, unique email renders user-<token>-<n>@example.test, unique number returns a large integer whose high 30 bits are the namespace, and unique uuid fills bytes 8-11 from it. unique like renders exactly as before — same pattern, same capacity, same digits — it just walks the space in a different order in the next run. Recorded because it is real, not because a migration is needed: tflw is unpublished and has no users outside this repository and its dogfood sibling.

unique number gains a ceiling — 2^23 draws in one run, the bits the namespace leaves in a JavaScript safe integer. It is refused, naming both numbers, rather than wrapped past the point where two counters round onto one value: unique like's rule, for unique like's reason. Only a load run can reach it, and the message names the two members that have no ceiling at all.

Fixed — one string form for a value, and unique across processes (M161)

Section titled “Fixed — one string form for a value, and unique across processes (M161)”

unique was not distinct across forked load workers, which SPEC §7.2 promises in as many words ("distinct across tests/workers within a run"). Under --workers N>1 each generating process built its own counter from 0, so every shard emitted the same sequence and every unique draw collided N ways. Measured at --workers 2: 167 draws, 84 distinct. The counter is now striped by shard index, so shard i of n draws i, i+n, i+2n, … — the same scheme globalIterationIndex already used for the neighbouring axis, and it makes §7.2's sentence true rather than editing it. There were two call sites, not one: the forked children, and the parent process, which is shard 0 and runs its own share in-process rather than forking itself. Fixing only the obvious one left shard 0 overlapping every other shard on odd values.

matches against a date is coherent, and its failure message no longer contradicts itself. matches tested String(actual) — a locale- and timezone-dependent rendering — while the failure message printed the ISO form, so check {past} matches "^[0-9]{4}-…" failed while reporting a "got" value that plainly satisfied the pattern. Both halves now use the one value-to-string form the rest of the runtime already used, newly written down as SPEC §7.5. contains had the same defect on its expected-value side. An object subject under matches now renders as JSON rather than [object Object].

  • hex encode emits lowercase, and hex decode accepts either case — SPEC §7.6 now says so instead of a test asserting a literal value being the only thing that pinned it.
  • unique uuid's random-looking half no longer derives from the same sub-seed space as a test's own random stream, so it can no longer be predicted from an unrelated test's values. Its trailing 8 hex digits — where the distinctness guarantee actually lives — are unchanged.

Seed-replay note. Two of these change values a given --seed reproduces: unique uuid (its shaped first 12 bytes) and matches against a date, plus every unique value under --workers N>1. Nothing else moves — a test's random stream replays exactly as before, deliberately, since the sub-seed domain for that caller was left at its historical value. Recorded because it is real, not because a migration is needed: tflw is unpublished and has no users outside this repository and its dogfood sibling.

Fixed — a workload's reported latency is its body's, not its hooks' (M157)

Section titled “Fixed — a workload's reported latency is its body's, not its hooks' (M157)”
  • before/after hook time no longer counts toward a workload iteration's reported duration (D782), and therefore no longer toward p50/p95/p99, the timeline, or any threshold pNN duration clause. The timing window used to open above the hook loops, so a scenario with a 60 ms setup hook in front of a 25 ms body reported a p95 of 96 ms for a 25 ms system. This is not a new rule but an existing one applied to the metric that escaped it: the same function already counts assertions and per-endpoint metrics off the scenario body alone, on the grounds that a hook's steps are setup rather than the system under load.
  • Contention is deliberately preserved. A hook's requests still reach the server and still compete with the body they surround; what leaves the number is the hook's own round-trip, not its effect. Measured on three 20-iteration arms against a target with a 25 ms body path and 60 ms hook paths: reported p95 was 37 / 96 / 96 ms before (no hooks / after hook / before hook) and 35 / 34 / 27 ms after, while the server's own request counts stayed 20 / 40 / 40.

Breaking — cleanup is removed; teardown is on by default

Section titled “Breaking — cleanup is removed; teardown is on by default”
  • A workload's after hooks now run after every iteration, passing or failing (D781), exactly as before hooks already ran before every one. They used to run only for a test carrying a cleanup line — and even then not on the iterations that failed, because the failure was thrown one line above the hook loop. A run at a 5% error rate cleaned up after 95% of its iterations and leaked on precisely the 5% worth investigating: measured at 5 failing iterations creating 5 resources and closing 0, and now closing 5.
  • cleanup in a .tflw file is answered by name — "cleanup was removed — teardown now runs by default under load", pointing at teardown never — rather than degrading to an unknown-statement error. tflw migrate deliberately declines it: the other three retired spellings are word-for-word renames, and a deletion has no replacement to splice.
  • The behaviour the gate provided is still reachable, as teardown always|on success|never in tflw.config, with --teardown overriding it for one run (D783). on success is the investigation mode — it cleans up the iterations that worked and leaves the failed ones' data on the server — and it reads that iteration's verdict, never the run's thresholds. The key is workload-only (D784): functional tests always run their after hooks, because inter-test isolation is not something a committed config key gets to switch off.
  • Any level but always announces itself on every run, with the count, on the advisory channel that already carries ℹ demo: — never affecting the exit code (D785). A config key is a footgun a flag is not: set to debug one afternoon, committed, and every later run leaks in silence.
  • Skipping teardown skips the hook's own assertions too (D786). tflw cannot tell a DELETE from the expect status equals 204 under it, so the documented meaning is "skip the after hook" and preserved data is the consequence. No diagnostic guesses at intent.
  • D26, which set the old policy, keeps its text and is superseded rather than amended (D787). It closes by rejecting "always running it" — the option this release adopts — and an entry edited into adopting the alternative it names and refuses would erase the record that the option was considered, and why it lost.

Added — timeout api and timeout browser (M155)

Section titled “Added — timeout api and timeout browser (M155)”

One number was bounding two unrelated things. timeout step was the abort deadline for every HTTP request tflw makes and for every browser locator resolution — 8 sites and 15 sites, sharing one key. An api step could always be given its own budget on its own line; a browser step could not, and still cannot (click button "Go" timeout 5s is TF010), so the only way to give a slow-rendering page more patience was to give every request in the suite the same patience.

  • timeout api and timeout browser narrow timeout step, which stays the fallback (D768). An HTTP request uses timeout api if set, otherwise timeout step; a browser step uses timeout browser if set, otherwise timeout step. All five targets remain optional and every default is unchanged, so no config written before this release changes meaning — timeout step 10s alone still sets both, exactly as it did.
  • The two tiers still merge per key, and one case is worth knowing: defaults setting timeout api 10s beside an env setting timeout step 20s resolves to api 10s, browser 20s. The env's broader key does not reset the narrower one it inherited, because same-key-wins is about the same key (D772). SPEC §3.1 says so out loud.
  • timeout wait keeps one number for both transports (D771): a poll budget is the condition's patience rather than the transport's speed, and where the two genuinely differ it is already writable per step (M147d/D640). wait until api's per-poll request budget does follow the split — it reads timeout api, under the same clamp as before.
  • timeout api 0s and timeout browser 0s are refused (TF071), and timeout expect 0s / timeout wait 0s stay legal. The rule moved from the name step to the target's family (D770): a budget is handed to an operation, so zero makes it fail before doing anything, while a poll ceiling is tested after a read, where zero legitimately means "evaluate once, don't poll". Restating it that way is what made the two new targets follow from the existing rule instead of needing a second decision.
  • This also moves the slow/failed boundary in a load run out from under the browser. A request that overruns its budget is aborted, so its iteration fails and never reaches the duration percentiles — which meant threshold p95 duration is less than 800ms read a distribution whose right tail was defined by a key that also governed browser locator resolution. timeout browser is inert under a workload by construction (TF033/D19), so of the two only timeout api ever bounds a load run.

Internally, timeout step no longer survives resolution at all (D769): the resolved shape is { api, browser, expect, wait }, so a runtime site added later cannot inherit the shared budget by accident — it has to name a transport or fail to compile.

Changed — config directive spelling (M147b)

Section titled “Changed — config directive spelling (M147b)”

The rule the language never had, and the last breaking change before 1.0.0: a directive whose value comes from a closed set the language defines is written as a bare keyword; a directive whose value is a boolean or an open string is written as a literal. Three of the four closed-set directives were on the wrong side of it — timeout step 10s and insecure true were already right.

  • evidence "full" → evidence full, and evidence "headers-only" → evidence headers only. Two words rather than a hyphen: tflw identifiers have no -, and every multi-word construct in the language is space-separated (the answer input handling already got in M134a). The AST value, report.html and --evidence headers-only are unchanged — a CLI argument is typed into a shell, not into this lexer.
  • log destination "console" → log destination console, log level "warn" → log level warn. LOG_LEVELS was previously spelled both ways in one language: bare in the statement dialect (log warn "…"), quoted in the config dialect.

Each retired spelling is a TF010 naming the bare form and carrying a tflw migrate payload, so tflw migrate rewrites an existing config in place. These are the first three real deprecations migrate has had; it had been proven against synthetic diagnostics for four arcs.

The enumerated values are now ident tokens in a config buffer, which is what finally makes the editor able to colour them — a reversal M142 recorded as unreachable against the grammar of the day, and it was right then.

  • authzBlindSpot → scanBlindSpot, and declines[].principal → declines[].subject with a new scan field. Breaking for anything reading that field. Taken now rather than after Tier 4 doubles the number of producers: the field is named for one tier and carries two, and a name that lies about its contents is how a report comes to describe the same state in two vocabularies. coverage is unchanged and stays authorization-only — it counts api steps in a test declaring an owner, which is a fact about the suite that no other tier has an equivalent of.

The shipped grammar is frozen additive-only from 1.0.0 on, so every incompatible change had to land before it. Each of these is a hard parse/check error naming its replacement, not a silent behavior change:

  • check <locator> / uncheck <locator> (the browser checkbox actions) → tick / untick, freeing check to mean only "soft assertion". Bare check <locator> is now TF014 and uncheck is TF011.
  • think <duration> → pause <duration>, avoiding a collision with the existing wait until construct; wait until … for <duration> was added alongside it.
  • The test-header modifiers became order-independent, ReportDecl.dir keeps its string literal, and three further grammar-surface fixes landed in the same sweep.

A systematic pre-1.0.0 review of the whole surface. Highlights:

  • Three lexer defects, and the checker's pass list unified so a file is checked once by one ordered pipeline rather than by several ad-hoc traversals.
  • Redaction reached every sink: the ndjson stream had bypassed the final redaction pass, and file-based log sinks were not redacted at all.
  • junit.xml gained <testsuites>, per-file <testsuite> elements and classname attributes, so CI servers attribute a failure to the right file.
  • An empty --tag/--only value ran the entire suite instead of erroring.
  • check --format json attributes every diagnostic to its own file instead of flattening several files into one array with colliding spans.
  • exclude stopped silently ignoring file paths, and --verbose/--bail's per-file behavior is now documented against --parallel, the flag that actually controls it.
  • The ndjson event stream became a documented contract (every test:start has a matching test:end, on every path), with a regression test per guarantee.
  • Documentation gained a mechanical guard: every fenced block in the docs site is classified, and every tflw sample is executed through the shipped dist/cli.cjs rather than trusted.

Fixed — launch review, checker & lexer contracts (M89–M98, M97e, M100)

Section titled “Fixed — launch review, checker & lexer contracts (M89–M98, M97e, M100)”
  • body pdf text no longer fails on roughly 1 PDF in 300. A content stream's extent now comes from its dict's /Length (including the indirect /Length N 0 R spelling) instead of from a scan of its own binary bytes; the old code dropped a byte whenever the compressed data happened to end in CR, and expect body pdf text … failed with could not decompress content stream (M100).

  • The checker's relationship to the runtime became a stated contract rather than an aspiration: every rule the runtime enforces is either decided statically first or carries a written reason it cannot be, checked by a source scan that fails when the two lists drift.

  • tflw check now reports a path literal that names a file which is not there (TF043) — covering import, use, with each from, body from, upload, matches file and drop file. Before this, a suite with a mistyped import checked clean and then printed ✗ t.tflw (crashed) (0 ms) as its entire run output, --verbose included.

  • TF043 reports at two severities. import/use are an error — tflw check opens those files itself. The five a step opens are a warning, because a file that is not there at check time may be created by an earlier step, a hook or a fixture build before the step that reads it runs. Reported at error severity for one release, this made a valid suite unrunnable with no override; tflw check's summary line counts warnings instead of claiming no problems found.

  • Lexer diagnostics report the fact they already had: an unclosed bracket points at the bracket rather than at a synthesized dedent past end-of-file, an empty @ tag is an error instead of an unusable tag name, an unsupported string escape is refused rather than silently dropped, and tab indentation and invisible/confusable characters are named for what they are.

  • Diagnostic carets are placed in terminal cells rather than UTF-16 code units, so a line containing wide or combining characters underlines the span the reader can see.

  • An open (rps) workload no longer reports the inter-arrival gap as request latency. Both load models now send over the same node:http keep-alive client; the open model previously used fetch, and on Node 26 a fetch issued from a timer callback that its own loop does not await — exactly how an open arrival is dispatched — has its completion deferred to roughly the next timer tick. The reported duration therefore tracked the arrival gap rather than the service time: on one 0.2 ms endpoint, in one process, hold 10 rps reported p50 36 ms while hold 1 users reported 0 ms. The error was largest precisely where it misleads most — a fast target at a low rate, i.e. a healthy service. Every arrival in a scenario shares one connection pool, and that pool is deliberately unbounded: a bounded one would queue arrivals inside the generator, where the wait would be counted as service time.

    Node 22 and 24 are unaffected, as is every non-workload tflw run and the closed (users) model on all versions. No previously published measurement changes — every performance corpus tflw ships uses the closed model. Full chain, version bisect and a standalone reproduction: tflw-acceptance/perf/profile/FINDINGS_M121_OPEN_MODEL_FETCH.md in the dogfood repo.

Fixed — locator suggestions on assertions, not just actions (M119)

Section titled “Fixed — locator suggestions on assertions, not just actions (M119)”
  • A misspelled locator now gets the same "nearest matches on the page" suggestions in expect/check/wait until that it already got in click/fill. click button "Add to Crat" named the real button "Add to Cart"; expect button "Add to Crat" is visible said only "but got no matching element" and stopped — the same typo answered two ways depending on the verb, in the half of a suite where most failures actually happen. The suggestions are appended only on a step's final failure and only when nothing matched at all: with an element resolved the failure is about its state, not its name, so a list of similar names would point away from the cause. Absence that the matcher is happy with — is hidden, has count 0 — still passes, and a passing step is never annotated.
  • The generator's saturation verdict is tested at its real thresholds instead of raced for. The self-diagnosis test that proves a blocked event loop reports itself as saturated used to also assert the process had held more than 50% of a core — a number that measures the machine's scheduler rather than tflw, and that a busy loop cannot guarantee on a contended box. It went red twice on that assertion alone while the behaviour it was named for passed both times. The verdict is now a pure isSaturated, with the lag arm, the CPU arm (at its actual 90% threshold, which no test had ever covered) and the short-window floor each pinned deterministically.
  • A failing text locator no longer suggests the document head. Because the text scan looks at every element and only leaves carry a name, every container fell into the "no usable name" arm and was offered as a ready-to-paste css path — css "html", css "html > head", css "html > body" — ranked by position in the document rather than by any relationship to what was typed, and one of them can never be visible. An element with no text is not a misspelling of a text locator, so when nothing on the page is similar enough the message is now left unchanged. Icon-only buttons and unlabelled fields keep their generated css suggestion, which is the case that arm was written for.
  • tflw init then tflw run is green in an empty directory. The scaffolded config points api at tflw://demo, tflw's own bundled demo service — a real HTTP server started for the run on an ephemeral loopback port and stopped on every exit path, answering GET /health and nothing else. The README has annotated that second command "green in seconds" since tflw init existed, and it was FAIL 0/1 passed: the scaffold pointed at http://localhost:3001, where nothing listens in a fresh directory. Swapping that one line for your own service is the intended first edit, and the scaffold says so. A demo run is labelled in the CLI summary and in report.html — a green run that proves nothing about your system must not look like one that does. tflw check never starts it.
  • tflw install-browsers says what happened. On success it names the engine and the playwright version that now has the binary — previously it printed nothing at all, on either stream, which is indistinguishable from a no-op or a silent hang. On failure Playwright's own output still passes through, now with a tflw summary naming the three things that actually cause it (a proxy, no route, no disk), and exit 2 rather than the exit code that means "a test failed".

tflw has no reserved words, so a bare variable followed by a keyword was indistinguishable from a multi-word call name. The parser always guessed "call", and reported the variable as a missing paren.

  • A bare name followed by a keyword is now a variable. let x = random number lo to hi, random date between a and b, format d as "yyyy-MM-dd" and select size from field "Size" all parse; a word run that really was reaching for a call still says so. Braced {x} was always accepted in these positions and is unchanged. SPEC §7.1.1 documents the bare form for the first time — it has existed since 0.1 and was never written down.
  • random password's optional length must be a number or a {var}. It is the only value in the grammar that is both optional and unmarked, so a bare word there was taken as the length: select random password from field "pw" consumed from. random password n is now an error naming the three working spellings. No corpus program used it.
  • A duration unit must touch its number in every position. pause 250 ms was accepted while expect duration is less than 250 ms was already an error; the check now lives in the shared duration parser, so both positions and both dialects agree. No corpus program used the spaced form.
  • report/events.ndjson is written one line at a time. A whole-corpus run at evidence full produced a 612 MB stream, and building it as one string hit V8's limit: Invalid string length, exit 2 after every test had passed, and the tail of the live stream gone. The sink now streams the redacted events through a write stream with backpressure; the exit is the suite's verdict whatever the stream's size.
  • A file hook's test:start/test:end pair says it is a hook. Both halves carry hook: "before file" or hook: "after file" (SPEC §13). A consumer showing work in flight counts by the field: a passing hook is work and never a test, and a test named before file is a test. tflw ui's live counter had read 106 of 105 done on a corpus whose first file has a before file.
  • A run owns report/ whole. findings.sarif, events.ndjson, assets/ and the repro directories are written only when a run has something for them; a run that does not now removes the previous run's, so every member present after a run is that run's — a CI step uploading findings.sarif can only upload this run's findings. report/runs/ is untouched.

Fixed — a reuse hint is an action that runs (M196)

Section titled “Fixed — a reuse hint is an action that runs (M196)”
  • A reuse window is a response frame, at both ends. An action body is its own frame at run time — a response never crosses a call in either direction — and the reuse pass had windowed step sequences without asking. A window that read a response before any api inside it was offered as a hint and refused by tflw refactor apply with the checker's own TF039: twelve of the twenty hints over the dogfood suite, applied by nobody until a sweep tried (M195-01). A window that established a response the caller read after it was worse: every hint applied, tflw check clean, and ten tests red — the caller's capture reading the response it had before the call (M196-02). Both windows are now dropped by the checker's own walk, exported for the purpose; a shorter window that is a frame is its own candidate. Over the dogfood suite: twenty hints offered and twelve refused became ten offered, ten applied to a fixpoint, and the fifteen files they touch green through the extracted actions.

Added — a config URL from the environment, a document source by service (M197)

Section titled “Added — a config URL from the environment, a document source by service (M197)”
  • api [<service>] env NAME default "<url>", web env NAME default "<url>", authorized target env NAME default "<url>" reason "…". The URL comes from the environment variable when it is set and non-empty, from the literal otherwise — one suite against many deployments of the same service, or four copies of one stack on one machine with the ports offset. Not env(): that form is a secret and a base URL read through it would be masked in every request line of the report; this one is plain and appears verbatim in evidence. An override that is not an absolute URL is a startup error naming the variable.
  • matches schema "X" from <service> "path", seed openapi <service> "path", seed spider <service> "path". A relative document source resolves against the named api service's base, the shape api <service> GET /path has; an unknown name is the step's own unknown-service diagnostic. Until now a non-default service's document needed an absolute URL, which is how a suite that declared api root came to write its host's port into test files.
  • A relative token url resolves against the default api base, like a step path.

Added — the page writes the language (M200, M201)

Section titled “Added — the page writes the language (M200, M201)”
  • A printer for the whole language. Every construct the parser accepts can now be rendered back to source: 116 of 116 kinds, and a whole-file round trip of 260 of 260 files in the corpus. This is what makes an authoring surface possible at all — a form that cannot be turned back into .tflw is a form that owns your test.
  • Builders for every construct a door writes, each landing with the vocabulary gate that says which words the form can produce and which the printer refuses. A construct the builder can say and the printer cannot is a defect the round trip catches rather than a surprise at write time.
  • The corpus the printer is graded on is authored here, not harvested from the test suite. A harvested corpus measures what someone happened to write; an authored one measures the language, and the coverage floor is an equality so that reading more than claimed fails too (M201).

Added — tflw ui becomes a workspace (M203, M205–M209, M213)

Section titled “Added — tflw ui becomes a workspace (M203, M205–M209, M213)”
  • A file explorer: every .tflw file as a tree, with the tests each holds, a count behind the current door, and a search box that takes either a name or an @tag and says which it read.
  • A tab strip over one file — Source, Compose, Run, Auth, Config — where a tab is a stage of that file's life or a project fact it resolves against. The door, the file and the tab all live in the address, so a link is shareable and a reload changes nothing.
  • A run strip: environment, worker count and the run button, with the run streaming into the page and writing a real run directory. tflw ui also opens an empty directory and offers to scaffold a project, with the forms opening blank rather than guessing at your service.
  • Four themes, differing in type, density and shape as much as in colour, remembered in the browser and never written to the project.

Added — Compose, the authoring pane (M210, M212, M214–M218)

Section titled “Added — Compose, the authoring pane (M210, M212, M214–M218)”
  • The pane draws what the file says. One region edits whichever row is selected — a request, a statement, the test's own facts, or the file's — which removed 47 controls down to 13 and retired the fixed-height name box that truncated a 40-character sentence.
  • The whole assertion vocabulary is editable, not a subset, with the body read as JSON and a live diagnostic where it is not. Statements between requests — let, capture, wait until and the notes above them — are rows in the same sequence, in file order, because the interleaving is what makes the next request work.
  • Send runs the prefix. Most requests read a binding an earlier step made, so sending one alone fails for reasons that are not about it: Send issues the request and the ones that feed it, lists what it will fire before firing, and grades nothing.
  • Undo, and a say in where things land. Every destructive gesture is reversible, a new request goes where you were rather than at the end, and the explorer's right-click menus carry the door's first destructive routes behind a confirmation.

Added — the pane reaches all four doors (M219, M224, M228, M232)

Section titled “Added — the pane reaches all four doors (M219, M224, M228, M232)”
  • BROWSER, LOAD and SCANS each stopped being a form and gained the same three-region pane the API door has, with their own vocabularies: locators and the whole interaction set; the workload shapes, their units and thresholds as a grid; the scan families and the permission they require.
  • A door decides where you land and what a new test scaffolds — nothing else. A test carrying a workload shows its workload panel behind every door, because a panel is earned by the construct and not granted by the door (M232).
  • The catalogue of steps a door offers is derived from the door's own construct set, so a construct the language gains is offered without a second list being edited.

Added — recording and playback (M220–M222, M231)

Section titled “Added — recording and playback (M220–M222, M231)”
  • tflw record drives a real browser and writes the .tflw your clicks made. It records what actually happened: a click that opens a tab is written as the switch to new tab block that wraps it, a click on an ambiguous name is scoped with the within that disambiguates it, and a synthetic click a script dispatched is not recorded at all, because nobody clicked anything.
  • Recording writes steps to stdout and everything else to stderr, so tflw record > flow.tflw yields a file that parses (M231).
  • Playback runs a test in the page and draws it in place — the stage sits under the columns rather than replacing them, so the source that produced a step stays on screen beside it.

Added — security triage in the page (M208, M211)

Section titled “Added — security triage in the page (M208, M211)”
  • The accepted-findings baseline became a project fact rather than a command-line flag, so the page and a CI run grade against the same document.
  • The findings list is one row per distinct finding, with the baseline's verdict beside it and an [accept] that links a finding to the editor without writing anything itself.
  • Config became multi-document and addressable per environment, so a project with four environments is read rather than reconstructed.

Fixed — the language, and what building the page found (M199, M229, M230)

Section titled “Fixed — the language, and what building the page found (M199, M229, M230)”
  • expect page has no a11y violations threw against any page setting a strict Content-Security-Policy — and the security scanner ships sec/csp-missing as a serious finding, so the documentation asked a reader to set the header that broke the construct. The injection now goes through a route the policy does not govern (M230).
  • A body path segment that is not a bare identifier had no spelling at all. body.user-name and keys with dots, spaces or digits-first are now addressable with a quoted segment (M230).
  • The identifier arity nobody chose. A pattern bounded at three digits silently stopped matching the sequence once it passed 999, and every test that could have caught it spelled its fixtures with the same bound — so the gate and the defect agreed (M199).
  • Sixteen findings from a review of the page itself, including a contrast floor re-solved across all four themes and a drag that died at a same-origin iframe (M229).

Changed — the page's first five minutes (M240)

Section titled “Changed — the page's first five minutes (M240)”
  • A door opens on the file with its work in it, not the file that sorts first, and on that file's first test rather than a hook above it; after that it remembers where you were, per door and per project, in your browser. A door with nothing behind it says so and offers a new file (D1309).
  • A directory with no tflw.config is a project that has not started, not an error: the page names the directory and the tflw it is, and every route answers — reads with their empty shapes, writes with a sentence saying to init first (D1310).
  • The keyboard reaches the page. The file list, the door bar and the tab strip are one Tab stop each with the arrows inside them; ⌘S, ⌘↩, ⌘⇧↩, ⌘P, / and ? do what the legend says, and the legend also says what each panel is for (D1311). axe finds nothing on any view in a light and a dark theme.
  • Fewer words, and none of them ours. Every view holds a word budget, a tip is one sentence, and no string a reader sees — an error, a report line, the page, tflw --help — points at a SPEC section or a decision id any more; where the long form is needed it is a docs-site link (D1312, D1313). The page says here and at another door, and defines door once (D1314).
  • Paper is what a first visit sees; Terminal and the other two are one click away, and native controls wear the theme's face (D1315).
  • Twelve smaller repairs from the same review: an empty field in the new-step dialog is refused rather than written, run times carry their zone, failures stack as notices, a dirty draft asks before the tab closes, a run started elsewhere is followed, and the corner says which tflw this is and links the docs.
Show the 2 entries

First public draft. API-only — the browser half lands in 0.2.0.

  • .tflw DSL: a tflw.config dialect (env/defaults/session/require env), test/action/ before/after blocks, import/use for shared actions and a JS/TS escape hatch.
  • api steps for GET/POST/PUT/DELETE/PATCH; all four request-body forms (body { … }, body from "file", form k=v, upload) plus raw body text; named services; per-step timeout; without redirects.
  • A closed assertion grammar: expect (hard) and check (soft) over status/header/body.<path>/ body text/duration, with any/all quantifiers and a not negation.
  • capture + chaining (create → use → verify across steps), let, and value expressions (arithmetic, string interpolation, today/now date math).
  • unique(...)/unique email/unique like "…"/unique uuid (collision-safe) and random number/ random date/random of/random like "…"/random uuid/random password [N] (value-shaped) generators; a run seed with --seed/--now replay. base64/hex/url encode(...)/ decode(...) value transforms.
  • session <name> blocks: run once per run, auto-apply captured headers to every test running as <name> — no repeated login boilerplate. A session now auto-refreshes on a 401 (bounded to one retry) and honors its own TTL if it has one; session <name> oauth2 sugar runs a client-credentials grant and sets that TTL from expires_in. Per-env cert/key config keys add mTLS client-certificate support.
  • Orchestration: @tags + --tag a,b,c (comma-separated OR — a test runs if it carries any listed tag; combines with --only as AND), retry N with flaky marking, inline (with each) and file-backed (.csv/.json) data tables, --workers N (in-process, per-file, default 1, deterministic under --seed at any concurrency).
  • Teaching-quality diagnostics: source line + caret + "did you mean", stable TF0xx codes, a conservative unknown-variable/unknown-service checker pass.
  • Reporting: a self-contained, theme-aware report.html (step timeline, full request/response detail, screenshots-ready layout for 0.2.0) plus junit.xml for CI; secrets (env(NAME)) are redacted from every report, trace, and CLI line automatically.
  • Safety/redaction: allow hosts "…" config allowlist — a request to a host outside the list is refused before any network I/O; --forbid-insecure fails a run up front if insecure true is active; evidence full|headers only|none config key + --evidence CLI override control how much of the request/response trace lands in the report (never affects what expect/capture can see); redact body.email, body.*.address masks matching JSON fields with [redacted] in the report, a declarative mechanism distinct from the existing env(...) secret redaction — now also masking a plain body.<path> capture/expect/check step's own detail text when its subject is redact-covered (closes TFLW-GAPS.md gap #15), not just the request/response trace; quantified any/all assertions are a documented exception, left unmasked.
  • Contract validation: expect body matches schema "Name" from "source" runs real ajv JSON-Schema validation against a schema in an API's own generated OpenAPI document (components.schemas), including cross-$ref resolution — the assertion itself fetches and caches the document.
  • retry honoring "Retry-After" up to N — a per-step api clause that re-issues just that one request when its response carries a Retry-After header (seconds or HTTP-date), sleeping the indicated duration before each re-attempt; distinct from retry N, which retries a whole test.
  • tflw run and tflw init (scaffolds tflw.config + example.tflw); tflw --version/-v.
  • Packaged as a single self-contained dist/cli.cjs (esbuild bundle). Bundles two real runtime dependencies, undici (mTLS client-cert request path) and ajv (contract/schema validation) — both build-time only, never installed by a consumer; every other request still uses Node's plain global fetch.
  • Documentation site (VitePress, packages/docs-site, deployed to GitHub Pages): a hand-adapted Guide, a generated Reference (matchers/generators/CLI flags, from a new canonical packages/lang/src/spec-data.ts manifest that also regenerates SPEC.md's own matcher/generator tables), the Grammar reference, an Editor page (install, plus live in-page demos of diagnostics, hover, autocomplete, go-to-definition, rename, signature help, and semantic highlighting — each one runs the real @tflw/lang/@tflw/lsp-server classifier and resolver code client-side against an editable or fixed sample, not a screenshot or recording), and an in-browser parse+check playground. GRAMMAR.md was refreshed to cover the full grammar through this release (it had been a frozen M0-only snapshot).
  • tflw lsp — a real Language Server Protocol implementation (packages/lsp-server), replacing the VS Code extension's old child-process tflw check --format json diagnostics. Diagnostics, hover, go-to-definition, autocomplete, rename, and signature help, all live over debounced in-process reparsing, for both .tflw test files and tflw.config; a tflw.env VS Code setting controls which environment diagnostics resolve services/sessions against. tflw lsp itself speaks LSP over stdio, so any LSP-capable editor (not just VS Code) can use it. It also serves textDocument/semanticTokens/full, coloring matcher/operator words, numbers, variable/parameter names, and object-literal field keys using the editor's own built-in default semantic palette — richer and theme-independent, closing a gap the static TextMate grammar structurally can't (it has no way to color arbitrary user-chosen names, and some of its scopes go unstyled under themes that don't define rules for them).
  • expect/check request connects/fails — asserts on the connection attempt itself rather than a response: a request that fails before any HTTP response exists (a TLS handshake rejection, DNS failure, ECONNREFUSED, an allow hosts block) can now be the expected, passing outcome of a test instead of always crashing the run. fails matching "<regex>" asserts on the failure reason too, reusing the same teaching-error text already unwrapped for connection failures. Opt-in per request (only an api step immediately followed by a request assertion catches the error; every other request keeps today's unconditional fail-fast); a request assertion can't be combined with a response-based one on the same request, and isn't supported inside wait until api (both checker errors, TF031).
  • CI ergonomics + console/log output: report/results.json (always written, the same redacted run report as JSON, no flag) and report/.last-run.json (always overwritten, feeds --failed). tflw run --failed re-runs only the previous run's failing tests; --bail stops after the first failing test (in-flight files still finish under --workers > 1). --format ndjson streams the event log as one JSON line per event (always file-tagged, full detail regardless of --verbose) instead of human text, also written to report/events.ndjson. Console output now gets an HH:MM:SS.mmm timestamp prefix by default (--no-timestamps opts out); on GitHub Actions, --verbose output folds into a collapsible ::group::/::endgroup:: block per test (auto-detected, no flag). --log-file <path> duplicates console output to a file, always plain text regardless of stdout's own color state.
  • tflw.config's require env list would hang the parser (and, on a second attempt, crash Node with an out-of-heap error) if a trailing comma preceded the newline — a malformed multi-line continuation left the parser's config recovery loop stuck reprocessing the same token forever. Now reports bounded diagnostics and recovers normally; require env itself still needs to stay on one line (no continuation support).