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.
Changelog
Section titled “Changelog”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.
[Unreleased]
Section titled “[Unreleased]”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 checkprints 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 checkjudges 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 acapture … 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:urlare 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 runmakes before its first request, through the same code: atflw checkerror under the env — aTF060scan target the env does not authorize, a service it does not declare, a file that does not parse — fails doctor as it stopsrun. The problem line quotes the first error and thetflw checkcommand that lists the rest. A project with no test files fails it too. - The browser check names the engine
runwill launch: Chromium, or--browser's. With only Firefox downloaded, a plainrunlaunches Chromium, and doctor now says so. - Doctor takes
run's flags that can stop a run: file arguments,--browser,--allow-public-target,--forbid-insecureand--no-helpers, each withrun's meaning. - Warnings, marked ⚠, never change the exit code: a
.envgit does not ignore, the warningstflw checkreports, andinsecure true. tflw doctor --all-envsprints one verdict per env and exits 1 if any env cannot run.--jsongainschecksandwarnings, andbrowsers.engine;suite.unparsedis gone, since a file that does not parse is now acheckserror.
Added — an env block may require its own secrets (M266)
Section titled “Added — an env block may require its own secrets (M266)”require env NAMEinside anenvblock 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 indefaults(TF025).TF077asks whether a name is declared for where it is read. Anenvblock reads the top level and its own lines; a session or signer scopedfor envreads 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: anenvblock 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)— andtflw 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 doctorlists the env's required secrets by name and whether each is set, reads the project's.envasrundoes, and exits 1 when one is not set.
Removed — report/.last-run.json (M265)
Section titled “Removed — report/.last-run.json (M265)”- A run no longer writes
report/.last-run.json.tflw run --failedhas read the kept runs underreport/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 fromtflw 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 docsand 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 docsprints 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 cliprints the CLI reference with every flag table filled in from the same manifest the site reads;tflw docs cli runprints one section; a word that is not a topic is looked for among every page's section titles, sotflw docs uniqueprintsuniquevs.random. Links to another printed page read(tflw docs <topic>), others as a URL.- The
.tflwrepro 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-sidebarsfails 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/watchwith no display,pick /pathwith noweb, and the404fromnpm installbefore 1.0. tflw docsno longer printsDistribution (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 (topicdistribution), and a test holds every title and group the real SPEC produces to carry noP#, decision id or status marker.- Fixed: chapter 8 and The editor packaged the extension to
packages/vscode/tflw.vsixand then installedtflw.vsixfrom the clone's root, which does not exist; the.vsixis now written where the install reads it. Found by a second walk-through that copied every command from the published pages.
Added — chapter 7, the page (M261)
Section titled “Added — chapter 7, the page (M261)”- 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 atflw runthat 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:runbooksends a page a background step printedGET /api/projectwith its token, and fails when nothing answers.verify-sidebarschecks every/ui/page is linked from chapter 7 orrunbook/page.md, and that chapters are numbered 1–N.
Earlier changes
Section titled “Earlier changes”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 byverify:runbookand every output block is copied from a run.runbook/index.mdis the contents page. tflw pickandtflw recordtake a path (/order) and open it againsttflw.config'sweb.- A load run's first line names the
workload, not the old keywordscenario;tflw check's hint header no longer prints a plan id;--tracehas its row in the flag reference, and a test holds every flagrunparses 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 --examplewrites the Coffee Shelf, a small shop and its suite, into the current directory, refusing to overwrite unless--force, and adds ashopscript topackage.json. The shop and its config both readSHOP_URL(D1417).verify:runbookruns everysh runbookfence 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 coverageonly once the config declares a session (D1421). - Fixed:
npm run shopexited 0 without starting on Windows and through a symlink (M259-06); a docs page's roadmap declaration did not match on Windows (M259-07).
Changed — two themes and compact (M258)
Section titled “Changed — two themes and compact (M258)”- 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 andrunbook/page.mdwas 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,INCONCLUSIVEorCANCELLED, 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. securityandhistoryare rows of the tree (D1409). The findings block opens from asecurity · 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 readsrunning · 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, awith eachtable, 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. Belowevidence fullit 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 thecaptureunder its request and opens it. + recordlands 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 --failedreplays the tests whose newest verdict in the kept runs is a failure (D1414) — pytest's--lfrule — 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-keeprun does not move it. Nothing failing still falls back to the full suite with a note..last-run.jsonis 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
failedchip beside the kinds narrows the list to the failing tests, opens each file onto them, and makes ▶tflw run --failed— in the address asfailed=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 fileand+ new testask which kind to scaffold, and+ new crawlshows 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 holdingworkersandheadedtoo), 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✕orEsc, 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 beforetflw initstays, 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.
Added — tflw run --kind (M254)
Section titled “Added — tflw run --kind (M254)”tflw run --kind api,browserruns 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,--onlyand--failed; an unknown kind, an empty list, or a kind no test is of exits2.
Fixed — the .vsix release workflow parses
Section titled “Fixed — the .vsix release workflow parses”release-vsix.ymldid not parse (M251D): 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, andverify-contributing.test.mjshands 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.
Removed — the maintainer's runbooks
Section titled “Removed — the maintainer's runbooks”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'sCONTRIBUTING.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's401,TF083, Windows — a Glossary of the page's and report's own words, and an upgrade section withtflw migrate. The sidebar gate asks about every page inrunbook/. The sixteen annotated*-explained.tflwexamples are linked from the first guide page. - The maintainer's runbooks.
RUNBOOK.mdin 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 fmtas out of v1. A gate now holds every 🔮/🔧 heading to an owner that resolves inDECISIONS.md. - Publishable (
D1351). Every sentence the site declares true only before 1.0 sits behind aPUBLISHEDflag beside the text 1.0 day needs, held by a test; publish day flips one constant.privatestaystrue(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.tflwfile and its tests; a file or a test runs astflw runwith--format ndjson, each verdict lands beside the test and a failure at the step that failed, and the run'sreport.htmlis 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 lspanswersdocumentSymbol(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) andfoldingRange(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'splanReuseApply— the plannertflw refactor applynow 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 meanx? offers the one-word fix. - Distribution, prepared (
D1389). CI packages the.vsixon every PR as an artifact;release-vsix.ymlattaches 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 webkiton its Linux jobs and checks the engine is recorded inresults.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.
Added — CI on macOS (M252)
Section titled “Added — CI on macOS (M252)”- A macOS job (
D1377): build, typecheck and the whole test suite onmacos-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, andtflw spec --json | …arrived cut at 64 KiB; Linux writes pipes synchronously, so no Linux run could show it. - A symlinked
TFLW_SOURCE_ROOTanchors 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. Everyuse "./helpers/…"in such a file was refused withTF083as loading from outsidehelpers.
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. elementdeclarations on the page: the file row lists the file'selementlines, each a name and a locator you can edit;+ elementadds 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
rowsblock on the page: the band lists each judgement under awith eachtest, 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 therowsline 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 runwritesreport/.running.jsonand a livereport/.running.ndjsonwhile it runs and removes both once the run is kept;tflw uidraws 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 refactorthat 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>ontflw recordandtflw pick(D1385): the headed browser's DevTools endpoint on127.0.0.1:<n>, announced on stderr, so a script can connect with Playwright'sconnectOverCDPand 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 runcopies what it wrote intoreport/runs/<id>/, the newestruns 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-keepfor a scratch run. Each test inresults.jsoncarries its file'ssourceHash. - 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,mergedFromnaming 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.--jsonfor scripts. It exits 1 only for no config, Node below 22, or browser tests with no browser.tflw <verb> --helpprints that verb's flags;tflw --helplists the verbs, one line each.- Schemas for
results.jsonandevents.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 holdsauthorize url,token url,client id, an optionalclient secretandscope, a loopbackredirect(port0for 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, checksstate, and exchanges the code with the verifier. Testsasit carry the bearer.expires_insets the TTL and arefresh_tokenis 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 missingredirect, or one that is not a loopbackhttpURL.- 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, amendingD10).test "…" as shopperstarts its browser context with the session's cookies —HttpOnly,SecureandSameSitekept — so the firstopenis 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 (localhostand127.0.0.1stay two sites), and a login made through the page still does not flow back to theapisteps. The page's Auth panel says the new rule. skip "reason" on env ci, staging(D1353): a skip that holds only in the namedenvblocks. Reported with its env (… (on env ci)),skippedOninresults.json,N skipped (M by env)on the summary; an undeclared env isTF088. 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 isTF089, a repeated oneTF035.tflw checkoffers a reuse hint for acss/xpathstring written in two or more files, andtflw refactor applywrites theelementline intoshared/elements.tflwand 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 markedconcurrentlyisTF090.- 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.
Changed — M247
Section titled “Changed — M247”--tag's exclusion form (--tag !slow) was already shipped (M242); a planned--skip-tagflag 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
elementname.expect total equals 3meaning a bound value isTF089at 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
crawlcan carryexpectover 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-writecreates 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 checkoffered to lift a step window naming an importedelementintoshared/…, andrefactor applyrefused its own proposal withTF089. 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'simports 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).togetheris a new step: the rows of awith each concurrentlytest 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 isTF092. - What
before filemakes, the whole file reads (G3,D1382). Aletorcaptureinbefore fileis now readable, read-only, by every test, row, each-scope hook andafter file. So a race and the test that judges it can name the same coupon. Binding the name again isTF091. Breaking, deliberately: a file that relied on the old isolation to reuse a name now getsTF091and must rename one of them. - A race whose outcome is only in the responses can be judged (
G10,D1384). Arowsblock under awith eachtest 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 isTF095; a page subject isTF096. any/allquantify into a nested array (G8,D1383, the sibling'sTFLW-GAPS.mdrow 11).expect any body.orders.items.quantity equals 3over orders that each carryitems[]read.items.quantityoff the second array asundefinedand 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.
Added — signed requests (M246)
Section titled “Added — signed requests (M246)”signerintflw.config(D1344–D1349): an HMAC template —signs "{timestamp}.{body}", aheaderline carrying{signature}/{timestamp}— or AWS SigV4, written onnode:cryptowith no dependency. A step opts in withsign with <name>, a session withsigned 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 theformfields (D1343). A one-file upload prints byte-identical. An unreadable file fails the step namingfile 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.filesays 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).
Added — the platform (M243)
Section titled “Added — the platform (M243)”- 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.
Added — the language (M242)
Section titled “Added — the language (M242)”- 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 !xexcludes.- GraphQL (
D1328):body graphql "…" [variables {…}] [operation "…"]. - Strings (
D1329):length of x,x joined with ", ",capture … matching "re" as n. --shard i/n(D1330) andtflw 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 uiserves on loopback behind a per-start token in its URL (D1316–D1318).helpers "./dir"intflw.confignames where ausemay load from;TF083elsewhere;tflw run --no-helpers;tflw checklists 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'sbaselineblock — advisory, never the exit code. A narrowed--baseline-writesays 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. dragholds 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.
Added — enterprise arc (M9–M28)
Section titled “Added — enterprise arc (M9–M28)”- Session hardening: refresh-on-
401, per-session TTL,session <name> oauth2client-credentials sugar, and per-envcert/keymTLS client certificates (P#99–P#100). - Safety and evidence controls: the
allow hostsallowlist,--forbid-insecureas a CI policy gate,evidence full|headers only|none, andredact(P#101). matches schemacontract validation against a JSON Schema or OpenAPI document (cached per run), andRetry-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/fieldcascade,css/xpathescapes,withinscoping, strict-ambiguity errors), auto-retrying UI expects, and dialog handling (accept/dismiss). - Frames (
within frame), tabs, downloads, drag-drop, andwait until <ui-locator>. - The
report/directory: failure-first screenshots + Playwright trace,--browser/--headed/ viewport flags. - Network observation (
expect request to "<url>" was made) andstubroute 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) andtflw pick(opens a real browser, prints a verified locator per click).
Added — user-defined logging
Section titled “Added — user-defined logging”- The
logstatement: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.configkeys (log destination,log level, override semantics likeevidence) plus--log-output/--log-levelCLI overrides for a single run — an explicit per-statementto …clause always wins over both. Every log step is always recorded inresults.json/--format ndjsonregardless of level/destination; only console text andreport.htmlfilter what renders. - Full LSP/VS Code support: hover, completion, and semantic highlighting for
log.
Added — reuse pass & acceptance
Section titled “Added — reuse pass & acceptance”tflw checknow surfaces advisory duplication hints (RF0xx) andtflw refactor apply <id>mechanically extracts a flagged window into a sharedaction, 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 reportsno deprecated syntax foundand 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
testbecomes workload-bearing the moment it contains a workload line; the body is ordinary steps, so anactionwritten for the functional suite is reusable unchanged. - Five workload shapes:
ramp to N users over D,hold N users for D,step …,spike …, andrun N iterations across M users(D97/D98/D102). thresholdassertions (p50/p90/p95/p99duration, and error rate; optionally scoped to one step viafor "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 Nforks 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
3for 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)”scenarioblocks were removed and collapsed intotest, with the kind inferred from the presence of a workload clause (D93–D96, D103).scenariois now a hardTF033error naming its replacement.tflw loadwas removed and folded intotflw run, which drives functional and workload-bearing tests alike in one pass, in file declaration order (D105–D115).- A
parallel/sequentialtest-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-workloaddrops 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 separateload-*artifacts (D116–D122). - An
excludeconfig 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 lastapistep received, reusing theFinding/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-missingandcookie-not-securewould 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
sessionblock'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 lacksHttpOnlyreports clean (D287). authorized target "<url>" reason "<text>"intflw.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 inreport.html, so every artifact records the claim that permitted the scan (D21, D291).sec/tls-version-oldandsec/tls-weak-cipher, from a stdlibtls.connect()probe to the same host — the runtime drives Node'sfetch, 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 perhost:portper run, obeyingallow hostsand re-checking theauthorized targetdeclaration 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, leavingtls-version-oldunable 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 lastapistep actually made is re-issued under every other declared principal and the responses are compared. A separate matcher fromsecurity 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
apipaths 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-evaluateunique(…)/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
200on another user's order, and a collection endpoint's correct answer for a non-owner is a filtered200— both invisible to a status oracle. Extraction is narrow (bare rootidonly; 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
cleanhas to be earned.429, any5xx, a non-JSON body and a CSRF-shaped403are all inconclusive, so a suite that trips its own rate limiter can never report the throttle as an authorization boundary.404sits with the refusals, because returning404rather than403to 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 underauthorized target— permission for a probe to re-issue aPOST/PUT/PATCH/DELETEagainst that host. Without it a mutating step reportsnot probedrather 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 insidewait 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).TF033covers the same rule inside a workload.TF062/TF063are each a lexical refusal plus an exact runtime guard, because calls bind late and anactionbody's owner is not statically knowable (D315, D328, D329).- Every finding emits a runnable
.tflwrepro underreport/authz-repro/, with per-rule templates: a collection leak's correct answer is a filtered200, so a single always-403template 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 theauthorized targetreason, 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 lastapistep actually made, re-sent once per (mutable input × payload) with exactly one input replaced. Two bare words, notinput-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/Cookieand applies a different principal; this scan moves only the payload, so the observed request's ownX-CSRF-Tokentravels with every probe and the probe reaches the code it was sent to test. Consequently there is deliberately noTF062/TF063analogue — 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
5xxis not a finding — plenty of correct applications answer500to 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,5xxincluded),inconclusive(429only),not probed. A5xxis a first-class answer here because the app demonstrably did process the payload, which is the thing being asked about. probe oversizedandprobe traversal, two new indented sub-clauses underauthorized target, siblings ofprobe mutatingand 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, abody fromfile is not the checker's to read, and raw text may well be JSON (D382).TF064widened, not duplicated. Thewait until apirefusal now covers both pentest scans under one code, renamedSCAN_ASSERTION_REPEATED_REQUEST, because the repair is one identical sentence for both — what makes the construct wrong is a property ofwait until apithat does not know which scan is asking.TF033covers 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 ratedoes 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.jsongrows afindingsarray andreport.htmlgrows 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
ruleandendpointbeside 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--tagrun 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>addsngenerated mutation payloads per already-granted class on top of Tier 3's fixed corpus. It cannot widen whatauthorized targetpermitted — seeding is a capability of the run and a mutation class is a claim in the config, so no number reachestraversalon 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.tflwline 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-sarifreads 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-onuploads 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 underruns[].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
.tflwrepro 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 specnow 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 atestheader 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
retryandafterhooks are exactly that. Measured in the dogfood corpus when the gap was found:after fileused once, a bareaftertwice,retryfive 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 tabandswitch to tab Nare oneswitch. - 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_VERSIONis 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 specgains asubjectfamily — 16 constructs, andSPEC_MANIFEST_VERSIONmoves 1 → 2. The left-hand side of every assertion in the language was outside the manifest:status,duration,header "…", the fivebodyforms,request,request to "…", a locator in subject position,page,response,dialog message,dialog typeand 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
M154cwas not. Adding a family changes the document's shape, and a consumer that buckets byfamilyand has never heard ofsubjectdrops 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.tsheld thirteen of the sixteen andpackages/lsp-servertwelve 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 omittingdialog, and editor completion offersresponse,dialog message,dialog typeand the fourbodysub-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.configword 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.--jsonis 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.
MATCHERSis 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. LOCATORSjoinsSTEP_KEYWORDSandCONFIG_KEYWORDSas a manifest held two-way to the parser's own dispatch list, sotflw speccannot 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.
commitis a short sha, ornullwhere there was no git to ask (a published tarball, a vendored checkout) — never invented, because an invented one would be believed.dirtyisnullwhen there is no commit for it to be relative to.sourceisdevunder the unbundlednpm 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.configfiles 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,levelandquery— 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,webanddestinationare ordinary identifiers in a.tflwtest 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
.tflwfiles, by construction and by test.
Fixed — roadmap truth (M135b)
Section titled “Fixed — roadmap truth (M135b)”- The README described security testing as unbuilt and named a
tflw scanmode that will never exist — the three scans ship insidetflw 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
M130bfor 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 aresults.jsonindistinguishable from one that probed everything. - Both halves now reach
findings.sarif, in therun.properties["tflw/notApplicable"]bucket the three-state model already used, with akinddiscriminator ("rule"/"subject") and namespaced ids (principal:shopper,endpoint:POST /notes) so a consumer grouping byidnever compares a principal against a rule. - The built
dist/cli.cjsnow 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 totestand run by plaintflw 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 atflw scansubcommand, which stays deleted.--tag,--onlyand--failedselect 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
violationsassertions Tiers 1–3 define, applied per response the crawl issues. No fourth matcher family and no new subject keyword — anything else in the body isTF070, because anapistep there would be a request nobody will send under a principal nobody chose, andexpect status equals 200names 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 trafficre-issues every distinct route the run's own tests touched and invents nothing;seed spider "<path>"walks a page's links and forms, withmax pagesandmax depthas optional indented bounds. Findings carryvia—openapi,trafficorspider— 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 spiderfetches 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/adminwith 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.
walkedandwalkCappedare reported besidediscovered = 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 setswalkCapped, so a truncated surface can never read as a complete one. - Everything discovered is accounted for.
discovered = withheld + sentandreached ≤ senthold always, and every route indiscovered - reachedreachesresults.json'sscanBlindSpot.declinesandreport.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/5xxare judged;400,422,404,405,410,401,403,415and429are not. The400row is the important one — a validator's refusal is indistinguishable from a hardened endpoint. The401/403row 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 noseedis 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.TF069is withdrawn, not renumbered, and the number is skipped permanently. It was allocated and then withdrawn; by the timeTF070was minted, six comments across three packages already usedTF069as a pointer to that withdrawal, so the number was spent without ever being allocated. A shipped code is never reused.- A third
ReportEntrykind, declared loudly. The union had exactly two members sinceM56, so 13 dispatch sites across 8 files could test it as a binary and be right by accident —junit.tsalone 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.exhaustiveEntrynow 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 ordinaryapisteps alike. It deliberately does not live in the session's ordinary header map: the token has to stay distinguishable forsec/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 theAuthorizationheader beside it, and withholding both would measure authentication rather than CSRF.probe ciphers— a fourthauthorized targetsub-clause, and the one whose purpose is many connections to one host.sec/tls-weak-cipherhas shipped sinceM128abut 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
.tflwthe way Tier 2 authorization findings have sinceM130b—report/input-repro/besidereport/authz-repro/, and SARIF'stflw/reprojoin 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
apibase 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 absentservers,[]and[{"url": "/"}]all mean a server of url/, so a document's paths are host-root-relative. A crawl now readsservers[0], keeps the origin theapinames 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/v1emits 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 sinceM130b— seven milestones, in the one artifact whose entire purpose is to fail until the bug is fixed, and SARIF'stflw/reprolinks pointed at those files. Nothing in the suite could have caught it: every fixture server here ishttp://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.
discoverTestswalkedreport/, so emitted repros were collected and run as part of the suite that emitted them. Also latent sinceM130b; 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
anonymousprincipal 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.roundat 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 of1, 3, 12, 30, 31, 86ms where k6 reported0.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 reports0.37, 3.3, 12, 29, 30, 64. The JSON report carries these rendered values so a consumer diffing two runs never meets12.000000001where it expects12. 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 Ncompares 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 passesis less than 1, where before it rounded to 1 and failed.- The precision contract moved into the two places that state contracts, since
durationMswas already typednumberand TypeScript cannot notice this change (D808):types.ts's field docs now say "do not assume an integer", andhistogram.ts's header was rewritten. That header had claimed its bucketing carried "~0.1% relative error" and thatmin/max/avgwere "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 adurationsblock namingD809and the largest relative error it can produce — exactly1/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-testsderived 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): a401poll in a test runningas <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 consecutive401polls, re-armed by the first poll that is not a401. 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 a401after 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 asession. 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
RuntimeErrorraised 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 writeshas countagainst a body that will never be an array now waits the full budget instead of failing on one poll —D248's trade, taken the wayD248took it. - Scope is declared rather than widened (
D896): the poll loop only. Anexpectoutside a wait still throws immediately, because there is no second observation there to be patient for. wait until apigained the progress line the browser wait has had sinceM125c(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).--seedmeans replay the RNG, and this family's whole contract is that it never consults the RNG — keying it off the seed would make--seed Nre-issue the same emails on every run, which is the measured defect wearing a flag.--nowis already the documented way to pin the clock. - The promise gains an axis rather than a caveat (
D931):randommoves with--seedand not with the clock;uniquemoves with--nowand not with the seed. That is now the discriminator betweenunique likeandrandom like, which share a pattern language and were previously told apart on one axis alone. unique likesays 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 encodeemits lowercase, andhex decodeaccepts 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 ownrandomstream, 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/afterhook time no longer counts toward a workload iteration's reported duration (D782), and therefore no longer towardp50/p95/p99, the timeline, or anythreshold pNN durationclause. 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 /
afterhook /beforehook) 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
afterhooks now run after every iteration, passing or failing (D781), exactly asbeforehooks already ran before every one. They used to run only for a test carrying acleanupline — 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. cleanupin a.tflwfile is answered by name — "cleanupwas removed — teardown now runs by default under load", pointing atteardown never— rather than degrading to an unknown-statement error.tflw migratedeliberately 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|neverintflw.config, with--teardownoverriding it for one run (D783).on successis 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 theirafterhooks, because inter-test isolation is not something a committed config key gets to switch off. - Any level but
alwaysannounces 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 aDELETEfrom theexpect status equals 204under it, so the documented meaning is "skip theafterhook" 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 apiandtimeout browsernarrowtimeout step, which stays the fallback (D768). An HTTP request usestimeout apiif set, otherwisetimeout step; a browser step usestimeout browserif set, otherwisetimeout step. All five targets remain optional and every default is unchanged, so no config written before this release changes meaning —timeout step 10salone still sets both, exactly as it did.- The two tiers still merge per key, and one case is worth knowing:
defaultssettingtimeout api 10sbeside anenvsettingtimeout step 20sresolves 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 waitkeeps 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 readstimeout api, under the same clamp as before.timeout api 0sandtimeout browser 0sare refused (TF071), andtimeout expect 0s/timeout wait 0sstay legal. The rule moved from the namestepto 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 800msread a distribution whose right tail was defined by a key that also governed browser locator resolution.timeout browseris inert under a workload by construction (TF033/D19), so of the two onlytimeout apiever 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, andevidence "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 answerinput handlingalready got in M134a). The AST value,report.htmland--evidence headers-onlyare 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_LEVELSwas 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.
Changed — results.json (M136a)
Section titled “Changed — results.json (M136a)”authzBlindSpot→scanBlindSpot, anddeclines[].principal→declines[].subjectwith a newscanfield. 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.coverageis unchanged and stays authorization-only — it countsapisteps in a test declaring an owner, which is a fact about the suite that no other tier has an equivalent of.
Changed — grammar freeze (M66–M69)
Section titled “Changed — grammar freeze (M66–M69)”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, freeingcheckto mean only "soft assertion". Barecheck <locator>is nowTF014anduncheckisTF011.think <duration>→pause <duration>, avoiding a collision with the existingwait untilconstruct;wait until … for <duration>was added alongside it.- The test-header modifiers became order-independent,
ReportDecl.dirkeeps its string literal, and three further grammar-surface fixes landed in the same sweep.
Fixed — launch review (M59–M77)
Section titled “Fixed — launch review (M59–M77)”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.xmlgained<testsuites>, per-file<testsuite>elements andclassnameattributes, so CI servers attribute a failure to the right file.- An empty
--tag/--onlyvalue ran the entire suite instead of erroring. check --format jsonattributes every diagnostic to its own file instead of flattening several files into one array with colliding spans.excludestopped 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:starthas a matchingtest: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
tflwsample is executed through the shippeddist/cli.cjsrather 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 textno 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 Rspelling) 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, andexpect body pdf text …failed withcould 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 checknow reports a path literal that names a file which is not there (TF043) — coveringimport,use,with each from,body from,upload,matches fileanddrop file. Before this, a suite with a mistyped import checked clean and then printed✗ t.tflw (crashed) (0 ms)as its entire run output,--verboseincluded. -
TF043reports at two severities.import/useare an error —tflw checkopens 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 claimingno 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.
Fixed — the open model's client (M121)
Section titled “Fixed — the open model's client (M121)”-
An open (
rps) workload no longer reports the inter-arrival gap as request latency. Both load models now send over the samenode:httpkeep-alive client; the open model previously usedfetch, and on Node 26 afetchissued 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 rpsreported p50 36 ms whilehold 1 usersreported 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 runand 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.mdin 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 untilthat it already got inclick/fill.click button "Add to Crat"named the realbutton "Add to Cart";expect button "Add to Crat" is visiblesaid 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
textlocator no longer suggests the documenthead. Because thetextscan 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-pastecsspath —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 generatedcsssuggestion, which is the case that arm was written for.
Fixed — the first two minutes (M118)
Section titled “Fixed — the first two minutes (M118)”tflw initthentflw runis green in an empty directory. The scaffolded config pointsapiattflw://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, answeringGET /healthand nothing else. The README has annotated that second command "green in seconds" sincetflw initexisted, and it wasFAIL 0/1 passed: the scaffold pointed athttp://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 inreport.html— a green run that proves nothing about your system must not look like one that does.tflw checknever starts it.tflw install-browserssays what happened. On success it names the engine and theplaywrightversion 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".
Changed — what ends a value (M99)
Section titled “Changed — what ends a value (M99)”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"andselect 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 since0.1and 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"consumedfrom.random password nis 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 mswas accepted whileexpect duration is less than 250 mswas 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.
Fixed — the run's own artefacts (M192b)
Section titled “Fixed — the run's own artefacts (M192b)”report/events.ndjsonis written one line at a time. A whole-corpus run atevidence fullproduced 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:endpair says it is a hook. Both halves carryhook: "before file"orhook: "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 namedbefore fileis a test.tflw ui's live counter had read 106 of 105 done on a corpus whose first file has abefore 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 uploadingfindings.sarifcan 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
callin either direction — and the reuse pass had windowed step sequences without asking. A window that read a response before anyapiinside it was offered as a hint and refused bytflw refactor applywith the checker's ownTF039: 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 checkclean, and ten tests red — the caller'scapturereading the response it had before thecall(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. Notenv(): 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 namedapiservice's base, the shapeapi <service> GET /pathhas; 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 declaredapi rootcame to write its host's port into test files.- A relative
token urlresolves against the defaultapibase, 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
.tflwis 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
.tflwfile 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@tagand 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 uialso 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 untiland 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 recorddrives a real browser and writes the.tflwyour clicks made. It records what actually happened: a click that opens a tab is written as theswitch to new tabblock that wraps it, a click on an ambiguous name is scoped with thewithinthat 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.tflwyields 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 violationsthrew against any page setting a strictContent-Security-Policy— and the security scanner shipssec/csp-missingas 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-nameand 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.configis 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.
[0.1.0] — 2026-07-06
Section titled “[0.1.0] — 2026-07-06”Show the 2 entries
First public draft. API-only — the browser half lands in 0.2.0.
.tflwDSL: atflw.configdialect (env/defaults/session/require env),test/action/before/afterblocks,import/usefor shared actions and a JS/TS escape hatch.apisteps for GET/POST/PUT/DELETE/PATCH; all four request-body forms (body { … },body from "file",form k=v,upload) plus rawbody text; named services; per-steptimeout;without redirects.- A closed assertion grammar:
expect(hard) andcheck(soft) over status/header/body.<path>/body text/duration, withany/allquantifiers and anotnegation. capture+ chaining (create → use → verify across steps),let, and value expressions (arithmetic, string interpolation,today/nowdate math).unique(...)/unique email/unique like "…"/unique uuid(collision-safe) andrandom number/random date/random of/random like "…"/random uuid/random password [N](value-shaped) generators; a run seed with--seed/--nowreplay.base64/hex/urlencode(...)/decode(...)value transforms.session <name>blocks: run once per run, auto-apply captured headers to every test runningas <name>— no repeated login boilerplate. A session now auto-refreshes on a401(bounded to one retry) and honors its own TTL if it has one;session <name> oauth2sugar runs a client-credentials grant and sets that TTL fromexpires_in. Per-envcert/keyconfig 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--onlyas AND),retry Nwithflakymarking, inline (with each) and file-backed (.csv/.json) data tables,--workers N(in-process, per-file, default 1, deterministic under--seedat any concurrency). - Teaching-quality diagnostics: source line + caret + "did you mean", stable
TF0xxcodes, 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 for0.2.0) plusjunit.xmlfor 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-insecurefails a run up front ifinsecure trueis active;evidence full|headers only|noneconfig key +--evidenceCLI override control how much of the request/response trace lands in the report (never affects whatexpect/capturecan see);redact body.email, body.*.addressmasks matching JSON fields with[redacted]in the report, a declarative mechanism distinct from the existingenv(...)secret redaction — now also masking a plainbody.<path>capture/expect/checkstep's own detail text when its subject is redact-covered (closes TFLW-GAPS.md gap #15), not just the request/response trace; quantifiedany/allassertions 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-$refresolution — the assertion itself fetches and caches the document. retry honoring "Retry-After" up to N— a per-stepapiclause that re-issues just that one request when its response carries aRetry-Afterheader (seconds or HTTP-date), sleeping the indicated duration before each re-attempt; distinct fromretry N, which retries a whole test.tflw runandtflw init(scaffoldstflw.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) andajv(contract/schema validation) — both build-time only, never installed by a consumer; every other request still uses Node's plain globalfetch. - Documentation site (VitePress,
packages/docs-site, deployed to GitHub Pages): a hand-adapted Guide, a generated Reference (matchers/generators/CLI flags, from a new canonicalpackages/lang/src/spec-data.tsmanifest 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-serverclassifier and resolver code client-side against an editable or fixed sample, not a screenshot or recording), and an in-browser parse+check playground.GRAMMAR.mdwas 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-processtflw check --format jsondiagnostics. Diagnostics, hover, go-to-definition, autocomplete, rename, and signature help, all live over debounced in-process reparsing, for both.tflwtest files andtflw.config; atflw.envVS Code setting controls which environment diagnostics resolve services/sessions against.tflw lspitself speaks LSP over stdio, so any LSP-capable editor (not just VS Code) can use it. It also servestextDocument/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, anallow hostsblock) 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 anapistep immediately followed by arequestassertion catches the error; every other request keeps today's unconditional fail-fast); arequestassertion can't be combined with a response-based one on the same request, and isn't supported insidewait 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) andreport/.last-run.json(always overwritten, feeds--failed).tflw run --failedre-runs only the previous run's failing tests;--bailstops after the first failing test (in-flight files still finish under--workers > 1).--format ndjsonstreams the event log as one JSON line per event (always file-tagged, full detail regardless of--verbose) instead of human text, also written toreport/events.ndjson. Console output now gets anHH:MM:SS.mmmtimestamp prefix by default (--no-timestampsopts out); on GitHub Actions,--verboseoutput 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'srequire envlist 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 envitself still needs to stay on one line (no continuation support).