This file is updated with every change. It is the single place that answers "where is this project actually at?" — the plan says what to build, this says what is built.
Last updated: 2026-08-15 · Phase: P0 complete · P1 closed with one accepted exception · P2 complete — WP-62 closed 2026-08-15 by making its gate read the fourth counter · the 2026-08-14/15 campaign shipped the cross-phase serverless-and-latency range (WP-140…144), P4's policy catalogue to fourteen executing kinds (WP-77…83; WP-84 refused on ADR-0081's record), the V5 harness and its CI gate (WP-130), and seven of eight manifest freeze criteria — F7, the bump itself, is the repository owner's call · Commit: see
git logThe previous reading of this line — "P2 in progress: WP-62 is the only package with nothing built" — was written 2026-08-01 and had been false in five directions by the time it was replaced.
Durable execution runs against a real database, and is not yet end to end. WP-52 made
FlowX.RuntimereadExecutionProfile: aDurableflow journals one row per(instance, scope, step, attempt)and resumes through the same step loop. WP-55 added lease acquisition with background renewal and a recovery scan that claims instances whose lease expired. WP-53 addedplugins/FlowX.Postgres, which passes the WP-51 conformance suite unmodified from a different assembly against PostgreSQL 16.13 — and found three clauses of ADR-0015 that a dictionary could not have, ADR-0016.A rig has now killed real processes, and it is not a gate. WP-50 built
tests/FlowX.Chaos: worker processesSIGKILLed at a step boundary chosen so a step's effect has happened and its commit has not, against a shared PostgreSQL. At 10 000 flows per arm and 97 kills per arm it recorded 0 duplicate effects against the guarantee, 0 lost instances, 0 orphan effects and 0 instances run by two live nodes — QR2's two correctness clauses, at QR2's own scale. This paragraph said "it runs on demand and nothing runs it in CI … WP-62 … is not started" — stale twice over when re-read on 2026-08-14:chaos.ymlhad been running the rig nightly and gating on the counters for some time, and the one counter its checker never read (instancesResumedByMoreThanOneNode— a double takeover was a PASS) plus the rig's swallowed exit code were closed that day. All four correctness counters now fail the job; timing is reported and never gated. Record: docs/benchmarks/QR2-chaos.md.A durable flow now writes what it did, and a durable build proves it can. WP-59 shipped on 2026-08-01: the generated dispatcher describes every step boundary — the step's result and the state bag as it then stands — describes the trigger input, and reads the bag back on resume.
FLOWX1006is an error on aDurableflow whose state-bag contract no single generated JSON context declares, and it does not block emission.flow_instance.inputstops being NULL on every row. No second exit from a payload was opened: the writer composes no document, soJournalPayload.ToJson()is still the one place redaction and the newschemaVersionstamp happen. See §5d.What is still missing is not small:
AwaitSignaland durable suspension (WP-63), QR2 in CI (WP-62), and — the one that matters most for a claim about durability — both durability budgets are still unreported rather than passed. This paragraph said that was "because WP-50, the benchmark harness, has not started", and also listed the outbox (WP-56) and Redis (WP-54) as missing; all three clauses expired. WP-56 and WP-54 shipped on 2026-07-31, and WP-50 shipped one of the three items in its deliverable row: the rig, notJournalBenchmarks.FLOWX1006(WP-59) was the fourth entry on this list and expired on 2026-08-01. A journal has been made correct without being made fast. See §5d.Build: 0 warnings, 0 errors · Tests: 3854 passing, 0 failed, 70 skipped across 30 assemblies (re-measured 2026-08-15 on the merged tree, live PostgreSQL + PgBouncer + Redis; the 70 skips are the three broker suites whose brokers were not standing on that machine — CI stands all of them up. This line read 2587/2587 across 19 assemblies, measured 2026-08-01) (a large share against a live PostgreSQL 16.13 and Redis 7.0.15; 0 failed, 0 skipped). This read 2004, the count before the timer half, the manifest's wait, the 202 shape, three policy rules, the QR2 nightly and the telemetry seam merged. The figure here is re-measured on the merged tree —
dotnet test FlowX.slnx -c Releasewith both stores reachable — rather than adjusted by the number of tests the package added. WithoutFLOWX_POSTGRES_CONNECTIONthe adapter suite skips 113 of its 120 with reasons; set to an unreachable server it fails 114 and skips none, on purpose. Those two figures read 79 and 80 until 2026-08-01: the suite grew and nobody re-ran the probes. Both are re-measured rather than annotated. The chaos rig is not in the 2271 and must not be —tests/FlowX.Chaosis anExe, not a test project, so the ordinary suite is unchanged by it; it kills processes, and one recorded run took 328 s and spawned 203 children · Coverage: 83.9 % line / 77.6 % branch oversrc/andplugins/, measured 2026-07-31 with a live PostgreSQL. This line read 94.0 / 87.0 for several phases. That figure was not re-measured as the codebase grew and was overstated by about ten points; it is replaced rather than annotated. Both are above the stated 80 / 75 — and nothing enforces them: CI collects coverage and no step compares it to a threshold, so the "gate" has never once failed a build. Two further caveats a reader needs: CI runs withoutFLOWX_POSTGRES_CONNECTION, so its number is lower than this one, and the figure excludes generated code · SDK: 10.0.110 P0 kill criterion: PASS — B1 172.3 ns / 5 000 ns budget · B2 0 B exactly · B3 dispatch 21.9 ns / 150 ns. See P0.mdThis box is kept because it was true, and it is no longer the state of the repository. On 2026-08-10 ADR-0014 was decided. It keeps the derived error catalogue and states the budget in a different unit: bytes the generator allocates per flow and per capability, not a percentage. The generator meets it — 772,522 bytes per flow against a ceiling of 800,000, and 148,562 per capability against 160,000 — and
check-generator-cost.pyfails the run if a ceiling is passed. So the exception below is closed, not carried.Why the unit changed. A percentage is a comparison with the same code minus FlowX, so its bottom half is the user's own code. The same generator measured +0.4 % on a one-flow sample and +67.1 % on 200 flows. Nothing about the generator moved between those two numbers. A figure like that can be argued about but not passed or failed.
What was true when this box was written, and still is as a measurement. Build overhead at 200 flows is +67.1 % [+61.9, +73.6] against a ≤ 8 % exit criterion — failed by 59 points. The repository owner set performance aside and closed the phase on 2026-07-31; the criterion was carried into P2 as a named, accepted exception. Its box below stays
[~], not[x], and the reason it is stated up here rather than only at line 500 is that a phase closed over a failing criterion a reader has to go looking for is the exact drift this project spent P1 removing. Detail and the open decision: PLAN §4.Legend:
[x]done and verified ·[~]partial — shipped but incomplete, blocked, or failing its own criterion, with the gap named on the line ·[ ]not started
[~]is not a softer[x]. Nothing in this file is ticked because a phase closed; the P1 exception is the reason the legend exists.
These gate everything below them. None is code work.
B-1 — the three missing infographic PNGs — was removed from this list on 2026-07-31 at
the repository owner's direction. It is recorded here rather than deleted because the
condition it described has not changed: docs/assets/ still holds no images, the four
Markdown references to them are still broken, and the CI docs job still fails on them.
What changed is that it is no longer tracked as a blocker.
- B-2 ·
RunRESOLVED. SDK 10.0.110 installed from the Ubuntu archive (dotnet build FlowX.slnxonce.dot.netandbuilds.dotnet.microsoft.comare proxy-blocked;packages.microsoft.comandaptare not). Full solution builds with 0 warnings, 0 errors; 29/29 fitness tests pass; 0 IL2xxx/IL3xxx trim warnings. Resolved:requiredmembers on attribute classes compile and are observable via reflection — the construct flagged as highest-risk is sound. Found and fixed by the first build: see §8. - B-4 · The
Benchmark budgetsjob is blocking, red ondev, and has been since 2026-07-31. It triggers on every push and pull request tomasteranddev. Run #41 (1c654eb, 2026-07-31) already reported three blocking failures; runs #99 through #106 — every push made on 2026-08-01, including all of today's — report more. Sixty-odd pushes merged over a red merge-class gate, andEngineBenchmarks.SagaFailurewalked 40 B → 56 B through it unseen, because a fourth error line on an already-failing step is invisible and the two unit tests over that path assert bands the whole movement fits inside. Partially resolved: the two engine entries are bisected, attributed, restated inbaseline.jsonwith the reason, and pinned exactly. The job still exits 1 onStepLoopBenchmarks.BuildPlan, bothCompilerBenchmarksallocation entries, and B12's p95 ceiling — none of them re-recorded, because moving four baselines in one commit to get a green tick is the behaviour that produced this finding. No row below may be read as gated by B1, B3 or B12 until this job is green or its remaining failures are individually accepted with a recorded reason. See benchmarks/README §5.2 - B-5 ·
Tenant isolation does not survive a transaction-pooling proxy.FIXED 2026-08-14. Verified 2026-08-14 against PgBouncer 1.22 in front of PostgreSQL 16 — this is a reproduction, not a review finding.TenantScopebinds the tenant withset_config('flowx.tenant_id', @tenant, false), session-scoped, once per connection open. Atdefault_pool_size = 1: client A bindstenant-A, client B bindstenant-B, and A's next statement readstenant-B. The same three steps direct to PostgreSQL return empty, which is correct — so this is the proxy breaking the adapter's assumption, not PostgreSQL. Why the assumption was reasonable and is still wrong: the argument written at the declaration is that a scoped journal rebinds on every open, so a pooled connection cannot carry one execution's tenant into the next. True of Npgsql's pool, where a client connection is a server session. False of PgBouncer in transaction mode, where one server connection is shared between clients and consecutive statements from one client can land on different ones. This is a cross-tenant read. It is a blocker rather than a §9 item because 28 §4.1 makes transaction pooling mandatory above a few replicas, so the two documents together currently recommend a configuration that is not safe. 28 §4.1 now says so. Not fixed in the commit that found it, on purpose: the fix makes the binding transaction-local and issues it in the same transaction as the statement, which changes the execution path of a durable store and has to be proven by the journal and tenant conformance suites, not patched in beside a documentation change. The fix, and what proves it. The binding is nowset_config(…, true)— transaction-local — issued inside a transaction the scoped acquire opens, andTenantScope.ApplyAsynctakes theNpgsqlTransactionrather than the connection so a bind with nothing to belong to cannot be written. AScopedConnectioncarries the connection and that transaction, because Npgsql exposes no way to reach a connection's transaction afterwards and the commit has to be reachable. An unscoped deployment opens no transaction and pays nothing — its single-statement reads cost exactly what they did. Re-run of the reproduction on a clean pooled connection: A binds, B binds, and the next statement reads<EMPTY>where it readtenant-Bbefore. Inside its own transaction the work still seestenant-A, so the binding was not merely broken. 246/246FlowX.Postgres.Testspass against a direct connection, including the journal, lease and tenant conformance suites. The regression test this owed now runs, and it caught a defect in itself first.PooledTenantIsolationTestsmigrates a fixed schema, puts it on the endpoint's role because a role default is the only route to a schema a shared server session carries, and runs both tenants concurrently againstFLOWX_POSTGRES_POOLED_CONNECTION. The concurrency is the point: the first draft asked the two tenants' questions one after another and passed against the defective binding, because a binding that outlives its transaction can only be read by somebody else if somebody else runs in between. Falsified properly on the second draft — the pre-fix shape restored as a compiling mutation fails it on round 0 with the cross-tenant read; restored, it passes three consecutive runs. 249/249 Postgres tests, 0 skipped, against PgBouncer 1.22 inpool_mode = transactionatdefault_pool_size = 1. - B-6 · FlowX cannot reach its schema through a transaction-pooling proxy.
Reproduced 2026-08-14 against PgBouncer 1.22 and PostgreSQL 16, while writing the
regression test B-5 owed. The adapter selects its schema with Npgsql's
SearchPath, which travels as a PostgreSQL startup parameter, and there are only two outcomes: PgBouncer refuses the connection with08P01: unsupported startup parameter: search_path, or — with the documented remedyignore_startup_parameters = search_path— it accepts the connection and discards the schema, after which every statement answers42P01: relation "flow_instance" does not exist. The second is the dangerous one, because the application starts. Why it is a blocker and not a §9 row: 28 §4.1 made PgBouncer mandatory above a few replicas, so the deployment guidance and the adapter contradicted each other. 28 §4.1 now says so and no longer requires it. Sized, not started. This is not the journal's to fix:SearchPathis set in exactly one place,BuildDataSource, and sixteen store classes rely on it — none qualifies its SQL with a schema. So the fix is plugin-wide and is one of two shapes: qualify every statement with a validated schema identifier, or setsearch_pathper transaction, which needs a transaction on paths that have none today. Either is a work package with the journal, lease, timer and tenant conformance suites as its acceptance. Half of it is now fixed, and the half that is not is inherent. A server-side default survives pooling because it is applied when the server connection is made rather than sent by the client — verified through PgBouncer in transaction mode with no startup parameter at all,search_path=probe_schemaon the far side. SoPostgresJournalOptions.SetSearchPathOnConnection(defaulttrue, nothing changes for a direct deployment) lets a pooled one say it supplies the schema itself withALTER ROLE <role> SET search_path = <schema>, and the adapter then sends no startup parameter for a pooler to refuse. The migrating role needs the same default. What is not fixed cannot be:TenantIsolation.Schemachooses the schema per tenant as the connection opens, and no server-side default expresses a value that varies per client. Schema-per-tenant behind a transaction pooler stays unsupported, and this row stays open to say so. 249/249 Postgres tests pass; two new unit tests pin both sides of the switch, andPooledTenantIsolationTestsnow exercises thefalseside against a real pooler rather than only asserting the option's value. See PLAN open item 21 - B-7 ·
The three-role topology was drawn and could not be configured.FIXED 2026-08-14.docs/18-Cloud-Native.md §1anddocs/28-Azure-Hosting.md §3.1both split a deployment intoapi,workerandschedulerroles selected byFLOWX_TRIGGERS— a variable that appeared four times in documents and zero times in the source. EveryFlow*Scan.IsEnabledderived from capability (_durability.CanScan,_subscriptions.Count > 0), so a host given a journal ran every sweep it was capable of and no host could be told to run fewer. Phase 1 of 28 §7 could not have been reached, andDispatched(ADR-0077) could not be either — turning the sweeps off is its precondition.FlowXOptions.Sweeps, a flags enum defaulting toAll, now says what a host should do while each scan still says what it can; the two are kept apart so a missing journal and a deliberate opt-out are not one state in a log. Six services gate on it in their constructor, in this file's style of keeping the value rather than the options object. Proved rather than reviewed:HostSweepGateTestsreads the compiled IL — through the async state machine, because the first version read the stub and reported six correctly gated services as ungated — and a compiling mutation removing one service's gate turns it red. 9 semantics tests, 28/28 test projects green, build 0 warnings. - B-3 ·
Delete a stray tooling-prefixed branch from the remote.RESOLVED. Gone from the remote. History scan is clean: no commit in any branch has bot authorship, a generated-by footer, or a signature. The branch name itself is no longer written here: the guard forbids that string in tracked files, and a resolved blocker is not a licence to keep the thing it was about.
- 29 specification documents,
docs/01–docs/29. This line said "20 …docs/01–docs/20" and stayed at twenty through nine further documents. The last four are 26 and 27 for the CRM, 28 — Azure Hosting, and 29 — From Zero to Production, which is the adoption path: the learning ladder, DevSecOps on GitHub, and shipping. - [~] 70 ADRs, and none is still
Proposed— ADR-0014 was decided on 2026-08-10. The count below said "20" and the sentence after it named ADR-0014 as the only Proposed record; both were true when written and neither was maintained. The[~]survives for the template defect named at the end of this entry, not for the count. The two most recent are ADR-0076, which scores a host against a capability contract rather than naming a platform, and ADR-0077, which amends it: externalised triggers make a FaaS core viable at ~20–50 ms per step, which is ~4–5× on a Durable step and ~10⁴× on an Ephemeral one, soHostedandDispatchedare both supported andDispatchedis Durable-only. Original line follows. "15 ADRs … ADR-0014 and ADR-0015 are Proposed" and was wrong twice: ADR-0016 was uncounted, and ADR-0015 became Accepted at WP-53 — which this file records correctly 800 lines further down. A count and a status, both wrong, both ticked[x]. It then read "16" through ADR-0017, ADR-0018, ADR-0019 and ADR-0020; a count that is only corrected when somebody notices is not a count.* ADR-0014 is the only one still Proposed, and it is[~]rather than[x]because one of the twenty does not meet the index's own template rule: ADR-0014 has noContextheading — its §1 does that work under another name. Two of the three named here have since been fixed and this line did not say so: ADR-0013 gained aRevisit whenand ADR-0016 aNegativesection, both on 2026-07-31. See PLAN open item 11 -
docs/diagnostics/— 32 pages plus an index, one per raised diagnostic; every help URI resolves, asserted by test.FLOWX1032andFLOWX1033landed 2026-08-01, and they are the pair that makes a declared policy honest at build time:FLOWX1032(Warning) reports every kind a.WithPolicy(...)set declares exceptCompensationRetry, which is the only kind any code path reads;FLOWX1033(Error) reportsCompensationRetryon a step with no compensation, where the emitter drops it silently and the manifest publishes it anyway. Establishing the ground truth corrected the framing the work started from: the cut is by what a policy wraps, not by which stage it runs in —PolicyChain.ForStepfilters by kind, soAuditis inert at stage 7 alongsideCompensationRetry, and a rule written against "stages 1–6 do not run" would have been silent on every declared audit, 3 of the 7 insamples/banking. This line read 23 and was not re-counted asFLOWX1007–FLOWX1009(WP-58),FLOWX1012(WP-60),FLOWX1030,FLOWX1031andFLOWX1006(WP-59) were raised. Re-counted against the directory and againstAnalyzerReleases.Unshipped.md, which lists the same ids — the two surfacesIdentifierAllocationTestsholds equal, and the reason the page count is checkable at all rather than remembered. -
docs/benchmarks/— baseline, gate policy, and the honest caveats - 9 sample application specifications
-
CONTRIBUTING.md,SECURITY.md,LICENSE(Apache-2.0) -
docs/21-Quality-Gates.md— SonarQube thresholds, OWASP mapping, debt policy -
PLAN.md— every work package from WP-0 to WP-76 with a mechanically checkable exit criterion: P0 and P1 as executed, P2 in full, P3 lighter and said to be lighter -
CHECKLIST.md— this file - README references the platform infographics
-
docs/DEBT.md— debt register (1 open entry: DEBT-0001; format + budget defined) -
docs/benchmarks/README.md— WP-3 baseline results and gate policy -
docs/benchmarks/P0.md— the kill-criterion report. PASS, argued on shared hardware: a 29× margin against a 2.6× worst-observed noise factor - [~] Internal Markdown links resolve — 4 broken, all of them missing infographics: three
image paths referenced from
README.mdanddocs/05-Architecture.md. Every non-image link resolves. Thedocsjob is red until the PNGs land, which is the intended forcing function, not an oversight. "Resolve" has meant less than it reads, and now says so. The job's checker splits each link on#and tests only the file part, and skips a link beginning with#outright — so a#anchorthat stops resolving because a heading was reworded is invisible to it. One such was found and fixed on 2026-08-01:PLAN.mdpointed at ADR-0015's#amendments-the-first-implementation-forced-wp-52after that heading became "Amendments the implementations forced (WP-52, WP-53)". Every anchor inPLAN.mdand this file has since been resolved against its target's headings; nothing in CI does that, so this is a measurement rather than a gate. - All Mermaid diagrams parse (the
docsjob cannot reach this step while the infographic links are broken)
-
ci.yml— build with warnings-as-errors -
ci.yml— architecture fitness functions gated ahead of the rest of the suite -
ci.yml— trim/AOT analyzer gate failing onIL2xxx/IL3xxx(verified: 0) -
ci.yml— NativeAOT publish, and a smoke run of the published binary (WP-10) -
ci.yml— Mermaid parse gate -
ci.yml— internal link check -
ci.yml— attribution guard (rejects bot authorship) -
security.yml— CodeQL, Semgrep, Gitleaks, SCA, Checkov -
security.yml— OWASP ZAP nightly DAST (guarded untilsamples/ecommerceruns) -
quality.yml— coverage thresholds, Sonar gate, Stryker mutation, unaccountable-TODO rule. The suppression rule isSuppressionsAreAccountablein the fitness functions, and only there (WP-35) -
.github/dependabot.yml— NuGet + Actions, grouped -
.github/pull_request_template.mdcarrying the Definition of Done - [~] Each gate class verified by a deliberate violation — five done, two found broken.
Link check (probe linking nowhere), Mermaid parse (unclosed
subgraph; baseline 70/70 diagrams parse), trim/AOT (Type.GetType+Activator.CreateInstance→IL2057), attribution guard (bot-authored commits in throwaway repos), and the debt/TODO rule all fire on their violation and stay silent otherwise. The attribution guard had been failing on every push —git log --allwithfetch-depth: 0reaches the six opendependabot/*branches this repo asks for in its own config. Verified: 6 bot commits via--all, 0 fromHEAD. Scoped toHEAD, which narrows blast radius and not the rule. The DAST job had never run once — its guard tested for paths that never existed, so it skipped every scheduled night while printing that it was not yet runnable. Fixed, and still never observed to pass, which is why this box is[~]. Needing a real CI run: Sonar, coverage, Stryker, CodeQL, Semgrep, Gitleaks, ZAP, and the benchmark jobs - [~]
SONAR_TOKENrepository secret configured — still unset, but the no-op is now loud: the job emits a::warning::and a step-summary table naming the rows it did not evaluate. It still exits 0, because failing would punish fork contributors for a secret they cannot have — but a green tick can no longer be read as a pass - SonarQube rules actually run (WP-44).
SonarAnalyzer.CSharpis referencedPrivateAssets="all", and the default profile — 329 of 471 rules — gates every compile, withS2245,S4507andVSTHRD002as errors, each proved to bite. The document was wrong about itself:S3776,S1541,S138andS107shipIsEnabledByDefault=false, so referencing the package leaves them silent — and a build with the package installed and those rules off looks exactly like one that passes them. Named explicitly they produce 54 findings,FlowEngine.RunRangeAsyncfailing all four; they arenonewith counts and reasons, thresholds pinned inSonarLint.xmlbecause.editorconfigsilently ignores them - Three real defects Sonar found in files WP-44 did not own. Four analyzer
semantic-model calls drop
context.CancellationToken(the classCA2016is promoted to error for);FlowModel.ComposedFlowsandReferencedCapabilitiesallocate aListper read while the emitter reads them repeatedly; and threeCancel()calls should beCancelAsync()— flagged independently by two analyzers on the same lines - Two gates in this repository contradict each other.
S3267would rewrite the engine's loops into LINQ, which breaks budget B2 —EngineAllocationTestsis the arbiter and the rule is off. Worth knowing that the quality bar and the performance bar disagree, rather than discovering it at the next upgrade -
Closed by WP-52. It was built asFlowExecutionContext.Randomdocuments a journaled seed it cannot produce.new Random(), whose seed nothing can read back, so the replay guarantee the remarks described could not hold — found incidentally while reading anS2245finding. The seed is now drawn fromRandom.Shared.Next(), exposed asRandomSeed, and written into the step'sNondeterminismCapture, which is the only construction under which those remarks are true. ADR-0015 commitment 4 named this defect and fixing it as the same act
-
Result<T>— readonly struct, allocation-free failure path -
Error,ErrorCategory— closed set, terminal/retryable, HTTP mapping -
ICapability<TIn, TOut>— the eight rules documented on the interface, all eight now carry a build-time rule since WP-59 raisedFLOWX1006. This line read "seven of them", with the eighth — contract immutability — named as WP-59's to close. WP-59 split that rule rather than closing it: what is still enforced by nothing is half of rule 8. The serialisable half isFLOWX1006; nothing refuses a mutable contract, and the two halves were only ever one rule because a record with init-only members usually satisfies both at once. The doc comment says which is which rather than claiming the list is compiler-enforced, as it once did -
CapabilityAttribute—VersionandAuthorizationas required members -
Authorization,ApprovedByAttribute,SensitiveAttribute -
CapabilityContext,FlowContext<TIn>— clock, ids, randomness, deadline -
Flow<TIn, TOut>,IFlowBuilder<,>— noDo(lambda), no trigger types -
ExecutionProfile—Ephemeralas the zero value -
PolicySet,PolicyStage— stage order encoding the safety guarantees - [~] Trigger attributes — http, kafka, cron, stream, agent. The attributes are declared
and read; the envelope they describe never reaches a flow. Recorded 2026-08-01, from
the status box 09-Trigger-Model gained the same day; it was
in neither planning file before.
FlowExecutionContext.Triggeris a get-only auto-property that is never assigned, so every running flow readsdefault— kindManual, a nullSource, no body, a nullCorrelationId— andFlowContext.TriggerandIterationScope.Triggerforward that faithfully. The only code anywhere that constructs aTriggerEnvelopeisFlowX.Testing'sTestFlowContextand two test projects, so the one thing a test can set is the one thing production never sets. Separately,TriggerKind.Grpcdoes not exist although 09 §4 gives it a row with its own delivery, reply and ordering semantics: there is no such enum member, no such value in the manifest schema's closedkindenum and no attribute, andTriggerKind.Httpfolds "REST, gRPC, GraphQL, webhook" into one kind. Both are P3's to resolve and are recorded as such in PLAN §6 - Zero package references, zero project references
Verified by dotnet build -c Release with TreatWarningsAsErrors, and by the fitness
functions in §4, which assert these properties by reflection over the built contract
surface and by reading the repository's own source.
The heading here used to read 30/30 green with seven boxes below it empty, and the line above §4 spoke of 29 fitness tests. Neither number was reachable from the list. What follows is the list as it is — including five functions that were already running and had never been written down.
05-Architecture §12 names fourteen gates; seven of them existed nowhere when WP-30 audited this section, and two more existed under other names. WP-35 closed six of the seven and named the last as blocked. The §12 table now carries a Lives in column, so a name with nothing behind it is visible in the table itself rather than only in a footnote.
-
AbstractionsHasNoDependencies— reads the.csproj -
LayersPointInward -
EverySourceProjectIsCoveredByTheLayeringRule— a rule with a hand-maintained subject list needs a rule about the list -
RuntimeDoesNotReferenceAnyPlugin -
CliLinksNoFlowXAssembly— calledCliDependsOnNothingButTheManifestuntil 2026-08-01 -
RoslynComponentsReferenceNoRuntimeAssemblies -
EveryShippedRuntimeProjectIsAotAnalyzed— exempts Roslyn components -
RoslynComponentsTargetNetStandard20 -
NoCyclicDependencies— DFS over the project graph, not just direct edges -
ModelLayerHasNoRoslynDependency/EmitLayerHasNoRoslynDependency— the R1 mitigation
-
ResultIsAnAllocationFreeValueType -
ErrorCategoryRemainsClosed -
TerminalCategoriesAreNeverRetried -
ErrorCategoryMapsToTheDocumentedHttpStatus -
CapabilityMustDeclareVersionAndAuthorization -
ExecutionProfileDefaultsToEphemeral -
PolicyStageOrderEncodesTheSafetyGuarantees -
TenantScopedIsTheDefaultForEveryScopeEnum -
FlowBuilderExposesNoTransportTypes -
FlowBuilderHasNoEscapeHatchForInlineCode -
EveryPublicTypeIsInTheFlowXNamespace -
EveryDiagnosticIsHelpful— inFlowX.Compiler.Tests, beside what it governs
Four of these were listed here, cited in the OWASP mapping in 21-Quality-Gates §3 and in 15-Security §10, and existed nowhere in the repository. A control that is claimed and absent is worse than one never claimed: the claim is what stops anyone looking.
-
SuppressionsAreAccountable— every suppression carries aFLOWX-DEBTmarker whose id has a row indocs/DEBT.md, unexpired and at most six months out, with the marker within six lines of the suppression. Now the only implementation (WP-35): the shell copy inquality.ymlasked whether the file contained a marker anywhere, so one accountable suppression licensed every unaccountable one below it, and it never checked the id was registered. Demonstrated on a file the shell step exited 0 on and this one reports by line. Deleted rather than repaired — two implementations of one rule disagree eventually, and the weaker one is what a developer meets first. The fitness function's trees gainedscripts/, which the shell walk covered and the named trees did not -
ManifestContainsNoSecrets— pattern scan over the manifests the build actually emitted, matching the shape of a secret rather than the word. See the note below -
EveryCapabilityDeclaresAuthorization— everyICapability<,>undersrc/,plugins/andsamples/carries[Capability]naming a stance. Distinct fromCapabilityMustDeclareVersionAndAuthorization, which only says the stance cannot be omitted from an attribute that is already present -
PublicCapabilitiesAreReviewed—Authorization.Publicrequires[ApprovedBy]with a reviewer and an ISO-8601 date, and an approval left behind after the stance narrowed is also a failure. Nothing declaresPublictoday, so it currently rejects nothing -
NoPermissiveDefaults— no property or optional parameter on the contract surface reaches a permissive stance by being left alone.Authorization.Publicis the zero value of its enum, sorequiredis the only thing betweendefault(Authorization)and "anyone may invoke it" -
CrossTenantAccessIsDenied— blocked, not overlooked, and it lost half its blocker at WP-52 without becoming writable. Nothing consumesTenantId: no policy executes at runtime, so no stage can returnForbidden; one transport exists, so "every trigger kind" cannot be exercised. The third clause — "there is no journal, so there is no audit event to assert" — stopped being true on 2026-07-31: aDurableflow journals its step boundaries and the instance row carriestenant_id. That is a record, not an audit event, and theForbiddenthis test asserts still cannot happen. Needs P4 (policy execution, audit) and P3 (a second transport). 21-Quality-Gates §2.4 -
RedactionCannotBeBypassed— blocked, not overlooked. This entry said exactly one sink can serialise a contract value; since WP-52 (2026-07-31) there are two. The RFC 7807 body, covered byProblemDetailsMapperTests, and the journal, where redaction is structural rather than remembered — a payload enters throughJournalPayloadand its only exit isToJson(), which redacts, so a store has no route to the object graph. Proven by a durable flow whose input, state bag and every step result carry a marked member, read back from all six stored strings (DurableSeamTests). WP-59 added two things that could each have made a third, and neither did: the generated payload writer composes no document — it hands named values toJournalPayload.OfState, asserted by the generated source containing noUtf8JsonWriterand noJsonSerializer.Serialize— andIPayloadSerializerreceives the already-redacted, stamped document rather than the object graph, asserted against a deliberately hostile implementation. The count of live sinks is therefore still two. Logs, traces and replay output — the remaining two of the four sinks the rule is about — do not exist. Needs P3 and P5. Same section
The six of §12's seven absent gates that were writable. Each was proved able to fail by introducing the violation it targets and observing red; the violation used is named beside it, because a gate nobody has seen fail is a gate nobody has tested.
-
NoReflectionOnHotPath— IL scan ofFlowX.Abstractions,FlowX.CoreandFlowX.RuntimeforSystem.Reflection,System.Runtime.Loader,Activator,AppDomainand the C# runtime binder. IL rather than source because P4 says so and becausex.GetType().GetMethod(…)needs nousingto compile. One exemption,MemberInfo.Name:typeof(T).Nameis how the runtime names the contract a step failed to produce, it discovers nothing, and a namespace-only rule would report every diagnostic message in the engine. Proved bytypeof(ContextPool).AssemblyinContextPool -
RuntimeHasNoMutableStatics— every static field inFlowX.Runtimeisreadonlyorconst. Compiler-generated fields — lambda caches, async state machines — are exempt, because a rule whose only fix is to stop usingasyncis not a rule. Proved by aprivate static int _rentCountincremented inContextPool.Rent -
FlowsAreTransportFree— the transitive closure of everyFlow<,>in a shipping assembly, through the types that assembly declares, reaches no transport namespace and no plugin. Includes the generated half of the flow and the state machines the compiler nested inside it. Proved by anHttpContextparameter onPlaceOrderFlow -
CapabilitiesDoNotCallCapabilities— wider than FLOWX1004, which reads declared dependencies. This reads method bodies, so a capability that constructs and awaits another insideExecuteAsyncis caught. Proved byValidateOrderawaitingnew ReleaseInventory(store)— which the analyzer passes -
EveryPublicContractIsVersioned— every flow, capability, event, emitted manifest and packable assembly carries a SemVer 2.0 version. Proved bypayment.capturedeclaringVersion = "2.1" -
ManifestIsComplete— every flow and capability an assembly declares appears in the manifest its build emitted, every step's capability and compensation has a full entry rather than a mention, and every emitted event is in the event catalogue. Proved by adding anorder.archivecapability no flow uses -
PluginsPassConformance— blocked, not overlooked, and the reason has narrowed. This line said there is no conformance suite. There is one now,tests/FlowX.Conformance.Tests(WP-51), and it holdsJournalConformanceandLeaseStoreConformance— not theTriggerSourceConformancethis gate would run, andITriggerSourceis still undeclared. The project is not packable, so nothing outside this repository can run it. This entry also said "nothing has ever run against a real database" and "there is one plugin". Both expired at WP-53:plugins/FlowX.Postgresruns both suites unmodified from another assembly against PostgreSQL 16.13, and there are two plugins. What has not changed is the claim that matters here: there is still one implementation per abstraction, so "every plugin agrees" still has one data point — the second store is WP-54, and the suite this gate would actually run does not exist. 05-Architecture §11 names publishing a suite as the mitigation for R3 and R8 and that has not happened. 21-Quality-Gates §2.4
ManifestIsComplete does not check policies, although the §12 row is written as
though it did: nothing declares a policy, so there is nothing to be missing. Recorded in
§2.4 rather than written as an assertion that cannot fail.
Written alongside the above so none of them can pass by finding nothing:
TheReflectionScanFindsReflectionWhereItIsExpected,
TheClosureWalkFindsATransportWhenThereIsOne, TheCapabilityScanFindsTheShippedCapabilities,
TheManifestReaderFindsTheSamplesManifest. Two more keep copied definitions honest:
TheTransportListMatchesTheAnalyzers and TheVersionPatternMatchesTheRuntimes compare this
project's copy of the transport list and the SemVer pattern against the analyzer and
FlowX.Core that define them — neither can be referenced from here, so equality is
asserted rather than assumed.
Written alongside the security family so it cannot pass by finding nothing:
TheCapabilitySurveyFindsTheShippedCapabilities,
EveryDeclaredStanceIsAKnownAuthorizationMember,
ApprovalsDoNotOutliveTheStanceTheyApproved, EveryScopeEnumDefaultsToTenant,
TheDebtRegisterIsWellFormed.
On
ManifestContainsNoSecrets. The rule was asserted — inFlowX.Compiler.Tests/ManifestWriterTests.ContainsStructureButNoValues, under a doc comment citing this exact name — but over a hand-built model, and by forbidden word. Run that same word list against the manifestsamples/ecommerceactually emits and it fails: the manifest correctly lists a[Sensitive]member namedPaymentToken, and the list forbidstoken. Naming a sensitive field is the manifest doing its job; carrying a value is the leak. The architecture gate therefore scans emitted manifests for the shape of a secret — PEM blocks, JWTs,Password=assignments, credentials in a URL, provider key prefixes. The writer test keeps its own name and its own job.
Built red → green: tests/FlowX.Core.Tests was written and observed failing to
compile (24 errors, types absent) before src/FlowX.Core existed.
-
CapabilityDescriptor— validated identity and SemVer, immutable side effects -
Identifiers— one regex pair for identity and version, source-generated -
PolicyChain— stage ordering (stable), retry-requires-idempotent, no-cache-with-side-effects -
StepNode— factories make an invalid shape unrepresentable -
StepGraph— non-empty, contiguous from zero, no duplicates, copies its input -
FlowDescriptor— positive deadline enforced -
ExecutionPlan— precomputed compensable indices, sorted side-effect set, profile validation -
CompensationStack— strict reverse unwind, drains, rejects double-record -
InvalidFlowPlanException— a defect, not anError(ADR-0007) -
tests/FlowX.Abstractions.Tests— 47 behavioural tests added;Result<T>,Error,PolicySetand the trigger defaults had zero execution coverage before
Exit criterion met: a three-step plan with one compensation is constructible, immutable, and rejects every invariant violation under test.
- WP-3
FlowX.Benchmarks— B1–B3 measurable, baseline committed - WP-4
FlowX.Runtime— step loop, pooled contexts, deadline handling, 0 B - WP-5
FlowX.Compiler—FlowPlanGenerator, model layer separate from emission. Diagnostics all raised (WP-13), B12 measured and passing (WP-14). The branching DSL is complete:When,Switch,Parallel,ForEachandSubFlowall ship (WP-15, WP-20, WP-24, WP-29, WP-33) - WP-6 Manifest emission, deterministic and schema-valid
- WP-7
FlowX.Hosting— DI, startup validation, graceful drain, health probe - [~] WP-8
plugins/FlowX.Http— endpoint, request binding, RFC 7807. Remaining: generated endpoints from[HttpTrigger], OpenAPI - WP-9
FlowX.Cli—flowx graph,flowx manifest - [~] WP-10
samples/ecommerce— 3-step flow end to end. Remaining: ZAP baseline - WP-11 P0 gate — PASS. B1 = 172.3 ns / 5 000 ns, B2 = 0 B. Report at docs/benchmarks/P0.md
- [~] WP-12a
[Sensitive]— read by the compiler, recorded in the manifest, and redacted from Problem Details bodies. The exit criterion named three sinks; only that one exists in this release. The phase names here were wrong and are corrected: the journal is a sink and arrives in P2; logs, traces and replay output arrive with observability in P5, not P3. So P2 creates a sink for sensitive values three phases beforeRedactionCannotBeBypassedcan be written — WP-52 owns not journaling a marked member in the clear. Discharged 2026-07-31: the second sink exists and redacts by construction, so two of the four sinks are covered and the exit criterion's third is still P5 - WP-12
FlowX.Testing—TestCapabilityContextandTestFlowContext; the sample's capability tests lost 27 lines of hand-written stub - WP-13 Diagnostics that were documented and never raised. All four now fire
—
FLOWX1014,FLOWX1018,FLOWX1003,FLOWX1004— each verified against the real sample, not only the harness - WP-14 Budget B12 build overhead — PASS at +0.4 % against +8 %, on a like-for-like build of the sample. Report at B12.md
P0's exit criteria are met. samples/ecommerce runs a 3-step ephemeral flow over
HTTP, B1 and B2 are green, and flowx graph renders it. The ZAP baseline is the one
item outstanding and needs a CI run.
Scope from the roadmap; work packages in PLAN.md §4.
The three exit criteria, each re-run rather than remembered:
| Criterion | Verdict |
|---|---|
| generator allocates ≤ 800,000 bytes per flow and ≤ 160,000 per capability | PASS — 772,522 and 148,562 at 25 flows, 769,272 and 146,808 at 50, flat across both sizes. check-generator-cost.py fails the run on a breached ceiling. ADR-0014 put this criterion in place of "≤ 8 % overhead" on 2026-08-10; against the old one the answer was FAIL at +67.1 %, and that measurement is still true of what it measured |
every diagnostic passes EveryDiagnosticIsHelpful |
PASS — FlowX.Compiler.Tests.CompilerFitnessTests.EveryDiagnosticIsHelpful, green in this working tree |
| emitted code is breakpoint-able | PASS — FlowPlanGeneratorTests.EachStepGetsItsOwnLineDirective plus five further line-directive tests across the emitter, Fail and step-input mapping, all green |
FlowTestHost shipped at WP-49, closing P0's other unshipped Should after three
documents had described a host that ran flows while FlowX.Testing contained only context
doubles. It runs the real engine over the real compiled plan in-process, with capabilities
substituted by capability id. For<TFlow>() is deliberately not offered: discovering
the generated dispatcher needs reflection over generated members, which constraint C2
forbids, so the documented shape was corrected rather than faked. AwaitSignal and
AwaitCompletion are unhandled, because both need a journal.
Carried into P2, in three named piles — five reserved diagnostics (FLOWX1006,
FLOWX1007–FLOWX1009, FLOWX1012, four of them blocked on severity and not on
analysis), three blocked fitness functions (CrossTenantAccessIsDenied,
RedactionCannotBeBypassed, PluginsPassConformance), and the build-overhead exception —
which is closed as of 2026-08-10, when ADR-0014 replaced the criterion it was an exception to.
The first pile is now empty: WP-58 raised FLOWX1007–FLOWX1009, WP-60 raised
FLOWX1012, and WP-59 raised FLOWX1006 on 2026-08-01 — the one of the five that was
blocked on a payload writer rather than on severity, and therefore the last. The second and
third piles are unchanged, and the reason each is unchanged is in its own row below.
dotnet new flowx has shipped, and this paragraph said otherwise for longer than the
command was actually missing. It read "unshipped since P0 and carried twice, is being
attempted in the current round". It scaffolds, builds at 0 warnings, serves a request,
and returns an RFC 7807 body for a rejected one. What was missing was the gate:
templates/README.md called verify.sh "the acceptance test, and what CI should run", and
CI never ran it — so the template could have rotted silently at any point. A template job
now runs it. docs/19-SDK.md and docs/03 §12 are corrected. See
§5d and PLAN §5.
- WP-15 The branching DSL —
When/Otherwisedone through builder, model, analysis, emission, graph and engine. A conditional compiles into the same flat step array as a linear flow, as aBranchplus aJump, so the engine gained no branch stack and both directions allocate 0 B in Release. The manifest deliberately does not carry the predicate's source text: it would put business thresholds into a file whose rule is structure-only.Parallelfollowed at WP-24,ForEachat WP-29 andSubFlowat WP-33, which closes the shape - WP-20
Switch/Case/Default— a value branch through builder, model, analysis, emission, graph and engine. OneStepKind.Switchcarrying a target per case plus a default, and aJumpclosing each case block, in the same flat step array; the dispatcher gainedint Select(...)returning the matching arm, which is anintand not the value so nothing is boxed. Every arm, the miss and an out-of-range arm allocate 0 B in Release. A value matching nothing in a switch with noDefaultfalls through, exactly asWhenwithoutOtherwisedoes — recorded in08 §3.2and onISwitchBuilder. The manifest carries neither the selector nor the case values, for the same reason WP-15 refused the predicate - WP-24
Parallel/Branch/MergeStrategy— concurrent branches through builder, model, analysis, emission, graph and engine, plusFLOWX1013. The flat array survives: oneStepKind.Parallelcarrying a target per branch and a join, each branch a contiguous sub-range of the same step array, and no closing jumps — a branch's range ends where the next branch begins, so a jump would only restate the bound. Every target still points strictly forward andStepGraphstill rejects one out of range, so termination is unchanged. What is no longer true is that "the loop index" describes execution: between a fork and its join there are several, on several threads, and the engine recurses once per fork into the same range-walking method.MergeStrategybecame a struct soQuorum(n)can carry its number — the documented surface listed four strategies and an enum could name only three of them. All four are implemented.AllSettledpublishes aParallelOutcomeat the join. B2 is unchanged and still a hard zero for the linear, conditional and switch paths; a fork allocates 792 B for three branches, 552 B for two — about 240 B per branch — recorded as a ceiling, with a second test pinning that the cost tracks branches and not steps. Concurrent context writes are made safe by a lock taken only when the plan contains a fork (ExecutionPlan.HasParallel), not by a concurrent collection, which would have cost every linear flow an allocation per write. Cancelled siblings' completed work is still compensated, and every branch is drained before the fork returns — the context is pooled, so a branch outliving its flow would write into the next tenant's. One caveat found in review, not by the package: the race test that covers those concurrent writes does not fail when the lock is disabled. It was run eight times unguarded — including a variant forcing dictionary resizes — and passed every time. Branches genuinely do overlap (BranchesOverlapWhenTheirStepsActuallyYieldprovesPeakConcurrency > 1), but overlapping is not the same as colliding inside one dictionary operation. The lock stays — concurrentDictionarymutation is unsafe by contract, and a race this hard to provoke reaches production instead of CI — but it is currently guarded by reasoning, not by a test that can fail - WP-29
ForEach— a bounded body re-entered per element, the first shape where one range of the flat array executes more than once. The body appears once and the engine re-enters the span[index+1, Target), so the plan, the manifest and the diagram stay independent of data size. The per-iteration item is not in the state bag:IterationScope<TItem>wraps the enclosing context and shadowsTryGet<T>whenTis the element type, viaUnsafe.Asso a struct element is not boxed per read; writes fall through, and scopes chain so nested loops each resolve their own element. 32 B per element and nothing per step — asserted as a ceiling and as an exact tracks-elements-not-steps test.CompensationStack's duplicate check moved from index to(index, scope): keying on index alone threw on the second element, which would have made the documented per-line compensation example impossible to write.MaxDegreeOfParallelism > 1reuses all ofParallel's machinery and adds only a sliding window; the bound is clamped against a constant rather thanProcessorCount, so a plan does not depend on the machine that built it. The new cost gate fired on this merge at +2.20 % against its +2.0 % threshold, with a 0.01 % spread — real signal. Re-recorded deliberately:IStepDispatchergained two required members, so every flow emits two throwing stubs for iteration it does not use, exactly as it already does forEvaluateandSelect. That is the gate working — a cost increase arriving as a reviewable diff instead of unseen. Worth watching: this is the fifth required member on that interface - WP-33
SubFlow— composition, and the shape where one array stops describing one execution. The flat array itself survives untouched: aStepKind.SubFlowoccupies one index, carries no target and moves nothing around it, so the graph validation and the termination proof for the parent are unchanged. What no longer holds is that the array in front of the loop contains every step that runs — the child has its own plan, dispatcher, pooled context and compensation stack, and the engine recurses into a second, independent execution. Splicing the child's steps in at compile time was rejected: it would make the parent's manifest claim the child's capabilities, discard the child's deadline and profile, and be impossible across an assembly boundary. Two modes of three ship.InlineandDetachedare real;AwaitCompletionis refused under every profile byFLOWX1026— it needs a durable suspension point, and WP-52's journal is not one: a durable flow still runs to completion inside one invocation (suspension is WP-63). UnlikeAwaitSignalit has no honest degenerate form (running it inline changes the parent's deadline and failure semantics; skipping it drops business logic). It is also unrepresentable inStepNode. Compensation crosses the boundary upwards. A child that succeeded is undone when the parent later fails — anything else would meanFLOWX1005's own advice, to extract shared steps into a sub-flow, silently weakened the saga. Strict reverse survives: the parent records the composition as one entry in its own stack, soA · child(X, Y) · BunwindsB, Y, X, A, exactly as the steps would have inline. The child's context stays rented until the parent finishes, because the undo binds to what the child's steps produced.Detachedis drained. It starts inside a step, soFlowHostnever counted it;DrainAsyncnow waits on the engine's detached count as well, because a drain that reported success while a fire-and-forget saga was reserving inventory would leave it reserved. The child never touches the parent's pooled context — the mapping runs on the parent's thread before the child starts — so the cross-tenant hazard is structural rather than guarded. 0 B, and that is not "sub-flows are free". Every part was made pooled or a struct on purpose; the first version walked the compensation stack to find retained children and cost 96 B per nesting level on the success path, which is why the context now owns the list.FLOWX1021proves the DAG for every edge in one compilation and cannot see across an assembly boundary — its page says so, andFlowEngine.MaxSubFlowDepthbounds at run time what it cannot bound at build time. Stated limit: the child's result does not reach the parent's context. Both ways to pass it up were refused for reasons written down in08 §3.7 - A test that can actually fail on the parallel context race. Needs deterministic interleaving, not more iterations. Until then the lock above rests on the language contract alone
- A test that can actually fail on the parallel context race. Closed by WP-34, and
the diagnosis is the useful part: the window was not narrow, it did not exist. The old
test wrote three keys through one reused engine, so from the second execution every
Setwas an overwrite — assigning an already-allocated slot cannot move an entry, relink a bucket or grow an array. Adding more keys did not help either, because the pooledDictionarykeeps its buckets acrossReset(Cleardoes not release capacity), so those became overwrites after iteration 0. That is why the earlier twelve-extra-keys stress attempt also passed unguarded. Now: every branch inserts unseen keys, a fresh engine per iteration keeps them inserts, and branches rendezvous at a spin gate — aBarrierwakes participants microseconds apart, long enough for one branch to finish its whole loop first. Verified by mutation in review: fails 4 of 4 unguarded, passes guarded - WP-16 Step binding —
FLOWX1020raised byStepBindingAnalyzer. A flow whose steps cannot pass values to each other now fails the build. Numbered 1020, not 1022:08-Flow-Definition.mdand bothGet<T>implementations already documented this check under 1020, and 1022 stays reserved for contract compatibility across versions - WP-17
flowx diffv1 — 29 classification rules, text and JSON, exit 1 on a breaking change. Wired into CI against a committed baseline, and verified by flippingIdempotenton the sample's real source - [~] WP-18 + WP-23 Scale — harness delivered, then made trustworthy. The budget
FAILS at +18.4 %, 95 % CI [+16.3, +19.9], against +8 %. WP-18's provisional +23 %
is superseded: it was measured under load average 2–34 with an 85 % within-arm
spread, and the rebuilt methodology (sandwiched arms, an A/A control in the same
rounds, IQR, interleaved sizes) moved the answer five points. Growth is linear —
3 ms fixed + 9.54 ms per flow, R² 0.994 — so the constant is too large rather than
the design being wrong. Cost splits 62 %
FlowPlanGenerator/ 37 %StepBindingAnalyzer/ 1 %CapabilityAnalyzer, which disproves WP-18's guess about where to look. The harness can now return exit 2 = INCONCLUSIVE and did so on its own first run rather than publishing a number its error bars swallowed. Box stays open: the budget is not met - ⚠ REGRESSION — the generator has tripled since that measurement.
FlowPlanGeneratorwas 8.07 ms per flow ata75c1f0; it is now ≈ 29. Measured twice independently — 28.7 ms/flow at 200 flows by WP-27, +29.70 ms/flow at 50 flows in review on a quiet machine (load 2.52), verdict FAIL — superseded by WP-43's +67.1 % at 200 flows and +46.5 % at 50, see below against the +8 % budget. The other two components reproduced to within 7 %, which is what makes the third reading believable. It slipped in because the scale job is advisory — correct while the measurement could not beat the noise, but the cost is now concrete: a 3× regression merged across four packages in silence. The job cannot just be made blocking while the criterion fails, so this wanted a relative gate against the committed figure instead of an absolute one against the budget — built at WP-31, and it catches this one at fifty times its threshold. Bisected at WP-28 to one commit:c7ae70a, WP-22's error-catalogue emission, 5.60 → 27.28 ms/flow (×4.9).Switch,Paralleland the sort-key fix cost nothing detectable;FLOWX1011never appeared in the generator's number, being an analyzer. Stubbing the catalogue read returnsdevto 7.36 — the control. Of the reader's cost, 91 % wasGetTypeInfo, and half of those binds were a duplicate question asked once in aDescendantNodespredicate and again in theWhereafter it. Fixed: 25.4 → 20.4 ms/flow, faster in 6 of 6 paired rounds, manifest byte-identical. The remaining ~80 % is what the feature costs — derivingerrorsfrom code binds every capability body, so generator cost now tracks how much capability implementation exists rather than how many flows do. Whether that catalogue is worth two thirds of the compile-time budget is a product decision - WP-31 A relative cost gate, blocking, on a deterministic proxy — bytes
allocated by one
RunGeneratorsAndUpdateCompilationcall, against a committed baseline, threshold +2 %. Validated againstc7ae70a: FAIL at +102 %, fifty times the threshold, on the commit that caused the incident. Wall clock provably cannot do this: gating the same probe on elapsed time, a no-op commit produces a false signal of up to +166 % while the real 4.9× regression produces +39 – 77 % — a threshold wide enough not to fire on nothing is 2–4× too wide to fire on the incident, and more rounds cannot fix a signal smaller than its noise. Confirmed in review under load average 38.6, where the gated metric moved +0.01 %. The absolute criterion is reprinted asABSOLUTE CRITERION — FAILon every run, passing ones included, so a green relative gate cannot be read as a met budget - WP-27 cut
StepBindingAnalyzer89 % — 4.70 → 0.53 ms per flow — by binding a step's type argument outside theDefinebody. 96 % of its cost was oneGetSymbolInfocall: a node inside a statement cannot be bound without binding the whole chain, so the first.Step<T>()of a flow cost 4.13 ms and every later one 0.06 ms. All 800 resolved symbols identical before and after. It does not close the criterion and is not claimed to — end to end the difference sits inside the noise. What it buys is the IDE, where the rule re-runs per keystroke - [~] WP-43 Re-measured P1's exit criterion. FAIL at +67.1 % [+61.9, +73.6] at 200
flows and +46.5 % at 50, against +8 %, on a quiet machine with a 10.6 % A/A
noise floor. The first attempt returned INCONCLUSIVE under load 6.8–39, with the
A/A control reporting ±77 % and identical builds swinging four-fold — the exit code
firing legitimately, and no verdict manufactured from it.
The attribution has moved decisively:
FlowPlanGeneratoris 90.5 % of the marginal cost;StepBindingAnalyzer, once 37 %, is 1.2 % — WP-27's 89 % cut is visible in the split and invisible in the criterion, exactly as predicted. The number that ends the profiling conversation: optimising all eight other components perfectly still leaves the criterion failing by 54 points. Reaching +8 % needs the generator at ~2.8 ms/flow against today's 23.25 — an ~8× cut, where the bulk is what the derived error catalogue costs. That is ADR-0014's question. Honest about attribution: 50 flows reproduces the previous figure to 0.1 points across a dozen merged changes, so the saving and the features cancelled; the 200-flow improvement was not bisected, and the earlier runs were on a 2.80 GHz CPU where this container reports 2.10. Box stays[~], and it stays[~]permanently. The phase closed over this on 2026-07-31 by decision, not by measurement changing. Ticking it because P1 is closed would make this file say the budget is met, which is the one thing it must not say. Risk R1's trigger has fired and its named action — freeze features, invest in the generator's model layer — was not taken; what was done instead is WP-31's relative gate, which stops it worsening and does not close it - WP-19 IDE code fixes —
FLOWX1001,FLOWX1010,FLOWX1017, in a separateFlowX.Compiler.CodeFixesassembly so the analyzer never drags Workspaces into a consumer's build.FLOWX1010deliberately withholdsPublic
Already satisfied from P0, per the roadmap's P1 list: diagnostics with help URIs (WP-13), generator snapshot tests (WP-5), readable and breakpoint-able emitted code (WP-10), and budget B12 (WP-14).
Gaps WP-15 surfaced, listed here rather than left in a commit message — each is a place where something is documented, reserved or parseable but not actually enforced, which is the exact failure mode P1 exists to remove:
-
FLOWX1011— predicate purity. Closed by WP-21:PredicatePurityAnalyzerraises it on aWhenpredicate that reads a clock, ambient randomness, the environment, mutable static state, a captured variable or flow instance state. An error inDurableflows and a warning inEphemeralones, rather than the Info that ADR-0003 and06§5 originally specified — Info is invisible in a build log andEphemeralis the only profile that runs today, so it would have shipped a rule that does nothing anywhere. Scope is decided by proof; impure statics are a list; nothing is interprocedural, and the page says so. Scope wasWhenonly until WP-25 widened it to every context delegate — see below -
.Step<TCapability, TStepIn>(map)is parsed and then ignored byFlowAnalyzerandFlowEmitter. It is on the builder surface andFLOWX1020recommends it as the fix for a binding failure, so a user following the diagnostic reaches an overload that silently does nothing. Worse than not existing. Closed by WP-41. The mapping is modelled, emitted as a cached staticFunc<FlowContext<TIn>, TStepIn>and called at the step; its result is the step's input and is not written into the state bag — the bag is keyed ontypeof(T)and a mapping exists precisely because nothing put aTStepInthere, so storing one would invent a producerFLOWX1020cannot see and would make two mapped steps of the same contract overwrite each other. A compensation on a mapped step re-runs the mapping, which is sound because it is pure byFLOWX1011and is the same guarantee an unmapped compensation has. 0 B on the mapped path.FLOWX1020's silence on this overload is now justified rather than self-defeating, andFLOWX1028was added because C# constrainsTStepInto nothing — a mapping the capability cannot accept used to be aCS1503inside generated source - Triggers and capability
errorsare in the manifest schema and never emitted. Closed by WP-22. Both are emitted, under a three-state rule: a resolved catalogue, a resolved-and-empty one ([]— "declares no errors"), or withheld entirely when it could not be resolved. A catalogue short by one entry reads exactly like a complete one, so an unresolvable case has to be visibly absent rather than quietly approximated - ⚠ The three-state rule has a fourth state nobody designed: positively wrong.
Found by WP-36. The rule above assumes the reader either resolves a catalogue or
knows it could not. There is a third outcome: it finds nothing, finds nothing it
could not follow, and publishes
errors: []— which the schema defines as the positive claim "this capability returns no declared error". It happens whenever a failure stays insideResult<T>for its whole journey and never takes the shape of anError, and it is reachable through the first-partyResult.Fail<T>(code, message, category)overload — whose siblingFail<T>(Error)resolves correctly. Same intent, one publishes the truth and the other a confident falsehood. Also hit by a one-line capability delegating to a service that returnsResult<T>, which is a very common shape.flowx difftreats the field as authoritative, so a wrong catalogue is worse than a slow build — and it contradicts ADR-0014's stated premise that a derived list cannot be wrong where a declared one can. Fixing it moves the measured corpus from 39 % to 47 % withheld: correctness costs coverage, and someone has to choose -
07-Capability-Model§4 prescribes a layout the reader cannot follow. It mandates the static error class in the same breath as putting contracts in a dedicated assembly — across an assembly boundary, which is exactly where the scan stops. A team following the documentation exactly gets no catalogue at all.samples/ecommercemisses this only because it is a single project
Closed this round, and one opened.
- The false-complete catalogue. Fixed by WP-37 by changing the question: the scan
roots at
ICapability<,>.ExecuteAsyncand follows the value, andResult.Okis the only expression that entitles a capability toerrors: []. Anything the resolver does not understand marks the catalogue incomplete — that is the safety property. Reachability came free. Better than B13 forecast: wrong 4 → 0, correct 47 % → 55 %, withheld only 39 % → 42 %, because two under-reports became correct catalogues rather than withholds. The load-bearing assumption is now written where it can be checked:Result<T>is enterable only fromTand fromError -
.Fail(Error)parsed and ignored — fixed by WP-38. It compiles to aStepKind.Failwhose dispatcher returnsStepOutcome.Failed, so the engine needed no change. Completed compensable steps unwind, because the documented workaround while it was unimplemented was to spell the rejection as a capability returningResult.Fail, and a feature that replaced that workaround without unwinding would silently weaken every saga that took the advice -
ctx.Inputdid not compile — fixed by WP-38, and the root cause was deeper than the emitter:FlowContext<TIn>was an abstract class nothing derived from. There was no such object at run time and there could not be, since the pooled context is shared by every flow. It is now areadonly structview over whatever context is current, which is what makes it work inside aForEachbody and a sub-flow where a cast would have thrown. One reference wide, so B2 stays a hard zero - ⚠
FlowX.Runtimenever readsExecutionProfile— closed 2026-07-31 by WP-52. The runtime reads the profile; aDurableflow journals one row per(instance, scope, step, attempt)and resumes through the same step loop;ExecutionProfileHonestyTestswas observed failing and is deleted;FLOWX1028is narrowed toStreaming; the warning boxes in06 §4,06 §5,11, ADR-0003 and ADR-0006 are corrected at WP-54. Risk R2 went from unreachable to live and unmitigated — its analyzers (WP-58) and its replay test (WP-61) did not land with it. What this box did not buy: no lease is acquired, no recovery scan exists, no store implementsIFlowJournaloutside an in-memory reference, and aDurableflow with no journal wired is now refused rather than silently run ephemerally. The original entry follows, unedited. Found by WP-40 while auditing risk R2. ADurableflow runs the ephemeral path; the profile affects only a plan validation and a manifest field. So R2 is not mitigated — it is unreachable, and it goes live the moment P2 lands. This also explains why several determinism diagnostics are blocked on severity rather than analysis. Still open, and deliberately: WP-42 made it loud, not fixed. Durability is a phase, not a package. What shipped isFLOWX1028— a warning on any flow declaring a profile the runtime does not implement — so the platform no longer accepts a declaration it does not honour in silence. Warning rather than error because the only repair for an error isProfile = Ephemeral, which deletes the design decision P2 must find, and becauseFLOWX1017is an error on the opposite condition: two errors would leave a suspending flow with no profile it could legally declare. The manifest still publishes"profile": "Durable", which is the declaration faithfully recorded; the untruth was the silence around it, not the field.RuntimeDoesNotReadTheExecutionProfileinFlowX.Architecture.Testsfails on the day the runtime reads a profile, so the scaffold gets taken down rather than left to rot. This box is ticked by P2 — specifically by WP-52, whose design is ADR-0015 and whose exit criterion is that every row of that record's take-down table is discharged in the same package: the fitness test deleted rather than skipped,FLOWX1028narrowed toStreaming, and the warning boxes in06 §4,06 §5,11, ADR-0003 and ADR-0006 corrected. The same package unblocks four of the five reserved diagnostics, which is an argument for landing the seam early in P2 rather than after the store adapters - The cost gate measured a subject that did not compile — the probe parsed a project
with
ImplicitUsings=enablewithout supplying them, soValueTaskand friends never bound. Harmless for relative comparisons of syntax-matching code, which is why it still caught the 4.9× regression; not harmless for anything that resolves a signature. Fixed and the baseline re-recorded. Left open as a reminder: a benchmark's subject needs a gate of its own, and this one had none
What building dotnet new flowx found out about the platform. A template is the
first thing a new user runs, so it is also the cheapest test of our own ergonomics.
Eleven findings; these are the ones that are ours to fix:
- The repository could not produce its own packages.
dotnet packfailed on both analyzer projects withNU5017—IncludeSymbolsis set repo-wide, those two ship only an analyzer asset, so the symbols package has no content and pack exits 1 after writing the.nupkgcorrectly. Nothing here ranpackuntil a template needed a local feed, so it would have gone undiscovered until the first release. Fixed; the solution now packs nine packages, exit 0 -
Program.csrestates the flow — the highest-value missing piece.AddFlowXregisters no transport and generates no endpoint, so a generated project hand-writes the method and route[HttpTrigger]already declares, plusPlan,Dispatcher,Projection,SensitiveMembersand twoJsonTypeInfos. Ten lines every consumer writes identically for every flow, and the one place a generated project can silently drift from its own flow.19-SDK §6's own standard is that ceremony every user pays is a platform defect -
FLOWX1028's Warning is defeated by warnings-as-errors. Its page argues at length that an Error would be actively harmful — and this repository mandatesTreatWarningsAsErrors, so any consumer at the platform's own bar who declaresDurablegets a hard error anyway. Same shape forFLOWX1024. Either they becomeInfoor the platform publishes aWarningsNotAsErrorsrecommendation -
EmitCompilerGeneratedFilesis documented as on by default and is not. It is on only via this repo'sDirectory.Build.props, so a consumer writing their own.csprojsilently loses the ADR-0002 / risk-R1 mitigation. It belongs inFlowX.Compiler.props, which every package consumer imports -
FLOWX1010fires second and in the wrong file. OmittingAuthorizationproducesCS9035first, with no help link;FLOWX1010then fires at the flow's.Step<T>()type argument rather than at the[Capability]attribute, so its documented quick action is offered somewhere other than the edit it describes - No fitness function sees
templates/.SourceSurvey.ShippingTreescoverssrc,pluginsandsamples. The stated reason for including samples — a permissive declaration there is one people copy — applies harder to a template, which is copied by definition
Gaps WP-20 and WP-22 surfaced in turn. Same class again — declared, documented or reachable, and enforced or honoured by nothing:
-
.Fail(Error)was parsed and then ignored. It was onIFlowBuilder, it was in the08 §4method table, andFlowAnalyzerdid not model it — so a block whose only call was.Fail(...)compiled to an empty block, and08 §3.2's ownSwitchexample, which uses.Default(b => b.Fail(...))to reject an unsupported channel, fell through and accepted it. Closed by WP-38.Failis a terminal step: the error is astatic readonly Errorin the generated dispatcher, delivered through the sameExecuteAsynca declined payment comes back on, so the completed compensable steps unwind exactly as they would on a capability failure — anything else would mean the arm that rejects a request leaves the stock it reserved. The error's value never reachesflowx.manifest.json, which publishes"kind": "Fail"and nothing more; steps written after one are unreachable and reported asFLOWX1027 -
FLOWX1013— parallel branch disjointness. Closed by WP-24:ParallelSlotAnalyzerreports two branches of a fork whose capabilities declare the same output contract, which is the slot the generated dispatcher writes. Its limit is stated on the page and worth repeating here, because a green build reads as a proof and is not one: it compares declared contracts, so a capability callingctx.Set<T>()from inside its own body writes a slot the rule never sees -
FLOWX1011did not coverSwitchselectors. Closed by WP-25. The analyzer is now driven by a table of everyIFlowBuildermethod taking aFunc<FlowContext<TIn>, …>—When,Switch,ForEach,Return,Emit,EmitOnFailure,Step<TCapability, TStepIn>andSubFlow— and the message names the construct it found, because aReturnprojection reported as "the condition" is a diagnostic a reader stops believing. The next DSL shape is a row in that table rather than a second code path, which is the mistake WP-21 made once and this package exists to undo. Three of the eight are not yet executed by the emitter and are checked anyway; the page says which - An unrecognised
TriggerAttributesubclass is skipped in silence. A trigger'sKindis an overridden property — executable code, not attribute data — so a third-party transport plugin's trigger cannot be read from metadata. WP-22 declined to guess, which is right, but the skip produced only an absenttriggersarray — whichflowx diffcannot tell apart from a flow that declares no trigger, so the gate that calls a removed trigger breaking lost its input without saying so. Closed byFLOWX1025(WP-26), a warning: the manifest still refuses to guess, and the refusal is now audible.What remains open is the cause — the abstractions give a plugin author no way to declare a kind the compiler can read, so the only fix offered is "use a built-in attribute instead"The cause is now closed.[TriggerKind(TriggerKind.Bus)]carries the kind as an enum constructor argument, which survives to metadata where an overridden property does not, andTriggerReaderwalks the base chain because Roslyn does not honourInherited = trueforISymbol.GetAttributes. Severity follows who can apply the fix: Error when the attribute is declared in the compilation being built, Warning when it arrives as a reference. Pulled forward from P3's WP-70 because it is an abstraction change and three plugins were about to be written against the old shape. One hole is stated rather than buried: for an attribute arriving from metadata, nothing checks that the marker and theKindproperty agree — readingKindmeans running a getter, and a generator does not run what it compiles. The rest of WP-70 —TriggerSourceConformance,ITriggerSource, the published package — has not shipped, andPluginsPassConformanceis still blocked (§4). - Nothing in the repository had ever compiled generator output. The generator
harness discarded the updated compilation, so every test asserted against parsed
text — which catches a syntax error but not an unresolved name, a wrong delegate
type argument, or an unimplemented interface member. Fixed by WP-20's
GeneratedCompileErrorsIn, and a real compile is now asserted -
ctx.Inputdid not compile in any predicate or projection. Found by WP-25, confirmed against a real build, closed by WP-38. The DSL signature isFunc<FlowContext<TIn>, …>, butFlowEmitterwrote every emitted delegate asFunc<FlowContext, …>— the non-generic base, on whichInputis not declared — so the lambda source was copied into a field whose parameter type had lost the member and the build failed with CS1061, in generated code, against three documents that all said it worked. The cause was thatFlowContext<TIn>was an abstract class nothing anywhere derived from: the engine's context is pooled and shared by every flow, so no such object could exist. It is now areadonly structview over anyFlowContext, which costs a register, allocates nothing, and works inside aForEachbody and a sub-flow where a cast would have thrown
- Four capabilities, one flow, one endpoint —
dotnet runserves it - The endpoint returns the flow's declared output, projected from its
.Return(...)clause, not a step count - 28 tests: 10 capability tests with no host in the file (quality goal Q2), 8 over a real server including the compensation path, 8 on the adapters, 2 on the wire contract
- NativeAOT publish, ~11 MB, 0 trim/AOT warnings — and CI runs the binary, because linking and serving a request are different facts
-
flowx manifest→flowx graphrenders the sample's real manifest;mmdcvalidates the output - ZAP baseline scan — the
securityworkflow needs a CI run against the sample
Exit criterion met, bar the ZAP scan, which cannot run in this working tree.
The sample was the first code written against the platform from outside it. Six defects that no test inside the platform could have caught:
| Found | Was |
|---|---|
Result<T> had no implicit conversions |
The documented capability style did not compile |
Every #line directive pointed at one line |
A breakpoint on step three landed on step one |
.Return(...) silently dropped |
The flow declared an output nothing produced |
MapFlow never read a request body |
A flow's first step had nothing to bind to |
AddFlowX registered the health-check type, not the check |
MapHealthChecks threw at startup |
| The manifest embedded an absolute path | Broke ADR-0005 determinism; leaked the agent's layout |
Three more surfaced while getting the suite green:
| Found | Was |
|---|---|
.Emit<T>() publishes nothing |
Silent. Now FLOWX1024, the first warning in the set — since joined by FLOWX1011 and FLOWX1025 |
| Allocation budgets measured 376 B in Debug | Compiler scaffolding, not the engine. CI runs Release and never saw it; every contributor did. Now skipped in Debug, with the reason |
The walker read ArgumentNullException.ThrowIfNull(flow) as a chain |
A statement after the chain would have silently replaced it. The walk is now rooted at the builder parameter |
5d. P2 · Durable execution — nearly complete; QR2 and B8 measured on demand, B7 measurable but not judged
This heading said "B7 and B8 not at all" until 2026-08-14, and half of it stopped being true that day.
tests/FlowX.Durability.Benchnow prices both against a real PostgreSQL, andscripts/check-durability-latency.pyjudges the run — B7-B8-durability.md is the record.B8 is MET: p99 3.634 ms against a budget of 8 ms, over 2 000 rehydrations at history depth 20, measuring
DurableExecution.ResumeAsync— the fence and the frontier read, which is the verb a recovering node actually calls.B7 is not judged, and that is a third state rather than a failure. The budget is
15 ms @ 5 000 commits/s/node, and a rate-qualified budget cannot be met by a machine that cannot offer the rate: the recorded four-core run saturates at 4 103 commits/s, so its latency describes a queue. Store-side p99 at that rate was 14.146 ms — inside the ceiling, and not a pass. Judging B7 needs a run on hardware that can offer 5 000/s, and PLAN open item 23 carries it.The verdict logic is merge-gated even though the measurement is not:
durability-self-testinperformance.ymlruns nine fabricated verdicts on every pull request, two of them green, so the one suppression in the checker — a B7 latency breach downgraded when the rate was missed — cannot be widened without a job going red.
Work packages in PLAN.md §5; the design they are held to is ADR-0015, Accepted at WP-53 and amended by ADR-0016. It is listed in full because P1 handed each item over with a named blocker, and an inventory that exists only in a closing summary is one nobody reads.
Four defects that only real samples could find, fixed 2026-07-31. Building
samples/bankingandsamples/workflowexposed what unit-testing the compiler had not:ctx.CapabilityIdnamed the step being undone during an unwind, so a compensator keyed on it produced the forward step's key, the store deduped the contra entry away, and the engine reportedSucceededover money that never came back;OnTimeout,DelayandAwaitSignalcompiled to nothing with zero diagnostics, the last of them publishing a hardcoded one-hour timeout in place of whatever the author declared;.WithPolicynever reached the plan, so WP-57'sCompensationRetryshipped unreachable; andFLOWX1014never checked the compensation side at all, which the third fix turned from dormant into live. Each fix made the next defect reachable rather than creating it — the holes were always there and nothing could get to them.P2's Must has one package nothing has been written for, three that shipped in part, and its correctness clauses are measured while its budgets are not. Of the thirteen numbered Must packages, twelve have shipped at least in part; WP-62 is the one with nothing, and WP-50, WP-53 and WP-57 are the three carrying a named gap. This paragraph read "two packages … eleven have shipped … WP-59 and WP-62 are the two with nothing" until WP-59 merged on 2026-08-01. What is left is not a compiler package: WP-62's deliverable is a nightly schedule. It also said "of the twelve Must packages, ten have shipped" — the count was twelve when WP-50..WP-62 is thirteen — and that WP-50 has not started, "so B7, B8 and the chaos rig do not exist, which is why WP-62, P2's own Done-when, cannot run: nothing kills a node, nothing crosses a process boundary, nothing has executed ten thousand of anything". All four of those sentences expired on 2026-08-01, and one of them was wrong before it did: the chaos rig exists, kills operating-system processes with
SIGKILL, and has executed 10 000 flows in each of two arms. B7 and B8 still do not exist. And WP-62 was never blocked on the rig alone — its deliverable is the chaos run in CI, nightly, which nothing is, so "cannot run" has become "is not scheduled". A phase that is functionally complete, whose correctness is measured by a rig no job runs and whose two latency budgets are measured by nothing at all, is a specific state worth naming rather than averaging away.Where durability actually is, in one paragraph, because the rest of this file depends on it.
FlowX.RuntimereadsExecutionProfile(WP-52). ADurableflow commits one journal row per(instance, scope, step, attempt), capturesctx.UtcNow,ctx.NewId()andRandom's seed per step, gives a composed sub-flow its own instance row, and resumes by replaying its committed rows into the same step loop. A host acquires a lease, renews it in the background at TTL/3, and runs a recovery scan that claims instances whose lease expired (WP-55); a node that loses its lease stops without compensating, because the work belongs to another node now.plugins/FlowX.Postgresis a real store (WP-53), and the conformance suite passes against PostgreSQL 16.13 unmodified, from a different assembly. All of that has now been made to happen across a real process boundary underSIGKILL— WP-50's rig runs the shippedFlowRecoveryScanin real recovery-node processes against workers the kernel destroys, and nothing in that path is rig-specific. Since WP-59 the resumed steps can also do something, which is the half this section recorded as absent: the generated dispatcher journals a state bag at every step boundary and restores it before the first resumed step, so a node taking an instance over re-enters holding the values its earlier steps produced. Until 2026-08-01 the dispatcher described onlyEmitsteps,flow_instance.state_bagstayed null,RestoreStatewas never called, and the first step past the frontier that bound an earlier step's output failed — so "resumes on another node" was true of the loop and not of a flow.ResumeTestswas written to assert that and now asserts the opposite.AwaitSignalis not built (WP-63), and neither durability budget has been measured. This sentence also named the outbox and Redis as not built; WP-56 and WP-54 shipped on 2026-07-31 and the clause was carried a day too long. A reader must not conclude durability works end to end — but "nothing has run against a store", which this section said until 2026-07-31, "nothing has killed a process", which it said until 2026-08-01, and "a resumed instance re-enters with an empty bag", which it said until later the same day, are no longer among the reasons why.
ADR-0015 is Accepted, and the caveat matters more than the status. Its condition was that the conformance suite hold a real implementation to the schema — not the in-memory dictionary that could not disagree with a transaction boundary, an index, a unique constraint or a migration. WP-53 supplied one, and all five Decision commitments held. Three clauses did not:
jsonbreorders object keys and so breaks commitment 5 outright (payload columns arejson);flow_lease.instance_idasPK,FKis inverted in time, because the lease is taken before the instance row exists; and the state-bag snapshot ADR-0015 names as B8's mitigation had no column saying which commit it came from. Two of the three were in an ERD ADR-0015 already declared superseded; the third was the record contradicting itself. B7 and B8 are unreported rather than passed — this said "WP-50 has not started"; WP-50 has since shipped its chaos rig and neither benchmark — so a journal has been made correct without being made fast, and that is what Accepted does and does not mean here.
Roadmap Must:
- [~] WP-50 B7, B8 and the QR2 chaos rig — one of the three, shipped 2026-08-01.
[~]is the whole of this row: the rig is built; B7 and B8 are not, and this line must never be read as the package being done. It read[ ]and "JournalBenchmarksdoes not exist and neither does a chaos rig" until 2026-08-01; the second half expired, the first did not. What the rig is.tests/FlowX.Chaosis anExe, not a test project, so the ordinary suite is unchanged by it. Its coordinator spawns worker and recovery-node processes (Process.Start) against a shared PostgreSQL; a worker kills itself —Process.Kill(), which on Unix iskill(2)withSIGKILL— at an instruction chosen so a step's effect has happened and the engine's commit has not. On Windows it throwsPlatformNotSupportedExceptionrather than substituting a graceful stop, because a rig that quietly stops instead of dying is the failure this package exists to prevent. The recovery half is the shippedFlowHost+FlowRecoveryScanover the real adapters: if this loop recovers an instance, production code recovered it. Three-way gating, the same discipline as the Postgres suite.scripts/run-chaos-qr2.shis opt-in onFLOWX_CHAOS: unset → skip with a reason, exit 0; set with noFLOWX_POSTGRES_CONNECTION→ fail, exit 1, because a skip there would report a chaos run that never happened as a green job.scripts/check-chaos-qr2.pyreturns INCONCLUSIVE (exit 2), never PASS, when nothing was killed, when no worker exited 137, when nothing was recovered, or when an arm ran fewer flows than it registered. This is a rig you run, not a gate that runs: no CI job invokes either script, so everything it measured is measured and none of it is enforced. Scheduling it is WP-62 and is not started. Two deviations from PLAN's rows for this package. Itsscripts/chaos-qr2.shisscripts/run-chaos-qr2.shplus a Python checker. And the ephemeral floor was never built: the Tests-first row asked for a run against the ephemeral engine reporting 10 000 lost instances, the rig has no ephemeral mode and its flow is declaredDurable, and the INCONCLUSIVE conditions above are what stands in its place — they prove the rig killed something, not that it can see a loss. The ordering lesson still stands and now has a second example. WP-51 landed first, so the baseline WP-53 was to be judged against still does not exist and will be written by someone who already knows what the journal looks like; WP-52 landed too, and so in the end did the rig — after the thing it measures rather than before it. The entry this package was to remove first —JournalBenchmarksin14 §8— is now the last one left, and its stated reason ("there is no journal") stopped being true two packages ago. (The attribution-guard and DAST repairs were credited to WP-50 indocs/21; they were not this package's work and the credit is withdrawn.) - [~] QR2's clauses, measured by that rig and enforced by nothing.
[~]because a measurement is not a gate and one row below is an open question, not a pass. Read fromdocs/benchmarks/QR2-chaos.json, recorded in QR2-chaos.md. Two arms,before-commitandafter-commit, 10 000 flows requested each, 97 process kills each, exit codes{"137": 97, "0": 2}and{"137": 97, "0": 3}— every kill observed as a realSIGKILLfrom outside. Zero in both arms: lost instances, duplicates against the guarantee, orphan effects, duplicates between live workers, instances resumed by more than one node. Not zero, and not a defect: 260 and 186 duplicate applications, every one of them inside ADR-0006's documented window — the exposure that record stated in advance, not a breach of it. Isolated at concurrency 1 (QR2-chaos-isolated.json), killing after the effect and before the commit gave exactly 20 duplicates from 20 kills, and killing after the commit gave 0 from 20. Not zero, and an open question: 61 and 57 instances were claimed by a worker killed before it opened a journal row. Nothing durable existed to lose and no effect was applied, so they are not lost instances — but the rig asked for those flows and they did not run, and closing that window belongs to trigger redelivery rather than to the journal. This is not a passing row. Resume p99 32.860131 s and 32.568505 s against QR2's 45 s (p50 30.698786 and 30.778241). Measured and reported, not gated — see the budget table in §6 - WP-51
IFlowJournal,ILeaseStoreand the shared conformance suite ADR-0006 promises. Shipped, with two deviations from its own row, both recorded rather than absorbed. The contracts are insrc/FlowX.Abstractions/Durability/— where ADR-0009 requires them, and not wheredocs/05 §5.3drew them; that document has been corrected.FenceAsyncwas added toIFlowJournalto close a gap ADR-0015 left: the ADR never says when the fence rises, and a journal that learned tokens only from writes accepts a stale token during the window between acquisition and the new owner's first commit. Pinned byJournalConformance.TheFenceRisesOnAcquisitionNotOnTheFirstWrite; the ADR should be amended when next opened. The suite is two of six and is not packable.TriggerSourceConformance,PublisherConformance,SerializerConformanceandPolicyHandlerConformanceare unwritten. The shape exists and is proved to reject a wrong store by name (TheSuiteRejectsAStoreThatIsWrongTests); nothing real has met it. It packs at WP-53/WP-54, when a second and third store exist to push back on it - WP-52 The seam —
FlowX.RuntimereadsExecutionProfile. Shipped 2026-07-31.RuntimeDoesNotReadTheExecutionProfilewas observed failing andExecutionProfileHonestyTests.csis deleted, not skipped;FLOWX1028is narrowed toStreaming. B2 re-measured at 0 B on the ephemeral path, andDurablecosts 192 B per step, recorded as a ceiling. A[Sensitive]member reaches the journal redacted, proved by reading all six stored strings back — structural, not remembered, becauseJournalPayload.ToJson()is the only exit. Three limits recorded rather than absorbed: non-determinism attribution inside aParallelis best-effort (one pooled context is shared by the branches, so a sibling's id can land on the wrong row — harmless while nothing replays a capture; WP-61 needs a per-branch context); a skipped sub-flow's compensations are not rebuilt on resume, because the parent's entry binds to the child's context and that died with the node (WP-57); and ADR-0015 was amended in two places its own first implementation found wrong. The take-down list is worked row by row in WP-54's commits - [~] WP-53 Postgres journal + lease adapter; B7 and B8 reported with an explicit
verdict. The adapter shipped 2026-07-31; the verdict did not, and the row stays
[~]for exactly that half.plugins/FlowX.PostgresimplementsIFlowJournal,ILeaseStore, migrations and retention, depending onFlowX.Abstractionsand nothing else. The WP-51 suite was inherited unmodified from a different assembly — the arrangement17 §5describes for a third party claiming conformance — and 45 conformance assertions plus 18 adapter tests are green against PostgreSQL 16.13. Three ADR-0015 clauses failed contact and are amended in ADR-0016: payload columns arejsonbecausejsonbreorders keys and breaks commitment 5;flow_leasecarries no foreign key toflow_instance, because the lease precedes the instance row; andflow_instance.state_bag_sequencewas added in migration0002to give B8's mitigation the position nothing had given it. Four further things the record does not say are documented rather than absorbed —JournalStep.Sequenceneeds an instance-local column, the lease row must beUPDATEd and neverDELETEd or the token counter resets under a returning zombie,duration_msoverflows asintat 24.8 days, andCompleteAsyncon an already-terminal instance is a contract gap the suite does not specify. B7 and B8 are unreported — this said "because WP-50 has not started"; WP-50 shipped its chaos rig and neither benchmark, so the reason has changed and the state has not. The exit criterion asks for an explicit pass or fail and the honest answer is still neither. Skip behaviour is deliberately three-way: no connection string → 113 of 120 skip with a reason; a connection string and no server → 114 failures, 0 skips, because a skip would report the suite green against a database never reached; and three always-on tests gate the skip logic itself. Those two counts read "59 failures, 0 skips" until 2026-08-01; the suite grew and the probe was not re-run. Both figures here were re-measured. Three gaps found after the merge, by reading the adapter against the documents rather than by a test. One is closed, two stand. Closed: the adapter implemented noIRecoveryIndex, so a Postgres-backed host resolved the scan's query tonulland silently swept nothing — it fenced correctly and picked up no dead node's work, which made P2's Done-when unreachable.PostgresRecoveryIndexcloses it; see the entry below. Standing:state_bag_sequenceis written and never read — the frontier query isWHERE instance_id = @instance ORDER BY sequencewith no lower bound, so B8's mitigation is a column and not yet a shorter scan; and the deliverable row's group-commit batching andtenant_idpartition key are not built, which is defensible only because B7 and B8 are the sole rational basis for either and WP-50 shipped its rig without them — the rig measures resume latency after a kill, which neither optimisation is aimed at -
IRecoveryIndexfor Postgres — the class that connects WP-53's store to WP-55's scan. Recorded as its own line because it belongs to neither package: both shipped complete against their own exit criteria, and the gap was between them. A separate class rather than a second interface onPostgresFlowJournal, because a scan is not part of executing an instance and the type every durable write passes through should not carry a member no write uses (ADR-0016 decision 4). Migration0003adds the index the query needs, and0002's was the wrong shape: withstateleading,ORDER BY updated_atinherits no ordering — 1 748 buffers and a top-N sort on 200 000 rows, against 4 with(updated_at)partial.0002's index is left in place, because superseded is not unused. The plan is asserted, not assumed: a test EXPLAINs the statement read from the class rather than transcribed, and fails onSeq ScanorSort. 19 tests, including a real death and recovery over real stores. Two gaps named and not closed: there is noRecoveryIndexConformance, so which states count as abandoned is agreed between the two implementations by reading rather than by an assertion; andAbandonedInstanceQuery.TenantIdis a filter, not a second index, because nothing sets the parameter yet - WP-54 Redis lease store. Shipped 2026-07-31, and the headline is the suite, not
the store.
LeaseStoreConformancewas inherited across an assembly boundary with zero edits — verified against the diff, not the report — and all 13 assertions pass at the suite's own default 200 ms TTL, which PostgreSQL needed an override for and Redis did not use. WP-51 wrote a suite, not a description of PostgreSQL. It is not vacuous either: mutating to the idiomatic Redis lease (SET NX PXplusDELon release) turns three assertions red, because expiry deletes the counter, the next acquisition restarts at 1, and the zombie's token then equals its successor's — ADR-0016's split brain reproduced in a different technology from a different cause. Expiry is therefore a field in a hash, never a key TTL; no path issuesEXPIRE/PEXPIRE/DEL.StepScope.Rootrenders as-, discharging ADR-0015's portability note at the key space — and the fold turned out to be at the client, not the server: Redis keeps''distinct from a missing field, but both arrive as values whoseIsNullOrEmptyagrees. ADR-0019. One operational requirement is load-bearing: the key space must not be under anallkeys-*eviction policy, since eviction is deletion by another name and would silently restore the reset counter - WP-55 Resume: lease acquisition, recovery scan, re-entry into the same step loop.
Shipped 2026-07-31.
DurableLeaseacquires and renews in the background at TTL/3;LeasePolicycarries the TTL and renewal stance.FlowRecoveryScanandFlowRecoveryServicefind instances whose lease has expired and hand each to the sameExecuteAsync, so there is still no second recovery code path to rot. A fenced-out node stops without compensating (CompensationOutcome.Abandoned) — the alternative, compensating work another node now owns, is a double-undo, and the decision is recorded rather than inferred from the code. This discharges the WP-52 consequence that aDurableflow was "rejected at its first invocation unless the caller builds the session": a host wires it now - WP-56 Transactional outbox and publisher. Shipped 2026-07-31 in two halves.
PostgresOutboxPublisherclaims a batch underFOR UPDATE SKIP LOCKEDin staging order, publishes, marks the acknowledged prefix and commits, one pass per transaction. Per-partition_keyordering survives two publishers, whichSKIP LOCKEDalone does not: the claim drops a row whose key has an older pending sibling it did not take. Global ordering is not offered and no setting turns it on. Both hard properties were mutation-checked — moving mark-and-commit before the publish breaks at-least-once, droppingSKIP LOCKEDmakes the second publisher block, dropping the per-key probe lets a newer event overtake. Retention refuses to purge an instance holding an unpublished event, with no age window, because there is no age at which discarding an unsent event is correct. The second half wired.Emitto it. The publisher shipped first and published nothing —FlowEnginenever populatedStepCommit.OutboxandDescribeStepreturned no event, so the table it drained was always empty. That is closed: one line inCommitStepAsync, gated on a plan-levelExecutionPlan.HasEmitcomputed besideHasParallel. B2 stays a measured 0 B on a saga that contains anEmitstep, with the assertion strengthened to be about the outbox rather than beside it; the durable path pays 768 B → 792 B, one array for the one step that emits.[Sensitive]stays structural, not re-implemented:OutboxWritecarries aJournalPayload, so there is no accessor for the value and the only exit isToJson(), which redacts.FLOWX1024is re-scoped rather than retired, and the distinction is the point. It fires on two things a flow author can fix in one line — an ephemeral profile, and no generated context declaring the event — and deliberately not on the one thing still missing, that no broker plugin implementsIEventPublisher. Warning about that on every.Emitwould be a warning the author cannot act on, which is how a rule gets suppressed project-wide.So "reaches a broker" is still met as "reaches a publisher"— a third half landed 2026-08-01 and it reaches a broker.RedisStreamEventPublisher(plugins/FlowX.Redis) publishes each staged event to one Redis stream perpartition_key, which is ADR-0018's decision 3 rather than an implementation of it: a Redis stream is totally ordered, so per-key streams offer per-key order and nothing across keys. One stream for everything would have offered the global order the record refuses. No new project and no new dependency —StackExchange.Rediswas already vetted indocs/DEPENDENCIES.md, andplugins/FlowX.Postgresalready holds five adapters, so the repo's own convention settles "one plugin per technology".FlowX.slnxis unchanged.PublisherConformanceholds two implementations — the plugin and the recording double, promoted from a test fixture to a reference implementation — across 10 assertions, and encodes the absence of global order structurally:BrokerUnderTest.ReadAsynctakes the key it is asking about, so there is no method that reads the whole broker and no assertion about cross-key order can be written. A positive control publishes a batch reversed across keys and passes; reversed within a key it is rejected by name. ADR-0018's revisit condition is met. A defect only a real network could find: aList<OutboxRecord>accepts nulls and a stream entry does not, so a nullPartitionKeythrewArgumentExceptionfrom insideStackExchange.Redis. It is now an omitted field rather than an empty one, so a consumer can still tell "staged with no key" from "staged with the empty key". One double proves nothing about a broker. Not proved end to end in one process: nothing drives a PostgreSQL outbox into the Redis publisher in a single test, because the two adapters gate on separate servers in separate test projects. Every link is held and the seam is one interface with one conformance suite behind it; the limitation is stated indocs/17,docs/11and the ADR rather than left to read as end-to-end - [~] WP-57 Compensation with its own policies. Shipped 2026-07-31, and it resolved
open item 7 — P2 built the slice rather
than moving the item to P4.
PolicySet.CompensationRetryis declared atPolicyStage.Consistency, stage 7, which is where the policy framework already puts compensation registration. It honours ADR-0011 without becoming a policy engine because the only stage it executes is the last — there is no earlier stage it can skip, the forward path still executes zero policies, andPolicyChain's stage sort stays the single ordering mechanism.PolicyChainrefuses a compensation retry on a non-idempotent capability:FLOWX1014's rule applied to the compensating capability, because that is what would run twice. B2 holds at 0 B, gated onExecutionPlan.HasCompensationPoliciesthe wayHasParallelis; the failure path moved 48 B → 56 B against an unchanged 2048 B ceiling. Compensation is now journaled, so a kill mid-unwind resumes rather than repeats.[~]for two named gaps. There is no DSL surface —CompensateWith<T>(PolicySet)needs generator work, so no authored flow can declare one yet and the feature is real but unreachable from user code. And a resumed parent still does not rebuild a skipped child's compensation stack: it needs a which-instances-are-under-this-parent queryIFlowJournaldeliberately does not answer — the same reasonIRecoveryIndexwas split out. That gap is now pinned by a test that fails when it is fixed - WP-58
FLOWX1007–FLOWX1009, with the determinism severity stance re-decided as a set,FLOWX1011's deliberate deviation included. Shipped 2026-07-31.DeterminismAnalyzerraises all three: ambient time (1007), ambient identifiers and randomness (1008), and mutable state at the declaration site (1009). The stance was re-decided rather than inherited, andInfowas rejected outright — Info never reaches a build log andEphemeralis the default profile, so the informational severity ADR-0003 originally specified is precisely what kept all four ids unraised through two phases. They ship Warning by default, and Error where the compilation can prove the code is on a durable flow's replay path; a capability has no profile of its own, so escalation is a proof — aDurableflow in this compilation naming it as a step — rather than a guess.FLOWX1011's deviation stops being an exception and becomes the rule. ADR-0003's bullet is amended to say so - WP-59
FLOWX1006and the journal payload contract. Shipped 2026-08-01, the last P2 Must with nothing written for it. This row read[ ]and "now the only capability rule left unenforced — WP-58 built the other three". What the writer is.FlowEmitteremits, onto aDurableflow's dispatcher only:DescribeStep, which describes every step boundary — what the step produced and the state bag as it then stands —DescribeInput, andRestoreState. None of it is emitted for anEphemeralflow: the contract list comes back empty before a line is written when the profile is notDurable, so B2's hard zero is untouched by the writer existing andAnEphemeralFlowGetsNoWriterAtAllasserts each member's absence by name. (An ephemeral flow that stages an.Emitstill gets WP-56's outboxDescribeStep; that member predates this package.)TryGet, notGet, throughout: a step that succeeded without writing a result journals no result rather than failing a flow inside its own commit.[Sensitive]stayed structural and no second exit was opened, which was this package's real risk and PLAN's own prediction about it. The writer composes no document:StateBaghands namedJournalMembers toJournalPayload.OfState, and composition happens inside the payload, ahead of the oneToJson()that redacts and stamps. Pinned twice — the generated source must contain neitherUtf8JsonWriternorJsonSerializer.Serialize(tests/FlowX.Compiler.Tests/PayloadWriterTests.cs), and a hostileIPayloadSerializerreceives only the redacted, stamped document, never the value (tests/FlowX.Abstractions.Tests/PayloadContractTests.cs).JournalMemberexposes itsNameand no accessor for its value, so composition adds no route to one either.FLOWX1006is anerroruniformly, not the determinism set's Warning-then- escalate: it reports only onDurableflows, so its trigger is the escalation condition the rest of the set has to prove —FLOWX1012's mutual exclusivity read from the other end. It does not block emission: the emitter leaves the undeclarable contract out and the plan still compiles, so one accurate error does not become a page of "does not contain a definition forPlan". Two contexts declaring one contract is the same answer as none, which is the ruleEndpointEmitterandFLOWX1024already follow. It fires on both durable samples and on the Postgres suite's durable fixture, and the fix each time is the one attribute the message names (FLOWX1006).schemaVersionandIPayloadSerializershipped with it, ADR-0008 Decision clauses that were in neither planning file until 2026-07-31. The stamp rides the redaction pass rather than adding a second walk, and versions the envelope — placeholder, name matching, composition, the presence of the stamp — deliberately not the contract. A document whose root is not an object carries no stamp, because a stamp is a member.JsonPayloadSerializer.Defaultis the JSON case of the seam.flow_instance.inputstops being NULL on every row, closing a defect this file recorded before the package existed.FlowHostpassed the literalinput: nulland now passesdispatcher.DescribeInput(input)—src/FlowX.Hosting/FlowHost.cs:495. It was never fixable in the host: journaling an input needs aJsonTypeInfo<TIn>and only generated code can name one. One deviation from its Deliverable row, and it is that row's first item. "The generated STJ context ADR-0008 chose" was not built and is not owed. FlowX emits noJsonSerializerContext; the writer reads the ones the compilation declares and makes membership of one of them the build-time requirement — a context FlowX generated would be a second context declaring the same contracts, the case the rule itself refuses. Two measurements it did not move.samples/ecommerceisEphemeral, so no writer is emitted for it andflowx.manifest.baseline.jsondid not change; and theschemaVersionstamp is a field of a stored payload row, not a manifest field, so ADR-0017's F1 stays at twelve schema-declared fields nothing writes. What it found and did not fix:IStepDispatcher.DescribeInputis a defaulted interface member, and both hand-written decorating dispatchers inherited the default and putinputback to NULL — PLAN open item 12 - WP-60
FLOWX1012— shipped 2026-07-31 as aWarning, uniformly, with no escalation. The source is not wrong: a compensableEphemeralflow unwinds correctly on every failure that is not a crash, which is the trade ADR-0003 ratified anddocs/DEBT.mdclasses as a decision rather than debt. Its remedy also has a prerequisite outside the compilation —Profile = Durableis only a fix where a host registers a journal — so an error would block a build over a deployment fact no analyzer can see. WP-58's escalation rule does not transfer and the reason is worth keeping: that set escalates where the compilation proves the code is on a durable flow's replay path, and this rule fires because the flow is not durable. Trigger and escalation are mutually exclusive. No code fix, deliberately — a one-clickDurableproduces a flow refused at start-up, which is the fix that silences the rule rather than the one that is correct. The reference sample fires it and keepsEphemeralbehind a stated-reason pragma - WP-61
ReplayDeterminismTestand its corpus — shipped 2026-07-31. Risk R2's actual mitigation, which05 §11had cited as though it existed. Nine flow shapes, each run twice: once against the world, once against the journal the first run wrote, on a clock 100 days away so an unreplayed value cannot be mistaken for a replayed one. Two observation channels — every action the flow took, and every journal row including the whole capture — because a compensation's ambient reads are invisible to the journal. The harness is proved able to fail: five deliberate divergences are permanent tests. TheFLOWX1007case on two realUtcNowreads was deliberately not built — a coarse platform timer could make them agree and the gate would go green for the wrong reason.Parallelis pinned, not bought. A per-branch context is aFlowEnginechange, so instead a rendezvous makes the interleaving reproducible every run: branch 2's row carries both ids and branch 1's carries none, and a second test measures the cost — the robbed branch mints a fresh id on replay. An overlapping fork does not replay; a non-overlapping one replays exactly, and that is the honest extent of the claim. Both tests go red the day the per-branch context lands. One runtime change:FlowExecutionContext.ReplayNondeterminism, the read half of a capture that was written and never read. The engine still cannot call it — the loop skips a committed step rather than re-running it — so replay is driven from outside the loop, which is WP-64. WP-64 has since shipped, and it does not use this.flowx replay --mode inspectrenders history from the journal as rows in a process that links no FlowX assembly, so it cannot callReplayNondeterminismand does not re-execute anything. The only caller is stillReplayCorpusintests/FlowX.Runtime.Tests; no shipped code path drives a replay - WP-62 QR2 — 10 000 flows,
SIGKILLat every step boundary, zero duplicate non-idempotent effects, zero lost instances, resume p99 ≤ 45 s. P2's Done-when. Still[ ], and the reason has to be stated precisely because WP-50's rig makes it look otherwise. The rig has been run by hand and its results committed; this package's deliverable is the chaos run in CI, nightly, and no job, schedule or workflow invokes it. A recorded run is evidence; a nightly job is a check that can fail, and only the second one closes this row. Its p99 clause is the part that will argue back: 32.9 s and 32.6 s on the recorded run, 48.1 s and 69.9 s on two other runs of the same rig with every correctness row still zero (QR2-chaos.md §4.4)
Roadmap Should:
- [~] WP-63
AwaitSignal,Delay, timers. The suspension half shipped 2026-08-01; the timer half has not. ADurableflow reaching.AwaitSignal<T>(timeout)now stops there — sealedSuspendedat its resume frontier, holding no thread, no pooled context and no lease — andFlowHost.SignalAsyncresumes it through the sameFlowEngine.ExecuteAsynca recovery scan uses, as one private body with two overloads rather than a second loop. A delivered signal is committed as theAwaitSignalstep's ownflow_steprow, so there is no signal table and no migration: the derived frontier that already skipped committed steps is what makes a redelivery inert.FLOWX1017's code fix now produces a flow that compiles and waits, where for two phases it produced a different error. Not built:.Delay(...)and.OnTimeout(...)still compile to nothing, and the timeout the author writes reaches the plan armed by nothing — the only enforced budget on a waiting instance is its[FlowDeadline].FLOWX1031is narrowed to those two rather than deleted, and its page's deletion table now has one row done and one outstanding. Three limits are named on that page rather than left to be found: no manifest field for a signal, no202shape for an[HttpTrigger]ed flow that suspends, and an inline composed child that may not wait (flow.suspension_inside_composition— a parent resumed past a waiting child would compose a second child instance and repeat its effects). An assertion written to prove a redelivery is rejected was vacuous — a null-conditional short-circuited the whole chain — and hitting the running app showed the opposite of what it claimed to check. Replaced with assertions that a redelivery runs nothing, writes nothing and succeeds. - WP-64
flowx replay --mode inspect— shipped 2026-08-01. The verb renders a durable instance from the journal, reading it as rows over the published migration contract throughNpgsqland joining it against the manifest, which publishes the plan astep_idis meaningless without. ADR-0020 is the decision, and it declines to publishflowx.journal.schema.jsonon three grounds: nothing writes a journal document, so the schema would describe a file that does not exist; a manifest freeze is a promise about code regenerated on every build while a journal schema is a promise about data at rest that no gate here can enforce; and the migrations are on their fourth script, so the shape has not stopped moving. A store's published contract is its DDL, and that already ships as reviewable SQL. This line said the exit criterion "collides with the green fitness functionCliDependsOnNothingButTheManifest". It does not, and the warning was wrong rather than merely obsolete. The rule assertsRepositoryLayout.ProjectReferences(cli).ShouldBeEmpty(...)—tests/FlowX.Architecture.Tests/DependencyRuleTests.cs:166— so it counts project links, not package inputs.Npgsqlis aPackageReference, the same kind of dependencySystem.Reflection.MetadataLoadContextalready was; it adds no link, the assertion never came near failing, no fitness function was amended and every architecture gate stayed green. The warning was read off the rule's name instead of its body, which is the part worth keeping rather than deleting. The invariant that name was hiding is now asserted rather than accidental:EveryVerbButReplayRunsWithNoStoreintests/FlowX.Cli.Testsrunsgraph,manifest,diffandverifywith no connection string in the environment. The old property was enforced by nobody having tried. Owed when WP-64 shipped, done on 2026-08-01:CliDependsOnNothingButTheManifestis nowCliLinksNoFlowXAssembly, with the six documents that cite it by name. ADR-0020 recorded it as owed and its owed-work item is struck. It is carried in the fitness-function list below so it is not lost
Fitness functions P2 changes, and one it does not:
-
RuntimeDoesNotReadTheExecutionProfile— deleted at WP-52, not skipped. Observed failing first;ExecutionProfileHonestyTests.csno longer exists -
ReplayDeterminismTest— created at WP-61 - [~]
CrossTenantAccessIsDenied— lost half its blocker at WP-52 (the missing journal) and stays blocked on P4's policy execution and, for "every trigger kind", P3's second transport. It did not become green, and the row in §4 says so -
EveryVerbButReplayRunsWithNoStore— added at WP-64, intests/FlowX.Cli.Tests. It asserts whatCliDependsOnNothingButTheManifest's name implied and its body never checked:graph,manifest,diffandverifyrun with no connection string in the environment. Before WP-64 that held because no verb had needed a store -
CliDependsOnNothingButTheManifest→CliLinksNoFlowXAssembly— done 2026-08-01. WP-64 widened the CLI's inputs without touching the rule, which stayed green because it countsProjectReferenceitems andNpgsqlis aPackageReference, leaving the name further from the assertion than it was. Renamed with the six documents that cite it, and the rule's failure message now states that it counts project links rather than package inputs — that message is what a future reader believes, and it was the sentence that would have re-created the misreading. ADR-0020's owed-work item is struck, not deleted
Nothing in PLAN §6's table has started. One package adjacent to it shipped early and is recorded here rather than left to be rediscovered.
- Endpoint generation — a flow that declares an HTTP route no longer needs a
hand-written
MapPost.FlowXEndpoints.g.csis emitted into the user's assembly and registers every routed flow from one call;samples/ecommerce/Program.csdrops from 12 lines of registration to 2.RuntimeDoesNotReferenceAnyPluginstays green because the compiler emits the string"FlowX.Http.FlowEndpointExtensions"and resolves it throughCompilation.GetTypeByMetadataName— it knows the transport's name, not its assembly. Manifest output is byte-identical; the generator cost gate reports −0.50 % allocations and −0.97 % elapsed. Deliberately unnumbered: it was executed as "WP-74", whichPLAN.mdreserves for Azure Service Bus — the second work-package number collision this project has had, and the first to happen after the warning against it was written. One file had not heard, and was corrected the day it was raised.src/FlowX.Compiler/Analysis/TriggerReader.cs's class remarks said "Nothing yet turns these attributes into endpoint registrations — the sample maps its route by hand inProgram.cs", which this row is the refutation of. Both halves were false: all three samples call the generatedapp.MapFlowX(), not one mapping by hand. The paragraph now names the kinds that genuinely are declaration-only —Bus,Schedule,Stream,Change,Agent— and splits what the raiser kept as a single surviving conclusion: the registration and the manifest'striggersblock come from one reading of the attribute, so an HTTP route has no second copy to drift from; a hand-written route reaching a flow at an address it never declared is the half that is still unasserted. Raised while writingdocs/09's status box, fixed once WP-59 released the file — PLAN open item13 - A silent staleness bug in
templates/local-feed.sh, found by the same package rather than by a test. NuGet caches by id and version, so a rebuild at an unchanged version leftverify.shrestoring the previous run's assemblies — the template verification was passing against stale output. Fixed, andverify.shnow carries the check - WP-70 through WP-76 — not started. The abstraction half of WP-70
(
[TriggerKind]) shipped in P1;PluginsPassConformanceis still blocked on the rest
Added 2026-08-01. This section exists because the question "is the platform built?" was
being answered by reading twenty package rows, and three subsystems were found in one week
that were declared, published, diffed and never executed — the manifest carried them, a
flowx diff rule could break a build over them, and no code path read them at run time. That
is the failure mode this table is shaped to expose: a row is runs only when something
executes it, not when something publishes it.
| Subsystem | State | Evidence, or what is missing |
|---|---|---|
| Compile-time orchestration | runs | FlowPlanGenerator; 36 diagnostics; no runtime reflection |
| Ephemeral execution | runs | B2 hard zero, gated by EngineAllocationTests on every PR |
| Durable execution | runs | journal, fenced lease, recovery scan, resume from a derived frontier — against real PostgreSQL |
| Durable suspension | runs | .AwaitSignal<T> parks the instance; SignalAsync resumes through the same engine entry |
| Durable timers | runs | .Delay, .OnTimeout, and the declared wait armed; wake_at on the instance row |
| Compensation | runs | unwind rebuilt from the journal, with its own retry policy |
| Transactional outbox | runs | staged in the step's transaction; PostgresOutboxPublisher drains it |
| Broker publication | runs | four implementations — Redis Streams, RabbitMQ, Azure Service Bus, Kafka — held to one unmodified PublisherConformance, by four ordering mechanisms with nothing in common |
| HTTP trigger | runs | EndpointEmitter; 202 for a flow that suspends; generated signal routes |
| Schedule trigger | runs | ScheduleEmitter; one instance per occurrence across a fleet, no leader |
| Policy engine · fourteen kinds | runs | twelve until WP-83 (2026-08-15) added Consent and reclassified Authorize as derived — the stance machinery is the policy, and a declarable twin was refused as a second reading; Batch stays undeclarable on ADR-0081's five pieces — ten until WP-81/82 (2026-08-15) added Validate (build-time-generated checks from DataAnnotations, field errors as RFC 7807, a [Sensitive] value structurally unable to reach a message) and Quota (fixed window on an absolute grid, tenant-fair by key, Forbidden not Unavailable) — WP-80 (2026-08-15) completed Fallback's capability half: ExecuteFallbackAsync on the dispatcher (five decorators caught silently inheriting its default by the fitness gate), journal identity via the existing capability_id, manifest fallback: field, and a live resume-path defect fixed — a degraded step no longer re-enters the unwind stack on resume — this row said eight until WP-78/79 (2026-08-14) added Fallback (constant-valued; a degraded step commits its own journal row and registers no compensation) and Hedge (races inside retry, one winner commits; FLOWX1051 refuses it over a non-idempotent capability) — plus stage 4's four plus RateLimit, Idempotency, Cache, Audit. FLOWX1032 deleted with the gap it reported |
| Authorisation | runs | Authenticated and Permission refuse; Public and Internal permit by construction; Policy refused at build time (FLOWX1037). A fail-open where Policy permitted everybody is fixed |
| Manifest | runs — six of eight freeze criteria hold since 2026-08-15 | a build artifact, byte-pinned, diffed by 40-odd rules; four unproduced fields gained producers, eleven were struck per ADR-0017 §1's own convention, extensions is exercised rather than trusted, and ManifestIsComplete covers all four nouns non-vacuously. F2 closed 2026-08-15 the same day: [EventSchema] on the contract type, one reading feeding manifest and outbox alike, FLOWX-DIFF-020's major-bump half fired for the first time ever. Seven of eight hold; what remains is F7 — the bump itself, the repository owner's call |
| Telemetry · traces and metrics | runs | 11 of 13 metrics, 10 of 13 attributes; B6's allocation half gated |
| Telemetry · logs | runs | FlowXLog over DiagnosticSource; src/FlowX.Logging bridges to ILogger without moving AbstractionsHasNoDependencies |
| Triggers | 8 of 8 bound | HTTP, Schedule, Bus, Change, Agent, Manual, Cli, Stream |
| Push admission | runs | added 2026-08-14 (WP-140): FlowBusScan.AdmitAsync / FlowStreamScan.AdmitAsync decide and do not settle, so a push host (Functions, KEDA) settles by the returned disposition; pull path unchanged and proven row-for-row against a real Postgres; flowx_trigger_admitted_total finally has its producer at this one seam |
| Functions host | runs on the real local runtime | added 2026-08-14 (WP-141): FlowX.Functions emits [Function] entry points from the same TriggerReader reading the compiler uses (source-linked, asserted single); Http/Schedule/Bus/Change/Agent bound, Stream deliberately not (a closed window needs state a scaled-to-zero host lacks); generated methods invoked with the platform's argument types against a real Postgres, and since 2026-08-15 the real local runtime too: Core Tools 4.6.0 from its GitHub release, all three trigger arms green end to end against the Service Bus emulator and Azurite. The suite also caught the Functions HTTP surface missing the Idempotency-Key contract; FlowPushSeams.HttpAsync now refuses |
| Recovery wake-up | runs | added 2026-08-14 (WP-143): migration 0014 announces the schema's earliest live lease expiry; the recovery sweep arms to wake at it, poll stays the backstop. Chaos, matched pair at recovery capacity 32: takeover p99 52.5 s → 31.2 s (before-commit arm), LeaseTtl + 1.2 s, correctness counters all zero |
| Admission bounds | runs | added 2026-08-14 (WP-144): FlowAdmissionGate, CAS, sheds instead of queueing; default unlimited and allocation-free; Bus requeues, Stream holds, HTTP 429 + Retry-After; flowx_trigger_rejected_total has its first producer and a shed never counts as an admission |
| Sweep wake-up | runs | added 2026-08-14 (WP-142): migration 0013 NOTIFYs on outbox staging and parked wakes; PostgresSweepSignal LISTENs on the direct connection and completes the sweep's wait early — poll stays as the correctness backstop, absent signal is byte-for-byte the old behaviour. Chaos rig re-run green over the new triggers: 0 duplicates against the guarantee, 0 lost, 0 orphans, both arms |
| Multi-tenancy | runs | resolution at admission, Row and Schema isolation, six of six fairness mechanisms. Database refused as a topology, not a level (ADR-0051) |
| Stream engine | runs | tumbling event-time windows, an observed watermark and a checkpointed source position; window state is not journaled because a closed window derives the instance id it starts (ADR-0055). Sliding, session and global windows are refused by FLOWX1042 |
| AI surface / MCP | runs | plugins/FlowX.Mcp; tools/list is a projection of the manifest and tools/call meets the same authorisation stance HTTP does |
| Studio | absent | sixteen one-line mentions and no design |
| Samples | ten of ten run | every specification sample was built on 2026-08-02; each names the claims the platform refuses rather than lacks |
What is planned next. Every subsystem above runs but Studio, which the roadmap cannot yet specify. What is left is narrower than a phase:
- Studio — not next. Sixteen one-line mentions and no design.
- Nothing measures a tenant's p99 under load, so 21's tenant-fairness gate stays unwritten though all six mechanisms exist. It is a measurement, so it waits with the rest of the performance work.
CompensationDurabilityAnalyzer(FLOWX1012) still reports onnot Durableand its remarks justify that with "Streamingruns on the ephemeral engine", which the runtime contradicts —Streamingis journaled, which is whyFLOWX1017now accepts it.ACompensableStreamingFlowIsReportedpins the current behaviour, so changing it is a decision rather than a fix.
PLAN §1.1 carries the static mapping — which package or phase each criterion is owed to. This carries the state. P9 closes when all eight are met and gated in CI (20-Roadmap), so "satisfied" and "closed" are different columns on purpose.
Neither this file nor the plan mentioned V1–V8 before 2026-07-31. The criteria that
define whether the project succeeded were tracked nowhere in the two documents that track
everything else.
| # | Criterion | Satisfied? | Gated by a check that can fail? |
|---|---|---|---|
| V1 | ≤ 3 files, ≤ 60 lines for a 4-step flow | yes — 3 files, 29 of 60 lines, root costs zero | yes — UseCaseCostTests, four facts incl. the anti-vacuity check. This row's gate cell said "no — a review" |
| V2 | HTTP → Kafka, zero logic edits | partly — four transports over one capability chain in samples/event-driven, each costing one adapter step; not Kafka, which needs a broker |
yes, over the transports that exist — TransportEquivalenceTests runs one reference through HTTP, bus, change and cron and asserts on journal rows |
| V3 | p99 ≤ 5 µs, ≤ 1 alloc/step | yes — 172.3 ns / 0 B | yes. The only one of the eight |
| V4 | durable checkpoint p99 ≤ 15 ms @ 5 000 flows/s | unknown — a journal exists since WP-53; nothing times it. WP-50 shipping did not move this row: its rig times resume after a SIGKILL, not the checkpoint commit this criterion names |
no — WP-50's unbuilt half |
| V5 | cold start ≤ 200 ms, NativeAOT | yes on advisory hardware — p50 63.0 ms / p99 102.2 ms to first flow response, AOT, 30 starts (V5-cold-start.md). This cell said "unknown — nothing times it" | yes, on p50 — the aot job re-measures the smoke-tested binary and check-cold-start.py fails the run over 200 ms; the tail is reported and not gated, since two same-day p99s spread 26 % |
| V6 | generator allocation ≤ 800,000 B/flow, ≤ 160,000 B/capability | yes — 138,657 per capability against a 160,000 ceiling since the 2026-08-15 SemanticModel-cache fix (was 148,562) | yes — check-generator-cost.py fails the run on a breach. This row read "build overhead ≤ 8 % · no · +67.1 %" for four days after ADR-0014 replaced the ratio on 2026-08-10 and 01 §7 moved with it. The +67.1 % measurement stays true of what it measured; it is no longer what V6 asks |
| V7 | 100 % of flows, capabilities, policies and events in the manifest | partly — all four kinds are published, but a policy carries kind and stage and none of its parameters, and an event carries type and schemaVersion and no payload schema |
partly — ManifestIsComplete covers what is published |
| V8 | mid-level engineer ships a flow in ≤ 2 h, n ≥ 10 | not run | no — P9 |
Five of eight are gated — V1, V3, V5 (on p50) and V6 outright, V2 over the transports that exist. This line said one, and contradicted the V2 row three rows above it. Two more are satisfied or partly satisfied and enforced by nothing, which is the state that decays silently — V1 already moved without anything noticing.
- Three of nine samples have code (was one).
ecommerce(the baseline saga),banking— a durable transfer saga: compensation in strict reverse order,[Sensitive]redaction reaching a real PostgreSQL outbox row with zero IBAN occurrences in any journal column, one event staged per instance — andworkflow, 25 compiled nodes exercising the whole shipped DSL:Switch/Case/Default,Parallel,ForEachcontainingWhen,SubFlow,Fail, compensation at six sites. Building them found four runtime defects, all now fixed, none of which unit-testing the compiler had surfaced - The six blocked samples stopped claiming a proof that never happened. Each opened
with a present-tense "Claim proved:" line; design content is kept, the claim is gone,
and every code block using something that does not exist is marked.
event-drivenis the sharpest case —[KafkaTrigger]compiles and reaches the manifest as"kind": "Bus", and greppingTriggerKindacross Runtime, Hosting and plugins returns nothing. The attribute is real and the transport is not -
dotnet new flowxships — and this file said otherwise for longer than the command was missing. What was missing was the gate:templates/README.mdcalledverify.sh"what CI should run" and CI never ran it, so the template could have rotted silently. Atemplatejob now runs it -
docs/24-Getting-Started.md, with a gate that makes it checkable: every fenced block must carry averify:marker, so the page cannot grow an unchecked snippet. Blocks marked as compiling go throughFlowPlanGeneratorand all eleven analyzers, and the generated code is compiled too; blocks marked as reporting a diagnostic must produce exactly that set. Both directions were confirmed by breaking them
| Gate | Target | Now | Source |
|---|---|---|---|
| Compiler warnings | 0 | 0 ✅ | verified locally |
| Blocker/critical Sonar issues | 0 | not running | WP-0 |
| Line coverage | ≥ 80 % | 83.9 % — not enforced | measured 2026-07-31 over src/ + plugins/, live Postgres |
| Branch coverage | ≥ 75 % | 77.6 % — not enforced | same run; no CI step compares either figure to its target |
Mutation score (FlowX.Core) |
≥ 70 % | not measured — Stryker not run locally | WP-0 |
| Trim/AOT warnings | 0 | 0 ✅ | verified locally |
| Fitness functions | all green | 58/58 ✅ | dotnet test tests/FlowX.Architecture.Tests -c Release, plus compiler and code-fix fitness tests |
| NativeAOT publish | links and runs | ✅ | 11 MB binary served a real order |
| Concurrent cross-tenant leak | none | none ✅ | 64 concurrent flows, 0 overlaps |
| SAST findings | 0 | wired, unrun — needs a CI run | WP-0 |
| DAST findings | 0 | wired, unrun — the sample now exists; needs a CI run | WP-0 |
| Vulnerable dependencies | 0 | 0 by construction — zero dependencies | WP-1 |
| Open debt entries | ≤ 20 | 1 — DEBT-0001 | enforced by SuppressionsAreAccountable |
| B1 flow overhead | ≤ 5 µs | 172.3 ns ✅ — but the job that gates it is red; see B-4 | WP-11, real engine, 30 iterations |
| B1 failure-path allocation | recorded, exact | 56 B — was 40 B. Moved 8 B at 744b005 (WP-29) and 8 B at 16b6988 (WP-57) as CompensationEntry grew from 8 B to 24 B; still one object, still the Unwind iterator, because an iterator carries what it yields. Restated in baseline.json with the reason and now pinned exactly by UnwindingAllocatesOneIteratorPerFailedFlow instead of by a band |
bisected over 304 commits; the compensation-identity fix measured clear at 56 B on both sides |
| B2 allocations per step | 0 B | 0 B ✅ — every success path measures exactly zero. The failure path above is not what B2 governs | gated as a unit test — Release only, see below |
| B3 capability dispatch | ≤ 150 ns | 21.9 ns ✅ — same red job; see B-4 | shared hardware, advisory |
Benchmark budgets job |
green | red on dev since 2026-07-31 ❌ — 4 blocking failures remaining, none of them the engine. Blocking, and blocking nothing: no branch protection, no notification |
B-4 |
StepLoopBenchmarks.BuildPlan exact gate |
reproducible | not reproducible ❌ — 520 B committed, 456 B at its own commit on this container, 464 B at dev, 528 B on the hosted runner, while every other entry agreed across both machines |
PLAN §9 item 15 |
CompilerBenchmarks allocation band |
≤ +15 % | +41 % / +50 % ❌ — WithGenerator also at 51.2 ms against a committed 11.2 ms, p95 crossing B12's 60 ms ceiling on some runs |
compile-time cost — B12-scale's subject, not the engine's; not re-recorded |
| B12 build overhead · 1 flow | ≤ 8 % | +0.4 % ✅ | WP-14, like-for-like sample build |
| B12 build overhead · 200 flows — P1's exit criterion | ≤ 8 % | +67.1 % ❌ | WP-43. Accepted exception; P1 closed over it. 50 flows: +46.5 %. Growth linear, R² 0.994 |
| Generator cost regression (relative, blocking) | ≤ +2 % | green ✅ | WP-31, deterministic allocation proxy. Prints ABSOLUTE CRITERION — FAIL on every run, so a pass here is not a met budget |
| B7 durable step commit | ≤ 15 ms p99 @ 5 000/s | no harness — JournalBenchmarks does not exist. WP-50 shipped its rig and not this |
P2 · WP-50's unbuilt half |
| B8 journal rehydration | ≤ 8 ms p99 | no harness — same. These two rows read "P2 · WP-50" as though the whole package were pending; the package is now partial and these are the pending part | P2 · WP-50's unbuilt half |
QR2 chaos · correctness: 10 000 flows, SIGKILL |
0 duplicate effects against the guarantee, 0 lost | measured, not gated — this row read "no rig"; the rig shipped 2026-08-01. 0 and 0 in both arms, at 10 000 flows and 97 real SIGKILLs per arm. No CI job runs it: scripts/run-chaos-qr2.sh is opt-in on FLOWX_CHAOS, so nothing here can fail a build |
P2 · WP-50 built it, WP-62 schedules it |
| QR2 chaos · latency: resume p99 | ≤ 45 s | measured, not gated — 32.9 s / 32.6 s on the recorded run; 48.1 s and 69.9 s on two other runs of the same rig, correctness zero in all three. The checker prints it and does not fail on it | P2 · QR2-chaos.md §4.4 |
The architecture's quality goals — 05 §1.2
The gates above are mechanisms. These are the eight things the mechanisms exist to protect, and until 2026-07-31 they were named nowhere in this file. Q1–Q3 are architecture-defining: 05 §1.2 requires an ADR wherever a design choice trades one away.
| # | Quality goal | Enforced by |
|---|---|---|
| Q1 | predictable low latency | EngineAllocationTests (hard zero) + B1/B2. The only quality goal whose gate has ever failed a build |
| Q2 | durable correctness | conformance suite vs real Postgres, lease, recovery scan. The measure — p99 ≤ 15 ms — is still unmeasured. This cell added "and the scenario has never happened: nothing has killed a process". That expired on 2026-08-01: WP-50's rig SIGKILLs worker processes and found 0 duplicates against the guarantee and 0 lost instances over 10 000 flows per arm. Gated nightly since 2026-08-14's re-read: chaos.yml fails on any of the four correctness counters — this cell said "measured by a rig run on demand, not by a gate that can fail", which was stale about the schedule and right about the fourth counter, which the checker never read until that day |
| Q3 | static knowability | ManifestIsComplete, flowx diff, the error catalogue. Same half-gap as V7 — policies and events are unchecked |
| Q4 | transport portability | TransportEquivalenceTests — one reference chain over four transports, asserted on journal rows. This cell said "nothing. One transport" |
| Q5 | operational uniformity | one ActivitySource and one Meter, both named FlowX; TelemetryConformanceTests pins the names, TelemetryCostTests pins B6's hard zero with no listener. An exporter is still the deployment's to wire. This cell said "no ActivitySource, no Meter, no exporter"; two of those three expired |
| Q6 | extensibility | RuntimeDoesNotReferenceAnyPlugin ✅; PluginsPassConformance blocked. plugins/FlowX.Postgres is the first outside implementation to push back on a contract |
| Q7 | startup and footprint | nothing. Same gap as V5 |
| Q8 | multi-tenant isolation | Healthcare.Tests.CrossTenantAccessTests (the database refuses, not the runtime) and PooledTenantIsolationTests (it still refuses through a transaction pooler). The repository-wide CrossTenantAccessIsDenied stays blocked on P4 and P3's second transport — it is written over every trigger kind. This cell said "nothing" |
Sixty-eight records exist; this table stops at 0020 and has since it was written. Its heading said "20 records", which read as a count of the repository rather than of the table. The index is docs/adr/README.md; an obligation in a record above 0020 is tracked where that record is cited, not here.
| ADR | Status | Revisit trigger | Obligation this file or the plan is missing |
|---|---|---|---|
| 0001 primitives · 0004 triggers · 0010 C# DSL · 0012 licence | Accepted | not fired | 0012: the licence scan it calls for — open item 9 |
| 0002 compile-time orchestration | Accepted | FIRED — "build overhead > 8 % sustained"; measured +67.1 % | The record does not say so. Its mitigation list, called "all mandatory", includes a ≤ 8 % gate that ADR-0014 has since made advisory — two ADRs disagree on whether the gate binds |
| 0003 execution profiles | Accepted | not fired | flowx verify --cost is load-bearing in this ADR twice and appears in neither planning file |
0005 manifest · 0007 Result |
Accepted | not fired | 0007: Result.Try is named in the ADR and does not exist |
| 0006 journal + leases | Accepted | not fired | ceiling still a literature figure — tracked |
| 0008 serialization | Accepted | cannot fire — keyed on B7, which has no harness | Box repaired on 2026-08-01. It had said there is "no IPayloadSerializer interface", that FLOWX1006 "does not exist", that "replay is still absent", and that the schemaVersion stamp is on neither sink — all four expired at WP-56, WP-59 and WP-64. Repairing it turned up a fifth: "the generated serialiser is still WP-59" was a prediction that a generated writer would open a second redaction exit, and WP-59's writer opened none, so the clause is kept and annotated rather than deleted. The box now also states what of this record is genuinely still unbuilt — a binary serialiser plugin, and a contract-declared schemaVersion rather than a constant |
| 0009 plugin contracts | Accepted | reviewed "each phase gate" — no record of a review at P1's gate | plugins/FlowX.Postgres as the second plugin that ran the suite unmodified from another assembly. Struck rather than deleted: this is the record a plugin author reads, and what it used to say is why the row existed |
| 0011 policy stage order | Accepted | needs three counterexamples collected — nothing collects them, so it cannot be revisited | the counterexample register does not exist |
| 0013 DSL vocabulary | Accepted | Revisit when |
|
| 0014 catalogue vs budget | Proposed | one of four FIRED — the inner loop pays full derivation per edit, by construction. A second is crossed, not fired: withheld 42 % vs a 20 % trigger, on a corpus B13 §2 argues is inadmissible. This cell said "two of four" and overstated it | corrected 2026-07-31. The record headlined +77.1 % and claimed 200 flows had not been re-measured; ADR-0014 §10 now states +67.1 % and which triggers fired |
| 0015 journal schema | Accepted | cannot fire — keyed on B8, no harness | This cell read "tracked well", then recorded that the Still not in / Owed to table carried "The generated payload writer and FLOWX1006 → WP-59" a day after both shipped. Struck on 2026-08-01, with the construction that actually shipped: the writer meets commitment 5 by a narrower route than the commitment states — it hands named values to JournalPayload rather than routing them through a FlowX-owned STJ context — so redaction stayed structural. Its 06 §5 take-down line is struck too, and verified: docs/06 now has zero no — P2 rows |
| 0016 Postgres adapter | Accepted | not fired | This cell said the record has no Negative section; it gained a Positive / Negative split on 2026-07-31 and the cell was not updated. Its WP-56 purge-guard note and its Oracle Root-scope portability rule are still in neither planning file |
| 0020 CLI reads the journal as rows | Accepted | not fired | This cell said the record names its own owed work and nothing was tracking it. Tracked, then discharged on 2026-08-01: CliDependsOnNothingButTheManifest is CliLinksNoFlowXAssembly and all six citing documents moved with it. The record's owed-work item is struck rather than deleted, and §1/§2 keep the old name where they quote it, because those sections are the argument about the name |
The constraints — 05 §2
| # | Constraint | Enforced by |
|---|---|---|
| C1 .NET 10+/C# 14 | the SDK pin | ✅ |
| C2 NativeAOT | AOT job + IsAotCompatible analyzers |
✅ |
| C3 hosts in ASP.NET Core | nothing explicit — held by construction | — |
| C4 no 2-phase commit | nothing — held by design; the outbox that makes it correct shipped at WP-56 | — |
| C5 OpenTelemetry only | one BCL ActivitySource and one Meter, zero OpenTelemetry package references, AbstractionsHasNoDependencies keeping it so. This cell said "vacuous: nothing emits telemetry" |
✅ |
| C6 Apache-2.0, no copyleft | DependencyLicencesAreCompatible in DependencyLicenceTests, over the resolved transitive graph, against docs/DEPENDENCIES.md. Npgsql vetted (PostgreSQL Licence, permissive); two build-time packages found not to be MIT |
✅ |
| C7 SemVer + 2-minor deprecation | flowx diff catches breaking changes; nothing tracks the deprecation window |
partly |
| C8 documentation-first | convention. Held well; no gate | — |
Nothing in the "Now" column is green by assertion — every ✅ was produced by a command in this working tree. Every "not measured" is equally honest: the gate exists and the mechanism to run it has not been run here. "No harness" is a third state and the worst of them: the budget is stated, nothing can run, and P2's WP-50 exists to make sure that state does not survive into the phase that depends on it, the way it survived into P1. B7 and B8 are still in it, and WP-50 shipping did not change that — the package built its rig and neither benchmark.
"Measured, not gated" is a fourth state, and the two QR2 rows are the first entries in
it. A number exists, it was produced by a command in this working tree, and no check
anywhere fails when it moves: the rig is opt-in on FLOWX_CHAOS and no workflow sets
that variable. That is strictly better than "no harness" and strictly worse than a ✅,
because a measurement nobody re-runs decays exactly the way the coverage figure above did.
Converting the two correctness clauses into a job that can fail is WP-62. The resume
p99 is deliberately not on that path — three runs of the one rig disagree by a factor of
two on it, for reasons
QR2-chaos.md §4.4
attributes to lease TTL and recovery capacity rather than to this codebase.
The two B12 rows are one budget at two scales, and splitting them is the point. A single row reading +0.4 % ✅ was true of a one-flow sample and hid a criterion failing by 59 points on the solution shape the budget was actually written for.
B2 is measured in Release only. The C# compiler emits async state machines as
classes in Debug and structs in Release, so a Debug run charges the engine ~376 B of
Edit-and-Continue scaffolding it does not allocate. The tests now skip in Debug and
say why, rather than failing in the configuration everyone runs locally — a gate that
fails by default is a gate people learn to ignore. Run dotnet test -c Release.
Controls from 21-Quality-Gates §3. "Designed" means the control is specified and its enforcement point identified; "enforced" means a gate actually runs.
| Risk | Control designed | Control enforced |
|---|---|---|
| A01 Broken access control | [x] required Authorization member |
[x] FLOWX1010 raised and tested; the sample's four capabilities all declare a stance |
| A02 Cryptographic failures | [x] [Sensitive] + generated redaction |
[~] enforced on both sinks that exist. Secrets are stripped from Problem Details bodies, tested end to end, and from journal payloads since WP-52 — structurally, since JournalPayload.ToJson() is the only exit and it redacts. This row said the journal does not exist yet. Logs, traces and replay output still do not, and redaction is not applied by a generated serialiser anywhere, so the "no code path can forget it" claim is still not met |
| A03 Injection | [x] compile-time graph, no Do(lambda) |
[x] structurally true; CodeQL and Semgrep run and pass on every push. This row said "wired, unrun". Semgrep fails the job on any ERROR-severity finding; CodeQL uploads to code scanning and fails on nothing, which is §7.1's row rather than this one |
| A04 Insecure design | [x] STRIDE per boundary, 12 ADRs | [x] ADR review in CONTRIBUTING |
| A05 Security misconfiguration | [x] no permissive defaults | [x] startup validation, 8 tests |
| A06 Vulnerable components | [x] zero-dependency abstractions | [x] Dependabot + SCA gate |
| A07 Auth failures | [x] claims-only tenant resolution | [x] 5 tests, incl. headers ignored |
| A08 Integrity failures | [x] deterministic builds configured | [ ] needs signing + SBOM (WP-0) |
| A09 Logging failures | [x] Audit policy at Consistency stage |
[x] the policy engine runs all eight kinds, Audit among them (§5e2). This row said "needs the policy engine (P4)". |
| A10 SSRF | [x] FLOWX1003 forbids transport refs |
[~] raised by CapabilityAnalyzer, against a list of transport namespaces rather than a proof — the limit is stated on the diagnostic's page |
github/codeql-action/analyze uploads every alert the security-and-quality suite raises and
fails on none of them: the job's conclusion is success whether the page holds zero alerts
or a hundred. Semgrep is the opposite — it fails the job and uploads nothing, so its findings
never reach the page. Checkov scans nothing, because deploy/ and charts/ do not exist.
So /security/code-scanning is CodeQL's output alone, no gate reads it, and its size is
unknown to anyone who has not opened the page. Closing this is a number in a committed
baseline and a job that fails when the open-alert count exceeds it, ratcheted down — the
shape docs/benchmarks/baseline.json already uses for allocations. Until that exists, no row
above may be read as covering what CodeQL finds.
The build that resolved B-2 produced 31 errors. None was a language error — every one was an analyzer rule, which is the outcome the contract surface was written for. Recorded here because "it compiled first try" would be a more flattering claim than the truth, and a less useful one.
| Finding | Count | Resolution |
|---|---|---|
CA1716 — identifier matches a reserved keyword |
14 | The rule fires on Step, Return, When, Then, Error, Get, Set — the DSL's entire vocabulary. Disabled repo-wide with ADR-0013. A decision, not debt. |
IDE0040 — accessibility modifiers required |
15 | Our own .editorconfig defect: always demands public on interface members, which no C# codebase writes. Changed to for_non_interface_members. |
IL2026 — trim analyzer on GetExportedTypes() |
1 | Trim analyzers were enabled on test projects, which reflect by design. Disabled for tests/ only; EveryShippedProjectIsAotAnalyzed still guards src/. |
CA1859 — return concrete type for perf |
1 | Legitimate. Private helper changed from IReadOnlyList<string> to List<string>. |
MSB4025 — .slnx parse failure |
1 | A solution folder named / is invalid. The cosmetic file listings were removed; the solution now lists projects only. |
Two CI jobs were also wrong and are fixed: the AOT job published a class
library, where PublishAot does nothing and a RuntimeIdentifier is required.
The real gate for a library is the analyzer at build time, which now runs. The
genuine AOT publish was guarded until an executable existed; the sample is that
executable, so the guard is gone and CI now publishes and runs the binary.
Update it in the same commit as the change it describes — a checklist updated separately is a checklist that drifts, and a drifted checklist is worse than none, because people trust it.
When a [~] becomes [x], state what verified it. When a [ ] becomes [x],
the Definition of Done applies in
full.
Back to: README · Plan · Quality gates