Scope: work-package detail for the phases in docs/20-Roadmap.md. The roadmap says what each phase must prove and when it is done; this document is what a contributor picks work from.
Companion: CHECKLIST.md carries live status and is updated with every change. This document changes only when the plan changes.
Where we are: P0 is complete and passed its kill criterion (B1 172 ns against 5 µs, B2 zero — P0.md). P1 — Compiler hardening is closed with one exit criterion unmet and accepted — the build-overhead budget, at +67.1 % against ≤ 8 %; see §4, which states the exception before it states anything else. P2 — Durable execution is in progress: WP-59 has shipped, the last package in P2's Must with nothing written for it — the generated payload writer,
FLOWX1006, theschemaVersionstamp,IPayloadSerializer, and an input the instance row keeps (§5 WP-59); WP-64 has shipped —flowx replay --mode inspectrenders an instance from the journal, reading it as rows over the published migration contract rather than as a published document (ADR-0020) — and WP-50 has shipped one of the three things in its deliverable row: the QR2 chaos rig exists, kills real processes and has been run; B7 and B8 still have no harness, so that package is partially delivered and must not be read as done. WP-62 is now the only Must with nothing written for it, and it is a nightly schedule rather than code. WP-63, a Should, is now half built: aDurableflow suspends at.AwaitSignal<T>and a signal resumes it through the same engine entry a recovery scan uses, with no signal table and no migration;.Delayand.OnTimeoutstill compile to nothing and the declared timeout is armed by nothing, soFLOWX1031is narrowed rather than deleted. WP-56 gained the broker plugin its own ADR names as the condition for reopening —RedisStreamEventPublisher, one stream perpartition_key, held toPublisherConformancealongside the recording double, so ADR-0018's revisit condition is met. This sentence read "WP-51, WP-52, WP-53, WP-55 and WP-58 have shipped, WP-50 has not started". The WP-50 clause expired on 2026-08-01. The list before it was never the live record and is not one now — CHECKLIST §5d is, and it ticks packages this line never named. §5 carries the rest. §6 sketches P3, and §6a says plainly what this plan does not yet contain.This plan is downstream of a vision, and §1.1 is where it says so. Until 2026-07-31 neither this file nor the checklist mentioned criteria V1–V8 or quality goals Q1–Q8 even once, while P9's Done-when is "criteria V1–V8 are all met and gated in CI". A plan that never names its own terminal condition cannot be checked against it.
P0 is not "the foundation". It is a falsification attempt against the platform's central bet, stated in ADR-0002:
A Roslyn source generator can emit an execution plan that is correct, debuggable, and fast enough that compile-time orchestration beats a reflection-based mediator by an order of magnitude.
Everything else in FlowX is downstream of that sentence. If it is false, the right outcome for P0 is to discover it in three weeks, not to discover it in month nine with eight phases built on top.
Kill criterion (unchanged from the roadmap): if generated dispatch cannot reach B1 ≤ 5 µs p99 and B2 = 0 allocations, stop. Do not proceed to P1. Revisit ADR-0002 first.
The kill criterion passed and ADR-0002's other trigger has since fired. That record's revisit condition is "> 3 generator defects per delivery phase, or build overhead > 8 % sustained". Build overhead is +67.1 % at 200 flows, sustained across two independent measurement campaigns. ADR-0002 does not say so — it is marked Accepted with no note, and the sentence above is the only place in the repository that connects the number to the record it is supposed to reopen. ADR-0014 exists to put the resulting choice in front of a decider and remains Proposed. It is no longer tracked as an open item: the repository owner removed it from §9 on 2026-07-31, which is a decision to leave the choice unmade rather than an oversight, and consistent with performance being set aside for this phase. The amendment ADR-0002 owed itself was open item 11, and was made on 2026-07-31 — that record now carries a status note naming the fired clause. This sentence pointed at open item 9, which is the licence scan. A cross-reference to the wrong row is the same defect as a stale number, and it was in the paragraph arguing for accurate cross-references.
Two sets of numbers define success, and neither was named anywhere in this file or the checklist until 2026-07-31. They are reproduced here with their real current state, so that a phase cannot be closed against a criterion nobody looked up.
The vision's success criteria — 01 §7
P9 closes when all eight are met and gated in CI. Gated matters: three of the rows below are satisfiable today and measured by nothing that can fail a build.
| # | Criterion | State | Owed to |
|---|---|---|---|
| V1 | ≤ 3 files, ≤ 60 lines for a 4-step flow | met, not gated. A review, never automated; endpoint generation cut the sample's registration from 12 lines to 2, which moved the number and no assertion noticed | a fitness test, unscheduled |
| V2 | HTTP → Kafka is an attribute change, zero logic edits | not met. One transport exists | WP-71 (the unchanged-file assertion), WP-72 |
| V3 | p99 ≤ 5 µs, ≤ 1 alloc/step | met and gated. 172.3 ns against 5 000 ns; B2 exactly 0 B, re-verified after the durable seam | — |
| V4 | durable checkpoint p99 ≤ 15 ms @ 5 000 flows/s/node, Postgres | unreported, and WP-50 shipping did not move it. 01 §7 says "there is no journal to checkpoint into"; since WP-53 there is. What is missing is still only the harness: WP-50 built the QR2 chaos rig and not JournalBenchmarks, and the rig measures resume latency after a SIGKILL — how long until another node picks an instance up — which is a different quantity from the checkpoint commit latency this row names. Nothing timed a commit |
WP-50's unbuilt half |
| V5 | cold start ≤ 200 ms, NativeAOT | unreported. The AOT job proves the binary links and serves a request; nothing times it | P9 |
| V6 | build overhead ≤ 8 % | failing, and not gated in the sense P9 requires. +67.1 % [+61.9, +73.6] at 200 flows. The scale-overhead job measures the criterion and is advisory — its effect on a pull request is suppressed by an explicit ADR-0014 §4(4) commitment, because a gate you already fail reds every PR over a defect none of them introduced. The blocking cost gate (generator-cost) is relative: it answers "did this change make it worse", never "is the build fast enough". This row said "failing and gated" when first written on 2026-07-31 — copied from 01 §7's prose without reading performance.yml, which is the exact error this table exists to catch |
ADR-0014's decision |
| V7 | 100 % of flows, capabilities, policies and events in the manifest | partly met. ManifestIsComplete covers flows and capabilities; the policies-and-events half is checked by nothing, because neither executes yet |
P4, WP-56 |
| V8 | a mid-level engineer ships a correct flow in ≤ 2 h, n ≥ 10 | not run | P9 |
One met and gated. One failing, measured, and deliberately not blocking. Six unverified. That ratio is the honest summary of where the platform stands against its own definition of success, and it belongs at the top of the plan rather than in a document nobody opens mid-phase.
"Gated" is the word to be careful with, and this table got it wrong on its first draft. P9's Done-when is "V1–V8 are all met and gated in CI", so a criterion that is satisfied but unenforced does not close P9 — and one that is measured by an advisory job is not gated either. Exactly one of the eight (V3) is enforced by a check that can fail a build.
The constraints — 05 §2
Constraints are not goals: nothing is traded against them, they simply hold or the design is
wrong. Until 2026-07-31 only C2 was named in this file, once, and only as an obstacle.
| # | Constraint | Enforced by |
|---|---|---|
| C1 | .NET 10+, C# 14 | the SDK pin; global.json |
| C2 | NativeAOT | the AOT job + IsAotCompatible analyzers |
| C3 | hosts inside ASP.NET Core | nothing explicit. FlowX.Hosting is written to it; no test asserts the process lifecycle is not owned |
| C4 | no 2-phase commit | nothing. Held by design — one transaction per store — and the outbox that makes it correct is WP-56 |
| C5 | OpenTelemetry only | vacuous today: no telemetry of any kind is emitted (P5) |
| C6 | Apache-2.0, no copyleft | DependencyLicencesAreCompatible in DependencyLicenceTests, against the dependency licence register. Covers the resolved transitive graph, not only what is declared — NuGet writes it to obj/project.assets.json and every package's .nuspec is on disk beside it, so the scan needs no network. Findings on the first run: Npgsql (WP-53, unvetted until now) is the PostgreSQL Licence and permissive; SonarAnalyzer.CSharp is not MIT but the SONAR Source-Available Licence, and Microsoft.NETCore.Platforms 1.1.0 is a proprietary Microsoft EULA — both tolerated only because the resolved graph proves they contribute no assembly. What the gate cannot see is written down, including the three projects outside FlowX.slnx whose closure is unread |
| C7 | SemVer, 2-minor deprecation window | flowx diff detects breaking changes. The deprecation window is enforced by nothing — nothing tracks how long a member has been obsolete |
| C8 | documentation-first | convention only. Held well in practice; no gate |
Two of eight constraints have an enforcing gate. For C1–C5 and C8 that is mostly
appropriate — a constraint held by construction needs no test. C6 and C7 were the two
where "nothing enforces it" was a real exposure rather than a formality; C6 now has a
gate, and C7 is the one left.
The architecture's quality goals — 05 §1.2
Q1–Q3 are the architecture-defining goals: 05 §1.2 requires an ADR wherever a design choice trades one away.
| # | Goal | Where it is enforced today |
|---|---|---|
| Q1 | predictable low latency | EngineAllocationTests (hard zero) + B1/B2 in CI. The only goal with a gate that has ever failed a build |
| Q2 | durable correctness | conformance suite against real Postgres; lease + recovery scan. The measure — p99 ≤ 15 ms — is still unmeasured, because WP-50 shipped its chaos rig and not JournalBenchmarks. This cell also said the scenario — a node killed mid-flow — "has never been executed". That expired on 2026-08-01: tests/FlowX.Chaos SIGKILLs worker processes at a step boundary and recorded 0 duplicate effects against the guarantee and 0 lost instances over 10 000 flows per arm (benchmarks/QR2-chaos.md). It is measured, not gated — the rig is opt-in on FLOWX_CHAOS and no CI job runs it, which is WP-62's deliverable and is not started |
| Q3 | static knowability | ManifestIsComplete, flowx diff, the error catalogue. Same half-gap as V7 |
| Q4 | transport portability | nothing. One transport (WP-72) |
| Q5 | operational uniformity | nothing. No ActivitySource, no Meter, no exporter (P5) |
| 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 | nothing. CrossTenantAccessIsDenied is blocked on P4 and P3 |
Q1 is the only quality goal with an enforcing gate that has ever failed a build. Every other row is either a test that cannot fail yet or an empty cell. That is expected this early and it is not the same thing as being met, which is why this table says which is which.
flowchart TD
WP0["WP-0 · Quality gates<br/>CI, SAST, DAST, Sonar"]
WP1["WP-1 · Abstractions<br/>contract surface ✔"]
WP2["WP-2 · Core model<br/>StepGraph, ExecutionPlan"]
WP3["WP-3 · Benchmark harness<br/>B1–B3 measurable"]
WP4["WP-4 · Engine<br/>step loop, context pooling"]
WP5["WP-5 · Generator<br/>FlowPlanGenerator, linear"]
WP6["WP-6 · Manifest<br/>emit + schema validate"]
WP7["WP-7 · Hosting<br/>DI, options, health"]
WP8["WP-8 · HTTP plugin<br/>one endpoint, RFC 7807"]
WP9["WP-9 · CLI<br/>flowx graph"]
WP10["WP-10 · Sample<br/>ecommerce, 3 steps"]
WP11["WP-11 · Gate<br/>run kill criterion"]
WP12["WP-12 · Testing<br/>supported test context"]
WP12a["WP-12a · Sensitive<br/>read + manifest"]
WP13["WP-13 · Diagnostics<br/>FLOWX1014 · FLOWX1018"]
WP14["WP-14 · B12<br/>build overhead"]
WP15["WP-15 · DSL<br/>When · Otherwise"]
WP20["WP-20 · DSL<br/>Switch · Case · Default"]
WP24["WP-24 · DSL<br/>Parallel · FLOWX1013"]
WP29["WP-29 · DSL<br/>ForEach"]
WP33["WP-33 · DSL<br/>SubFlow · FLOWX1021"]
WP16["WP-16 · Step binding<br/>FLOWX1020"]
WP17["WP-17 · flowx diff<br/>breaking-change gate"]
WP18["WP-18 · Scale<br/>200 flows"]
WP19["WP-19 · Code fixes<br/>IDE quick actions"]
WP21["WP-21 · FLOWX1011<br/>predicate purity"]
WP25["WP-25 · FLOWX1011<br/>every context delegate"]
WP22["WP-22 · Manifest<br/>triggers · errors"]
WP26["WP-26 · FLOWX1025<br/>unreadable trigger"]
WP23["WP-23 · Scale<br/>methodology · linearity"]
WP27["WP-27 · FLOWX1020<br/>bind cost −89 %"]
WP28["WP-28 · Bisect<br/>4.9× regression"]
WP31["WP-31 · Cost gate<br/>relative · blocking"]
WP30["WP-30 · Fitness<br/>security gates"]
WP32["WP-32 · ADR-0014<br/>catalogue vs budget"]
WP48["WP-48 · Gate<br/>close P1 · B12 excepted"]
WP50["WP-50 · Harness first<br/>B7 · B8 · chaos rig"]
WP51["WP-51 · Contracts<br/>journal · lease · conformance"]
WP52["WP-52 · The seam<br/>runtime reads the profile"]
WP53["WP-53 · Postgres<br/>journal + lease"]
WP54["WP-54 · Redis<br/>lease store"]
WP55["WP-55 · Resume<br/>recovery scan · fencing"]
WP56["WP-56 · Outbox<br/>same tx · publisher"]
WP57["WP-57 · Compensation<br/>its own policies"]
WP58["WP-58 · FLOWX1007-1009<br/>determinism, as a set"]
WP59["WP-59 · FLOWX1006<br/>payload contract"]
WP60["WP-60 · FLOWX1012<br/>the fix becomes true"]
WP61["WP-61 · Replay<br/>determinism corpus"]
WP62["WP-62 · QR2<br/>10 000 flows, SIGKILL"]
WP63["WP-63 · AwaitSignal<br/>Delay · timers"]
WP64["WP-64 · flowx replay<br/>--mode inspect"]
WP70["WP-70 · Conformance<br/>suite as a package"]
WP71["WP-71 · Unchanged-file<br/>assertion, built first"]
WP72["WP-72 · Kafka"]
WP73["WP-73 · RabbitMQ"]
WP74["WP-74 · Azure Service Bus"]
WP75["WP-75 · Cron<br/>leader election"]
WP76["WP-76 · Should<br/>gRPC · MQTT · webhooks"]
WP0 --> WP1 --> WP2 --> WP3
WP2 --> WP4
WP3 --> WP4
WP4 --> WP5 --> WP6
WP5 --> WP7 --> WP8 --> WP10
WP6 --> WP9 --> WP10
WP10 --> WP11
WP10 --> WP12
WP5 --> WP12a
WP5 --> WP13
WP5 --> WP14
WP5 --> WP15 --> WP20 --> WP24 --> WP29 --> WP33
WP5 --> WP16 --> WP27
WP9 --> WP17
WP6 --> WP17 --> WP22 --> WP26
WP14 --> WP18 --> WP23 --> WP28 --> WP31
WP13 --> WP19
WP15 --> WP21 --> WP25
WP22 --> WP32
WP1 --> WP30
WP31 --> WP48
WP32 --> WP48
WP33 --> WP48
WP48 --> WP50 --> WP51 --> WP52
WP51 --> WP53
WP51 --> WP54
WP52 --> WP55
WP53 --> WP55
WP54 --> WP55
WP53 --> WP56
WP52 --> WP58
WP52 --> WP59
WP52 --> WP60
WP55 --> WP57
WP55 --> WP61
WP55 --> WP63
WP58 --> WP61
WP56 --> WP62
WP57 --> WP62
WP61 --> WP62
WP61 --> WP64
WP62 --> WP70 --> WP71
WP71 --> WP72
WP71 --> WP73
WP71 --> WP74
WP55 --> WP75
WP70 --> WP75
WP72 --> WP76
style WP3 fill:#fff3cd,stroke:#856404
style WP11 fill:#f8d7da,stroke:#721c24
style WP48 fill:#f8d7da,stroke:#721c24
style WP50 fill:#fff3cd,stroke:#856404
style WP71 fill:#fff3cd,stroke:#856404
style WP1 fill:#d4edda,stroke:#155724
style WP15 fill:#cfe2ff,stroke:#084298
style WP20 fill:#cfe2ff,stroke:#084298
style WP24 fill:#cfe2ff,stroke:#084298
style WP29 fill:#cfe2ff,stroke:#084298
style WP33 fill:#cfe2ff,stroke:#084298
style WP16 fill:#cfe2ff,stroke:#084298
style WP17 fill:#cfe2ff,stroke:#084298
style WP18 fill:#cfe2ff,stroke:#084298
style WP19 fill:#cfe2ff,stroke:#084298
style WP21 fill:#cfe2ff,stroke:#084298
style WP22 fill:#cfe2ff,stroke:#084298
style WP23 fill:#cfe2ff,stroke:#084298
style WP25 fill:#cfe2ff,stroke:#084298
style WP26 fill:#cfe2ff,stroke:#084298
style WP27 fill:#cfe2ff,stroke:#084298
style WP28 fill:#cfe2ff,stroke:#084298
style WP30 fill:#cfe2ff,stroke:#084298
style WP31 fill:#cfe2ff,stroke:#084298
style WP32 fill:#cfe2ff,stroke:#084298
style WP51 fill:#e2d9f3,stroke:#432874
style WP52 fill:#e2d9f3,stroke:#432874
style WP53 fill:#e2d9f3,stroke:#432874
style WP54 fill:#e2d9f3,stroke:#432874
style WP55 fill:#e2d9f3,stroke:#432874
style WP56 fill:#e2d9f3,stroke:#432874
style WP57 fill:#e2d9f3,stroke:#432874
style WP58 fill:#e2d9f3,stroke:#432874
style WP59 fill:#e2d9f3,stroke:#432874
style WP60 fill:#e2d9f3,stroke:#432874
style WP61 fill:#e2d9f3,stroke:#432874
style WP62 fill:#e2d9f3,stroke:#432874
style WP63 fill:#e2d9f3,stroke:#432874
style WP64 fill:#e2d9f3,stroke:#432874
style WP70 fill:#ffe5d0,stroke:#8a4b08
style WP72 fill:#ffe5d0,stroke:#8a4b08
style WP73 fill:#ffe5d0,stroke:#8a4b08
style WP74 fill:#ffe5d0,stroke:#8a4b08
style WP75 fill:#ffe5d0,stroke:#8a4b08
style WP76 fill:#ffe5d0,stroke:#8a4b08
WP-0 through WP-14 are P0 (complete); WP-15 through WP-47 are P1, in blue; WP-50 onward are P2, in purple, and WP-70 onward P3, in orange. The DSL chain WP-15 → 20 → 24 → 29 → 33 is complete: all five shapes the roadmap's full-DSL Must names now ship. WP-48 is a phase gate, red like WP-11: P0's gate answered a kill criterion, P1's records which criterion it is closing over.
Important
Work-package numbers here are the only authoritative ones, and they were collided
with — twice, and the second time after the warning was written. Packages executed
after P1 closed were labelled WP-49, WP-70, WP-71, WP-72 and WP-73 by the orchestration
that ran them, while this file already reserved WP-70–WP-76 for P3's transports. The
work is recorded under what it did — the [TriggerKind] marker is the abstraction
half of WP-70 landing early; the rest were unnumbered maintenance — and the P3
numbers below are unchanged.
Then it happened again. Generated HTTP endpoint registration was executed as "WP-74", which this file reserves for Azure Service Bus. It is recorded below as endpoint generation, without a number, and WP-74 still means Azure Service Bus. That this recurred after the rule below was written is the more useful finding than the collision itself: a warning in a document does not allocate anything, and the next honest step is a check that fails rather than a paragraph that asks.
This class of collision started with two diagnostics authored against FLOWX1028 in
separate branches on the same day, both having correctly read the index's next-free id.
The rule docs/diagnostics/README.md adopted then applies here too: claim the number
in this file first, in its own commit, before doing the work. Reading "the next free
number" is not enough when someone else is reading it at the same time.
It happened a fourth time on 2026-07-31, and the fourth one shows the rule cannot work
as written. Two records were authored as ADR-0017 — the manifest-freeze criteria
and the outbox publication decision — in separate worktrees, from the same base commit,
on the same day. Both authors followed the rule. Both claimed the number in
docs/adr/README.md in its own commit first, exactly as instructed; they simply claimed
it from the same starting point, so neither claim was visible to the other. The outbox
record was renumbered to ADR-0018 at merge.
A claim-first rule serialises nothing when the claimants branch from one commit. This is not a discipline failure to be re-taught — it is the third time the instruction has been followed and the collision has happened anyway. The fix is a check that fails, not another paragraph asking for care: a duplicated id across the reachable history is mechanically detectable, and detecting it at merge is what all four collisions needed and none had.
That check now exists. IdentifierAllocationTests in tests/FlowX.Architecture.Tests
fails the build when an identifier is allocated twice, across all three families that have
collided: ADR numbers, FLOWX diagnostic ids and the work-package numbers this file
allocates. For work packages it reads this file and nothing else — a definition site is
a ### WP-… heading or a | **WP-…** — row of a phase's package table — and it asserts
three things: no number defines two packages; every package sits inside the range its
phase reserved, read from the sentence below and from §6a's
allocator table; and no two phases reserve the same numbers. The middle one is the rule
WP-70 to WP-74 needed — none of those duplicated a number that had been spent, they took
numbers a later phase was holding, which no duplicate check can see.
What it catches, stated so nobody relies on more: a duplicate present in one working tree — a merge, a rebase onto the branch that took the number first, or one author writing both halves. It cannot see a duplicate that exists only across two unmerged branches. A test sees the tree it was built from; asking git about the other claim would need a ref this checkout does not have, and "which branches are live" is not a fact on disk. So it reports at the merge — which is exactly where all four of these were found by a human, and the only thing that was missing was a check that read it first.
Claiming the number in this file before the work starts is still the instruction, and it is still worth following: it makes the collision visible in a one-line diff instead of in a finished package. What has changed is that following it is no longer the only thing standing between two branches and a number that means two things.
The two yellow nodes are the same node WP-3 was. WP-50 and WP-71 are harnesses scheduled ahead of the things they measure, for the reason §2 has stated since P0 and which P1 then proved the hard way — see below.
P2 numbering starts at WP-50, and the gap is deliberate. WP-45 to WP-49 belong to
P1's close — WP-48 is this document's own package — and several are in flight on parallel
branches as this is written. Two rules were once authored against FLOWX1028
simultaneously in separate branches and collided at merge, which is why the diagnostics
index now requires an id to be claimed in one file, in its own commit, before the rule is
written. A work-package number is the same kind of object, and this file is where it is
claimed.
This diagram stopped at WP-19 for most of P1 and was wrong the whole time. It is recorded here rather than quietly corrected, because the failure is the same one the phase keeps finding elsewhere: a document that describes the plan as it was conceived rather than as it is executed stops being read, and then stops being maintained. P1 grew from five packages to nineteen — the extra fourteen were not scope creep but work each package surfaced, and a sequencing diagram that cannot show that is not a plan.
The DSL chain is strictly sequential (WP-15 → 20 → 24 → 29 → 33): every shape
touches the builder, the model, the analyzer, the emitter, StepGraph and the engine,
so no two can be built concurrently without fighting over the same six files. Everything
else in P1 ran in parallel batches of four, chosen for disjoint file ownership.
Three chains exist because one package kept exposing the next. WP-18 measured the
scale criterion and got a number its own noise swallowed; WP-23 rebuilt the methodology;
WP-28 bisected the 4.9× regression WP-23's numbers exposed; WP-31 built the gate that
would have caught it on the commit that caused it. Similarly WP-21 raised FLOWX1011
for When only, and WP-25 existed because WP-20 immediately added a second construct
under the identical rule.
WP-3 is scheduled before the engine on purpose. A performance budget that becomes measurable only after the thing it constrains is built is a budget that gets renegotiated instead of met. The benchmark harness measures an empty step loop first, so every subsequent commit is measured against a number that already exists.
That sentence about WP-3 was written as a principle. P1 then supplied the counterexample, and it is the reason this phase closes over an unmet criterion.
B12 — build overhead ≤ 8 % — was declared in 14-Performance §1 and had no harness through the whole of P0. Nothing measured it until WP-14, by which point the generator existed; nothing measured it at realistic scale until WP-18, by which point five DSL shapes and the derived error catalogue existed. The first honest number arrived at +18.4 % and the current one is +67.1 %. The budget was never renegotiated in words — it is renegotiated in fact, by being carried as an exception at §4. A 4.9× regression also merged in silence across four packages, because the only gate was absolute and the absolute gate was already red.
P2's budgets are in exactly the position B12 was in. B7 (durable step commit, p99
15 ms at 5 000 commits/s/node) and B8 (rehydration p99 8 ms) are stated in
14 §1 and measured by nothing: JournalBenchmarks does not
exist, and 14 §8 says so
plainly. This paragraph also said "QR2 — P2's entire Done-when — has no rig either", which
expired on 2026-08-01: WP-50 built the rig, ran it, and committed its results
(benchmarks/QR2-chaos.md). The budgets are what did not
move. So WP-50 comes first, and its exit criterion is a committed baseline and a
chaos verdict produced before there is a journal to measure. What can be measured before
the journal exists is not nothing: the store's commit latency under the exact transaction
shape 11 §5 specifies is a
property of Postgres, not of FlowX, and the chaos rig run against today's ephemeral engine
should report 10 000 lost instances — the honest floor QR2 is measured against. That is
the same move WP-3 made with an empty step loop. Neither of those two things happened in
that order: the rig arrived after the journal it measures, and it has no ephemeral mode,
so the floor it was supposed to establish first was never established at all. What it has
instead is a refusal — §5's WP-50 note
says what replaced it.
The DSL chain was sequential because of files, not caution: every shape touched the
builder, the model, the analyzer, the emitter, StepGraph and the engine. P2 splits the
same way, and the split is legible before any of it is written.
| Cannot run concurrently | Because |
|---|---|
| WP-51 → WP-52 → WP-55 → WP-61 → WP-62 | All four land in src/FlowX.Runtime — FlowEngine.cs, FlowExecutionContext.cs, ContextPool.cs — plus ExecutionPlan.cs in FlowX.Core. That is five files and it is the DSL chain's problem exactly. WP-52 also edits the analyzer, two ADRs, three docs and deletes a fitness test; two packages doing that at once merge badly |
| WP-57 after WP-55 | Compensation across a resume is a property of the resumed loop, not an addition to it. Written first, it is written against a loop that cannot yet resume |
| Can run concurrently | Because |
|---|---|
| WP-53 ∥ WP-54 | Two new plugin projects, no shared source. They share the conformance suite read-only, which is what makes a conformance suite worth writing first |
| WP-56 ∥ WP-55 | The outbox is a table, a publisher and a transaction boundary; once WP-51 fixes the schema, it touches the Postgres adapter and its own project, not the engine |
| WP-58 ∥ WP-59 ∥ WP-60, conditionally | Three diagnostics in three different files under src/FlowX.Compiler/Analysis — but all three touch FlowXDiagnostics.cs, AnalyzerReleases.Unshipped.md and docs/diagnostics/README.md. Three shared files is the six-file problem in miniature. They parallelise only if the ids are claimed in the diagnostics index first, in one commit, which is the rule that file already carries after two rules collided on FLOWX1028 |
| WP-63 ∥ WP-64 | A scheduler and a CLI verb; disjoint projects, both reading the journal contract |
WP-50 is the only package that can start immediately, because it is the only one that
touches nothing under src/.
Complete. The kill criterion passed at WP-11 and the phase's exit criteria are met; see CHECKLIST.md for live status. WP-12 through WP-14 are overruns — work P0 turned out to need once the reference sample was written, kept here with the phase that produced them rather than renumbered into P1.
Each package states its goal, the tests written first, the deliverable, and an exit criterion that is mechanically checkable.
| Goal | Every gate in 21-Quality-Gates runs before there is code to violate it |
| Tests first | n/a — this is the test infrastructure |
| Deliverable | .github/workflows/ci.yml (build, fitness, AOT, docs, attribution) · security.yml (CodeQL, Semgrep, Gitleaks, Trivy, SCA) · quality.yml (Sonar, coverage, Stryker) · .github/dependabot.yml · PR template with the Definition of Done |
| Exit | A deliberately introduced violation of each gate class is caught. Verified by pushing a throwaway branch per gate. |
| Depends on | — |
| Goal | FlowX.Abstractions — what all user code and every plugin reference |
| Tests first | AbstractionsHasNoDependencies, LayersPointInward, ContractSurfaceTests |
| Deliverable | Result<T>, Error, ErrorCategory, ICapability<,>, Flow<,>, IFlowBuilder<,>, contexts, trigger attributes, PolicySet |
| Exit | Solution compiles with zero warnings; fitness functions green; zero package references |
| Status | Done. 0 warnings, 30/30 fitness tests, 47 behavioural tests |
| Goal | The immutable data model an execution plan is made of. No I/O, no engine, no generator — just the shapes. |
| Tests first | StepGraphTests (construction, invariants) · ExecutionPlanTests (ordering, compensation stack) · NoCyclicDependencies |
| Deliverable | FlowX.Core: StepGraph, StepNode, ExecutionPlan, CompensationStack, FlowDescriptor, CapabilityDescriptor, PolicyChain |
| Exit | A three-step linear plan with one compensation is constructible, immutable, and asserts its own invariants. Mutation score ≥ 70 %. |
| Depends on | WP-1 |
| Status | Done. Built red → green; 58 tests; 98.5 % line / 95.6 % branch coverage. Mutation score not yet measured — Stryker is wired but unrun. |
| Goal | B1, B2 and B3 are measurable and gated in CI against a committed baseline |
| Tests first | The benchmarks are the tests |
| Deliverable | tests/FlowX.Benchmarks with BenchmarkDotNet · MemoryDiagnoser with a hard-zero assertion for B2 · baseline JSON committed · CI job failing on > 5 % regression |
| Exit | dotnet run -c Release --project tests/FlowX.Benchmarks reports B1–B3; CI fails on an injected 10 % regression |
| Depends on | WP-2 |
| Status | Done. Gate verified by injecting a 64 B allocation regression — rejected, exit 1. Results: docs/benchmarks |
| Goal | The step loop: execute an ExecutionPlan, thread a pooled context, honour deadlines, unwind compensation on failure |
| Tests first | FlowEngineTests — happy path, failure path, compensation ordering (strict reverse), deadline expiry, cancellation propagation · ContextPoolingTests — zero allocation across N executions |
| Deliverable | FlowX.Runtime: FlowEngine, CapabilityEngine, pooled FlowContext/CapabilityContext, IClock |
| Exit | B2 = 0 allocations on a 4-step flow; compensation ordering proven by test, not by inspection |
| Depends on | WP-2, WP-3 |
| Status | Done. 0 B on a 4-step flow (592 B → 0 B after three fixes found by measurement); 28 tests including concurrency; B1 = 169 ns against a 5 000 ns budget |
| Goal | Emit a compile-time ExecutionPlan from a Define method — linear steps only |
| Tests first | Generator snapshot tests (Verify) · EmittedCodeIsDebuggable (line directives present) · EveryDiagnosticIsHelpful · a golden-file test per DSL shape |
| Deliverable | FlowX.Compiler: FlowPlanGenerator (incremental), a syntax→model layer kept separate from emission, diagnostics FLOWX1001–1010 |
| Exit | The sample flow's plan is generated, readable, breakpoint-able; B1 ≤ 5 µs; build overhead ≤ 8 % on a 20-flow solution |
| Risk | R1. If the generator's model layer and emission layer blur together here, P1 becomes unmaintainable. Keep them separate from the first commit. |
| Depends on | WP-4 |
| Status | Partial. Generating end to end against a real compilation, exercised by the sample at WP-10. The diagnostics landed at WP-13 and budget B12 at WP-14. Remaining: the branching DSL — ForEach and SubFlow; When, Switch and Parallel ship. |
| Goal | flowx.manifest.json v0 emitted at build, validating against the committed schema |
| Tests first | ManifestValidatesAgainstSchema · ManifestContainsNoSecrets · ManifestIsDeterministic (same input → byte-identical output) |
| Deliverable | Manifest writer in FlowX.Compiler; schema already committed at schemas/flowx.manifest.schema.json |
| Exit | Sample build emits a manifest that validates; two consecutive builds are byte-identical |
| Depends on | WP-5 |
| Status | Done. Schema-valid against the committed schema with negative controls; byte-identical across runs and independent of flow discovery order. The on-disk file waits for the CLI at WP-9, because a generator must not do file IO. |
| Goal | AddFlowX() wires generated registrations; configuration validated at startup, not first use |
| Tests first | StartupValidationRejectsMisconfiguration (A05) · HealthCheckReportsReadiness |
| Deliverable | FlowX.Hosting: DI extensions, options with validation, health checks, graceful shutdown draining |
| Exit | A misconfigured host refuses to start with a message naming the setting; in-flight flows drain on SIGTERM |
| Status | Done. 19 tests. Validation runs at startup rather than first use, reports every problem at once, and names each setting. Drain refuses new work, is bounded, and reports whether it succeeded. |
| Depends on | WP-5 |
| Goal | Generated endpoint, generated binder, generated OpenAPI, RFC 7807 errors |
| Tests first | ErrorCategoryMapsToProblemDetails (all six) · IdempotencyKeyIsEnforced · TenantComesFromClaimsOnly (A07) · EgressIsAllowListed (A10) |
| Deliverable | plugins/FlowX.Http: endpoint generation, model binding, Problem Details mapping, OpenAPI document |
| Exit | Sample serves POST /api/v1/orders; ZAP baseline scan clean; B9 measured |
| Depends on | WP-7 |
| Status | Mostly done. The endpoint serves over a real TestServer with RFC 7807 mapping and claims-only tenant resolution; 59 tests. Generated endpoints, OpenAPI, ZAP and B9 all wait for the sample at WP-10. |
| Goal | flowx graph renders the manifest as Mermaid |
| Tests first | GraphOutputIsValidMermaid · GraphIsDeterministic |
| Deliverable | FlowX.Cli with the graph verb |
| Exit | Rendered graph of the sample parses with mmdc in CI |
| Depends on | WP-6 |
| Status | Done. Verified: mmdc renders the diagram to a 58 KB SVG, and the check is now a CI step. Also delivered flowx manifest, the on-disk artifact WP-6 deferred. |
| Goal | samples/ecommerce runs a real 3-step ephemeral flow over HTTP |
| Tests first | End-to-end test hitting the endpoint · CapabilityTestedWithoutHost (proves quality goal Q2) |
| Deliverable | Four capabilities, one flow, one endpoint, 20 tests, README |
| Exit | dotnet run serves the endpoint; ZAP baseline clean; flowx graph renders it |
| Depends on | WP-8, WP-9 |
| Status | Done, bar the ZAP baseline. The endpoint serves the flow's declared output over HTTP and as a NativeAOT binary; flowx graph renders the sample's real manifest; 20 tests. |
What the first consumer found. The sample was the first code written against the platform from outside it, and it found six defects that no test inside the platform could have:
| Found | Was |
|---|---|
Result<T> had no implicit conversions |
The documented capability style did not compile. Restored; the T = Error collision is real but is a loud CS0457, not a silent mis-resolution. |
Every step's #line directive pointed at the same line |
A fluent chain nests its receiver, so each invocation's span starts at the head of the chain. A breakpoint on step three landed on step one. |
.Return(...) was silently dropped |
The flow declared an output type that nothing produced. The endpoint returned a step count. Now generated as a static projection. |
MapFlow never read a request body |
A flow whose first step binds to a contract had nothing to bind to. |
AddFlowX registered the health-check type, not the check |
MapHealthChecks threw at startup; with AddHealthChecks it returned a probe that never ran. |
| The manifest embedded an absolute source path | Made the artifact non-reproducible, and shipped the build agent's directory layout. This cell cited "the determinism ADR-0005 requires"; that record requires the manifest be "complete, versioned, machine-readable" and says nothing about determinism, reproducibility or byte-identical builds. The requirement is real — flowx diff is meaningless without it — and it is enforced by tests rather than stated in an ADR, which is the actual gap. |
Two more surfaced while getting the suite green:
.Emit<T>()compiles into the plan and the manifest but publishes nothing. Now FLOWX1024, a warning — the first non-error diagnostic in the set — because the manifest promises consumers an event that does not arrive. (It was described here as "the only" one untilFLOWX1011andFLOWX1025joined it. Three warnings now, and they share a shape: the source is not wrong, the published artifact is incomplete.)- The engine's allocation budgets are Release-only assertions that silently measured
376 B of Debug scaffolding. CI runs Release and never saw it; every contributor
running
dotnet testdid. Now skipped in Debug with the reason.
| Goal | Answer the question P0 was built to answer |
| Deliverable | A benchmark report committed to docs/benchmarks/P0.md with the measured numbers, the hardware, and an explicit pass/fail against ADR-0002 |
| Exit | B1 ≤ 5 µs and B2 = 0 → proceed to P1. Otherwise → stop, write the ADR that supersedes ADR-0002, and re-plan. |
| Depends on | WP-10 |
| Status | Done. PASS. B1 = 172.3 ns against 5 000 ns (29× margin); B2 = 0 B exactly. Report at docs/benchmarks/P0.md; baseline re-recorded at 10 warmups / 30 iterations. |
P0 proceeds to P1. ADR-0002 stands: the compiled path delivers the budget it was chosen for.
The hardware is still shared, which was open item 5 against this work package. The
report answers that head-on rather than deferring: the measured margin is 29×, the
worst run-to-run variance ever observed on this container is a factor of 2.6, and 2.6
does not close 29. Where the hardware genuinely is not good enough — the 10–30 %
ratio comparisons in DispatchBenchmarks — the report says so and does not lean on
them. Open item 5 is closed on that reasoning, not on new hardware.
Two things the report explicitly does not claim:
- Not a p99 in the strict sense. BenchmarkDotNet's percentiles are over iteration means, not individual operations, and at ~170 ns a single operation cannot be timed without the timer costing more than the work. The worst iteration mean was 184.8 ns; a true operation-level p99 would have to be 27× the mean to breach the budget, and the usual cause of a tail that shape is a GC pause, which a zero-allocation path does not create.
- Not a retirement of risk R1. This measures runtime performance; R1 is generator maintenance cost. Build overhead (budget B12) was measured at WP-14 and passes at +0.4 % — see B12.md. The defect-count half of the clause is still not tracked.
| Goal | Constructing a CapabilityContext in a test costs one line, not nine |
| Why | Quality goal Q2 says a capability is testable by constructing it and calling it. It is — but CapabilityContext is abstract with nine members, so every consumer hand-writes the same stub. tests/Ecommerce.Tests/CapabilityTests.cs carries one; so will everybody else's first test file. Ceremony that every user pays is a platform defect, not a user problem. |
| Tests first | The sample's own capability tests, rewritten against it — if they do not get shorter, it is not worth shipping |
| Deliverable | FlowX.Testing with a context builder: fixed clock, fixed ids, seeded Random, overridable per test |
| Exit | CapabilityTests constructs its context in one expression and still pins every value it pins today |
| Depends on | WP-10 |
| Status | Done. TestCapabilityContext and TestFlowContext ship from src/FlowX.Testing; 30 tests. The sample's CapabilityTests builds its context in one expression and lost 27 lines of stub, pinning everything it pinned before. |
TestFlowContext was not in the original deliverable and is the more useful half. A
generated step dispatcher and a .Return(...) projection are ordinary methods that take
a FlowContext, so with a working typed bag they can be called directly — no engine, no
plan, no host. Fail(error) puts the context into the state a compensation actually
meets, which is otherwise unreachable.
Also found and fixed while here: LayersPointInward enumerates its projects in
hand-written [InlineData] rows, so adding FlowX.Testing created a src/ project that
no fitness function checked — it could have referenced anything at all and the theory
would have passed without looking at it. EverySourceProjectIsCoveredByTheLayeringRule
now fails on any unlisted project; it was verified by deleting the row and watching it
fail. A rule with a hand-maintained subject list needs a rule about the list.
Not shipped, and now said so in the docs: 19-SDK §6 described a
FlowTestHost with capability substitution, virtual time, crash simulation and a Kafka
integration harness. None of it exists. The section now separates what ships from what
is intended, rather than reading as a description of the current package.
| Goal | The attribute does something |
| Why | [Sensitive] exists on the contract surface and the sample applies it to PlaceOrder.PaymentToken. The compiler never reads it: it is absent from the manifest, and no redaction is generated. An attribute that looks like a control and is not one is worse than no attribute — a reviewer sees the token marked and concludes it is handled. Found while checking the OWASP A02 row in CHECKLIST.md, which claimed it reached the manifest. It does not. |
| Tests first | A test asserting a sensitive member is absent from any emitted log or error payload · a manifest test asserting the field is marked |
| Deliverable | CapabilityReader reads [Sensitive]; the manifest records it; the generator emits redaction for it |
| Exit | A flow whose input carries a sensitive member cannot emit that member's value into a log record, a Problem Details extension, or a trace attribute |
| Depends on | WP-5 |
| Status | Done for the one path that exists. The compiler reads the attribute in both spellings, the manifest carries a sensitive array per contract, and the flow's partial class carries SensitiveMembers. The HTTP endpoint replaces matching structured error detail with [redacted] before writing the body — proven end to end with a dispatcher that deliberately attaches a secret. |
The attribute previously documented itself as "applied by the generated serialiser… with no code path able to bypass it", while nothing read it at all. An attribute that reads as a control while doing nothing is worse than no attribute: a reviewer sees the field marked and concludes it is handled.
The exit criterion named three sinks — a log record, a Problem Details extension, and a trace attribute. Only the second exists. There is no logging scope, no journal and no replay view in this release, so there is nothing else to redact from. The criterion is met for the sink that exists and cannot be met for the two that do not; both the attribute's remarks and 12-Observability §4 say so in those words rather than implying coverage the release does not have. The remaining sinks arrive with the observability work and re-open this.
This paragraph said "in P3" until P1 closed, and it was wrong. The roadmap puts
observability in P5 and transport breadth in P3, and
12-Observability says P5 too — so a plan file was the only
document naming the wrong phase for its own follow-up. Corrected rather than left, because
RedactionCannotBeBypassed is blocked on exactly these sinks and a blocker pointing at the
wrong phase is a blocker nobody schedules. The journal arrives earlier, in P2, which
means P2 creates a sink for sensitive values two phases before the package that redacts
them: WP-52 must not journal a
[Sensitive] member in the clear and leave it for P5.
Redaction matches by member name, case-insensitively, because the wire contract is
camelCase and the member is PascalCase — a capability writing .With("paymentToken", …)
is naming PlaceOrder.PaymentToken, and a case-sensitive match would let through exactly
the spelling people write. The value is replaced with [redacted] rather than dropped: a
key that silently vanishes reads as a field the server never received.
Also found and fixed while here: the manifest listed a compensation only as a name
on the step it undoes. inventory.release had no entry in capabilities, so its
authorisation stance (Internal), its side effects and its idempotency reached
nothing, and flowx diff could not have seen a breaking change to one. A compensation
is a capability that happens to run backwards; StepModel.Compensation is now a whole
StepModel rather than three loose strings, and the manifest lists it.
| Goal | Every diagnostic the docs call a compile error is one |
| Why | FLOWX1014 — "a retry policy on a non-idempotent capability is a compile error" — is the safety property this repository advertises most loudly. 07-Capability-Model.md said "FlowX will not let you retry something that is unsafe to retry"; the diagnostics index listed it as preventing a duplicate charge; the sample README told the reader to try it. Nothing raised it. .WithPolicy(...) stored the argument's source text, so no rule could ask what was in the set. FLOWX1018 was in the same state. |
| Tests first | A generator test per rule, both directions · the sample itself, built with a Retry attached to payment.capture |
| Deliverable | PolicySetReader resolves a named set to the policies it declares; FLOWX1014 and FLOWX1018 raised; policies reach the manifest |
| Exit | Adding .WithPolicy(retry) to the sample's CapturePayment fails the build with FLOWX1014 |
| Depends on | WP-5 |
| Status | Done. All four are raised, tested in both directions, and verified against the real sample rather than only the harness. Every diagnostic the docs call a compile error now is one. |
A policy set is declared as a fluent chain, so reading one is the same problem as
reading a Define body and reuses the same FlowChainWalker. A set that is not a field
or property initialiser — one built by a method call — cannot be inspected at compile
time, and the reader returns nothing rather than guessing: a diagnostic derived from a
guess is one nobody can act on.
The manifest now carries each step's policies, which the schema had declared and the
writer had never emitted. That required duplicating the kind-to-stage mapping into the
compiler, because it targets netstandard2.0 and cannot reference FlowX.Abstractions.
An unpinned copy of a safety ordering is exactly what drifts silently, so
PolicyStagesMatchTheAbstraction reads the real mapping by reflection and fails on any
disagreement, in both directions. It was verified by changing one entry and watching it
fail.
FLOWX1003 and FLOWX1004 needed a DiagnosticAnalyzer rather than more generator
work, because they read a capability's dependencies and no flow mentions those. Being an
analyzer also means they apply to every capability in the compilation, including one no
flow has a step for yet — a capability that violates Q4 is wrong whether or not anything
calls it. Generic wrappers are unwrapped, so a single Lazy<> cannot defeat either rule,
and the generated dispatcher is exempt because it legitimately holds every capability its
flow invokes.
FLOWX1003 has a limit, and the page says so. It matches a list of transport
namespaces; there is no general way to recognise a transport. The sound alternative is an
attribute applied by transport authors, which is worth nothing until they adopt it. A
clean build means "no known transport", not proof, and the documentation says that
rather than implying coverage it does not have.
| Goal | Measure the number ADR-0002's revisit clause depends on |
| Why | ADR-0002 says to revisit the whole compile-time decision when "build overhead > 8 % sustained". Nothing measured build overhead, so the clause could never have fired. The budget was declared in 14-Performance §1 and left unmeasured through P0. |
| Tests first | The benchmark itself is the test; committed to the baseline like every other budget |
| Deliverable | CompilerBenchmarks (the file 14-Performance §7 already named for B12) and a report |
| Exit | A number for build overhead, with an explicit pass or fail against 8 % |
| Depends on | WP-5 |
| Status | Done. PASS at +0.4 % against a +8 % budget. Report at docs/benchmarks/B12.md. |
It took two measurements, and the first one was the wrong shape.
CompilerBenchmarks prices the generator in isolation at ~2.9 ms per compilation
containing one flow. That number is real and committed to the baseline, but it is not a
build-overhead ratio: its control compiled a file with no plan and no dispatcher in it,
so most of the difference was binding code the control did not contain — work an
application written without FlowX would have hand-written and paid for anyway. Reporting
that ratio as build overhead would have overstated the cost by more than an order of
magnitude, which is the same class of claim WP-10 through WP-13 spent their time
removing. It was written up as not settling the budget, with what would settle it
spelled out.
scripts/measure-build-overhead.sh then did that: two builds of the reference sample
producing the same final compilation, differing only in whether the generator ran.
Ecommerce.csproj carries an MSBuild condition (FlowXGeneratorDisabled) that drops the
analyzer and compiles the previously generated sources as ordinary files, so the sample
genuinely builds both ways.
Over 15 alternating rounds: 2 613 ms with, 2 603 ms without — +0.4 % against a +8 % budget. The result is legible from the isolated number: ~3 ms of generator against a ~2.6 s project build is about a tenth of a percent.
The report states what the figure cannot support. The two arms' ranges overlap and the within-arm spread is 14 %, so this cannot distinguish +0.4 % from −0.4 %. It is evidence that the overhead is nowhere near 8 %, not evidence that it is exactly 0.4 %. Sharpening it needs dedicated hardware, and no decision waits on the difference between 0.4 % and 2 %.
The roadmap's P1 exit criterion "a 200-flow synthetic solution builds with ≤ 8 % overhead" is not met. The measured figure is +67.1 %, 95 % CI [+61.9, +73.6]. It is failed by 59 points. Nothing about it is close, and no rounding, hardware or methodology argument changes that: the measurement was taken on a quiet machine with a 10.6 % A/A noise floor, and its predecessor returned
INCONCLUSIVErather than manufacture a verdict it could not support.The phase is closed anyway, deliberately, by the repository owner, on 2026-07-31. Performance is set aside; the criterion is carried into P2 as a named, accepted exception. It is not deferred, not quietly dropped, and not restated as met.
Why this box exists at all. A phase closed over a failing criterion that a later reader has to reconstruct from a benchmark file is precisely the quiet drift this project has spent P1 removing — a gate claimed and absent, a diagnostic documented and unraised, a fitness function named and never written. Closing P1 by hiding its one failure would be the same defect in the plan rather than in the code. So it is stated first, with the number, before anything P1 succeeded at.
What is actually known, so the exception can be reasoned about rather than re-litigated:
FlowPlanGeneratoris 90.5 % of the marginal per-flow cost.StepBindingAnalyzer, once 37 %, is 1.2 % after WP-27's 89 % cut — a real saving that is invisible in the criterion.- Optimising all eight other components perfectly still leaves the criterion failing by 54 points. This is not a profiling backlog.
- Reaching +8 % needs the generator at ~2.8 ms/flow against today's 23.25 — an ~8× cut — and the bulk of what would have to go is the derived error catalogue that populates the manifest's
errorsfield.- Growth is linear (R² 0.994). The design scales; the constant is too large. A superlinear result would have been a kill-criterion-shaped finding and it is not what was measured.
- A relative cost gate is blocking (WP-31, threshold +2 % on a deterministic allocation proxy), so the exception cannot silently get worse. It prints
ABSOLUTE CRITERION — FAILon every run, including passing ones.The open decision is not "optimise more". It is ADR-0014 — the derived error catalogue or the budget, one of them gives way — and it is still Proposed. Closing P1 does not decide it; it decides only that P2 does not wait for it.
Risk R1's trigger has fired and its named action has not been taken. The roadmap says: freeze features; invest in the generator's test harness and model layer. Features were not frozen. That is part of what is being accepted here, and it is written down so the next phase gate re-scores it against a fact rather than an impression.
The roadmap's P1 scope, item by item, with what is already done from P0's overruns marked. P1 exists to mitigate risk R1 — generator complexity becoming our own legacy — so its exit criteria are about maintainability and scale, not features.
| Roadmap item | Where it stands |
|---|---|
Full DSL: When/Otherwise, Switch, Parallel, ForEach, SubFlow |
Done — WP-15, WP-20, WP-24, WP-29, WP-33. All five ship end to end, with FLOWX1013 and FLOWX1021 raised. One documented mode does not: SubFlow(AwaitCompletion) is refused by FLOWX1026, because it needs a durable suspension point and P2 has not built one |
| Contract-compatibility checking | WP-16, done — as FLOWX1020, step binding |
| Diagnostics FLOWX1001–1023 with help URIs | Nearly done, and this row has twice overstated it — first as all raised, then with a stale list. Raised today: 1001–1005, 1010, 1011, 1013, 1014, 1015, 1016, 1017, 1018, 1019, 1020, 1021, 1023, 1024, 1025, 1026. Reserved and raised by nothing: 1006, 1007–1009, 1012, 1022 — each blocked on something named, not merely undone. 1007–1009 were blocked on severity, not analysis: ADR-0003 used to make them Info under Ephemeral, which was the only profile that ran, so they would have shipped doing nothing anywhere. That clause did not survive: WP-58 rejected Info outright and ADR-0003 now records the rejection. All three shipped on 2026-07-31 — this row's "reserved and raised by nothing" list is down to 1006, 1012 and 1022. 1012 is implementable today and was deliberately not raised: it would fire on every compensable flow including the sample, and its only available fix — Profile = Durable — changed nothing while there was no journal. Both blockers were discharged by WP-52 on 2026-07-31; all four remain unwritten (WP-58, WP-60). A rule whose fix is a lie is worse than an unraised id. 1006 needs a serialiser P2 chooses; 1022 needs two manifests and is flowx diff's job |
| Manifest completeness | WP-22, done — triggers and per-capability errors were declared in the schema and emitted by nothing |
| Generator snapshot tests | Done at WP-5 and extended since |
| Readable emitted code | Done — on disk under obj/generated, with per-step #line directives (fixed at WP-10) |
| Build-overhead budget B12 | FAILING at +67.1 % [+61.9, +73.6] against +8 %. This row read "Done at WP-14, +0.4 % against +8 %" until 2026-07-31 — a figure measured before the capability error catalogue existed, publicly retracted in 14 §1.1, and left standing here about sixty lines below this section's own FAIL box. A retracted number surviving in a summary table is how a reader skims a failing budget as met |
Should: flowx diff v1 |
WP-17, done |
| Should: IDE code fixes | WP-19, done |
Exit criteria, from the roadmap — one of three unmet. Each verdict below was produced by a command in this working tree on 2026-07-31, not read off an earlier document:
| Criterion | Verdict | Evidence |
|---|---|---|
| a 200-flow synthetic solution builds with ≤ 8 % overhead | FAIL at +67.1 % [+61.9, +73.6]; 50 flows +46.5 % | WP-43, quiet machine, 10.6 % A/A noise floor — B12-scale.md. Closed as an accepted exception, see the box above |
every diagnostic passes EveryDiagnosticIsHelpful |
PASS | CompilerFitnessTests.EveryDiagnosticIsHelpful, green. Re-run rather than assumed: the P0 gate's own report was written after a claim about a diagnostic turned out to be untrue |
| emitted code is breakpoint-able | PASS | FlowPlanGeneratorTests.EachStepGetsItsOwnLineDirective, plus five more line-directive tests across the emitter, Fail, and step-input mapping — all green. This one is pinned by test rather than by inspection precisely because WP-10 found every #line pointing at the same line |
What P1 delivered, against the roadmap's own lists.
| Roadmap Must | Outcome |
|---|---|
Full DSL: When/Otherwise, Switch, Parallel, ForEach, SubFlow |
Met. All five ship end to end. One documented mode does not: SubFlow(AwaitCompletion) is refused by FLOWX1026 for want of a durable suspension point |
| Contract-compatibility checking | Met as FLOWX1020, step binding (WP-16) |
| Diagnostics with fixes and help URIs | Met for every id that exists — 23 pages, every help URI asserted to resolve. Five ids remain reserved and unraised, each with a named blocker |
| Generator snapshot tests | Met (WP-5, extended since; WP-20 made the harness actually compile its output) |
| Readable emitted code | Met — on disk under obj/generated, per-step #line directives |
| Build-overhead budget B12 | Measured, and failing. The Must was to have the budget, and it is measured, gated relatively, and reported honestly. The exit criterion on the same number is the exception above |
| Roadmap Should | Outcome |
|---|---|
| IDE code fixes | Met — WP-19, three diagnostics, in a separate assembly |
flowx diff v1 |
Met — WP-17, 29 rules, wired into CI |
P1 also carried P0's two unshipped Should items. FlowTestHost shipped at WP-49,
after FlowX.Testing had shipped context doubles under its name at WP-12 — three
documents described a host that ran flows while the package contained neither. It runs a
real engine with capabilities substituted by id; For<TFlow>() is deliberately not
offered, because discovering the generated dispatcher would need reflection over
generated members and constraint C2 forbids it. dotnet new flowx is being attempted
in the current round, after being carried forward unstarted twice. Until it lands, every
document that names it — 03 §12,
19, 20 — is describing a command that does not
run, and none of them should be softened to hide it: the reason it is worth noticing is
that two carries is how an item stops being scope and becomes furniture.
What P1 hands to P2, in three named piles rather than as "remaining work":
- Five reserved diagnostics.
FLOWX1006(state must be serialisable),FLOWX1007–FLOWX1009(determinism in durable flows) andFLOWX1012(compensable-and-ephemeral). Four of the five are blocked on severity, not on analysis —PredicatePurityAnalyzeralready does the work, and WP-25 rebuilt it around a table of constructs so the next rule is a row rather than a code path. They ship the day the runtime honours a profile.FLOWX1006needs the generated STJ serialiser the journal's payload path brings with it. (FLOWX1022is also reserved and is not P2's: it isflowx diff's question asked of two manifests, and an analyzer sees one compilation.) - Three blocked fitness functions, each named with its blocker rather than skipped:
CrossTenantAccessIsDenied(needs P4's policy execution and P2's journal for the audit event),RedactionCannotBeBypassed(needs sinks that do not exist — logs, traces, journal, replay),PluginsPassConformance(needs a conformance suite and a second plugin, both P3 — WP-51 has since built a conformance project, but it holds journal and lease suites and no trigger suite, so this blocker is unchanged). A test named after a gate is itself a claim of coverage, so none of them exists as a green stub. - The build-overhead exception, and ADR-0014 still open behind it.
Two further items travel with the phase and are not in any of those piles because they are
defects rather than scope: the derived error catalogue's remaining withheld rate
(42 % of a 38-capability corpus after WP-37), and 07-Capability-Model §4 prescribing a
layout — contracts in a dedicated assembly — under which the catalogue scan stops at the
assembly boundary and a conforming team gets no catalogue at all.
| Goal | A flow can express a condition, a fan-out and a loop, not only a straight line |
| Why | P0 shipped linear flows only, and said so. Every real saga branches; a platform that cannot express When sends its users back to writing the control flow by hand, which is the thing it exists to replace. ADR-0010 chose a C# DSL precisely so branching stays type-checked. |
| Tests first | A walker test per shape · a golden emitted file per shape · a runtime test proving each shape executes · StepGraph invariant tests for a non-linear graph |
| Deliverable | When/Otherwise, Switch, Parallel, ForEach, SubFlow through the whole stack: builder surface, model, analysis, emission, StepGraph, engine |
| Exit | A flow using every shape compiles, runs, appears correctly in the manifest, and renders in flowx graph |
| Depends on | WP-5 |
| Status | When / Otherwise done, through the whole stack. Switch followed at WP-20, Parallel at WP-24. ForEach and SubFlow are still open; the exit criterion above is not met until they land. |
The engine's step loop walked an array by index, and budget B2 is a hard zero — so the shape of the change was constrained before it was designed: no allocation per step, no iterator, no closure per branch.
Branching did not make the graph a graph. A conditional compiles into the same flat
step array as everything else — a StepKind.Branch carrying the false target, and a
StepKind.Jump closing the then block. The engine gained no branch stack and no
recursion; the only change to the loop is that the index sometimes moves by more than
one. A tree of nested plan objects would have read more naturally and would have cost an
enumerator per level on the hot path. TakingEitherBranchOfAConditionalAllocatesNothing
asserts 0 B on both directions in Release, so B2 survived the DSL's most-used shape.
Termination is not an assumption: StepGraph rejects any target that is out of range or
points backwards, which is why that check exists and why the while loop is safe.
The manifest does not carry the predicate. This package proposed adding a
condition string to $defs/step holding the predicate's source text, so flowx graph
could label the branches. Rejected. Predicate text carries business values —
order.Total > 80 — and the manifest's rule is structure only, never values. That rule
is what ManifestContainsNoSecrets asserts and what makes the file safe to publish to
consumers who are not entitled to the thresholds inside it. A renderer wanting labels can
read them from source, where the reader is already trusted.
Newly surfaced by this package, and open:
FLOWX1011(predicate purity) is unimplemented. It was reserved when nothing could declare a predicate. Something can now, andFlowErrors.PredicateFaileddocuments the rule at run time that no analyzer enforces at build time. Closed by WP-21.- The
.Step<TCapability, TStepIn>(map)overload is not honoured byFlowAnalyzerorFlowEmitter— it parses and is then ignored, which is worse than not existing. Closed by WP-41. - Triggers and capability
errorsare in the manifest schema but never emitted.
| Goal | A flow can branch on a value, not only on a yes/no question |
| Why | The second shape in 08 §3.2, documented since before anything could compile it. Written as nested Whens it costs one predicate per arm and reads nothing like the decision it is. |
| Tests first | Walker tests for the nested case blocks · model tests for the layout arithmetic · a pinned emitted file · runtime tests per arm and for the miss · an allocation theory over every arm · StepGraph invariant tests |
| Deliverable | Switch/Case/Default through the whole stack: builder surface, model, analysis, emission, StepGraph, engine |
| Exit | Every arm executes, the default catches a miss, 0 B on every arm, and the manifest shows the shape without the values |
| Depends on | WP-15 |
| Status | Done. |
A switch is one node, not a chain of branches. It could have been desugared into
n StepKind.Branch steps comparing the selector against each case in turn, which would
have needed no new step kind, no new engine code and no change to IStepDispatcher. It
was rejected for two reasons: it re-evaluates the selector once per arm, and it publishes
the author's Switch to the manifest as a nest of conditionals — the manifest's whole
job is to show the shape that was declared. So StepKind.Switch carries a target per
case plus a default target, and the dispatcher gained
int Select(int stepIndex, FlowContext ctx) returning the matching arm, or -1.
Select returns an int, not the value it selected. Returning the value means
returning it as object — which boxes an enum on every switch a flow takes and loses
B2 — or making the method generic, which the engine cannot call because it does not know
the type. TakingAnyCaseOfASwitchAllocatesNothing covers all three arms, the miss, and
an out-of-range arm, and measures 0 B on every one.
A miss with no Default falls through. Requiring a Default would force
.Default(b => { }) onto every switch that legitimately special-cases a few values, and
would still not make the switch exhaustive — an enum can hold a value no member
declares. So the rule is When's: a branch nobody took does nothing. Recorded in
08 §3.2 and on ISwitchBuilder
itself, because a reader hits one of those two before they hit this file.
The manifest carries neither the selector nor the case values, for exactly the reason
WP-15 refused the predicate: Channel.Wholesale is a business value, and the file's rule
is structure only. The cases appear as positional branches, empty ones included, so a
reader sees that the flow branches three ways and what is in each arm.
Newly surfaced by this package, and open:
.Fail(error)is declared on the builder, listed in the §4 table, and not modelled by the compiler. A block whose only call is.Fail(...)compiles to an empty block — so08 §3.2's own example,.Default(b => b.Fail(OrderErrors.UnsupportedChannel)), currently compiles to a default that does nothing. Documented in place rather than left to be discovered.FLOWX1011now has a second unenforced subject: aSwitchselector obeys the same purity rule as aWhenpredicate, and nothing checks either.
| Goal | A flow whose steps cannot pass values to each other fails the build |
| Why | The generated dispatcher binds by type: ctx.Get<TInput>(). If no earlier step produced that type the flow compiles and throws on the first request — exactly the class of failure this platform exists to move to build time. |
| Tests first | Both directions per rule, plus the reference sample's real shape as a case that must stay silent |
| Deliverable | FLOWX1020 raised by a DiagnosticAnalyzer, with its documentation page |
| Exit | Reordering the sample's steps fails its build; the unmodified sample still builds clean |
| Depends on | WP-5 |
| Status | Done. Verified by reordering the real sample's steps: FLOWX1020 fires at the offending .Step<> type argument, naming the missing type and what the context can supply. |
The id is 1020, not the 1022 this package was originally opened against. The
reserved list assigns 1019–1022 to deadline coherence, step binding, sub-flow
cycles and contract compatibility, in that order — and 08-Flow-Definition.md already
documented this exact check as FLOWX1020, with an example message nearly identical to
the one now emitted, as did FlowContext.Get<T> and FlowExecutionContext.Get<T>.
Shipping it as 1022 would have left three places pointing at a number nothing raised.
FLOWX1022 stays reserved for contract compatibility across versions — the
analyzer counterpart of flowx diff, which is a different question.
The analyzer states its own limits rather than implying coverage it lacks: it checks by
exact declared type, because the context is a dictionary keyed on typeof(T) and a
base-class match would miss at run time; it stops at the first chain method it does not
understand, because a hidden branch may produce the next step's type; and it stays
silent on the explicit-mapping overload, which is the fix it recommends.
| Goal | The manifest earns its keep: a breaking change is caught in CI, not by a consumer |
| Why | ADR-0005 makes the manifest a build artifact so it can be compared. Until something compares two of them, the artifact is a description nobody acts on. |
| Tests first | One test per classification rule, in both directions |
| Deliverable | flowx diff --old --new, text and JSON output, non-zero exit on a breaking change |
| Exit | Removing a capability, narrowing a contract, or loosening an authorisation stance each fail; a line-number change does not |
| Depends on | WP-6, WP-9 |
| Status | Done. 29 rules; wired into CI against a committed baseline. |
The compatibility unit is id@major, not id@version. Exact-version keying reports
a patch bump as a removal plus an addition; identity-only keying lets two side-by-side
majors collide and hides a real removal. Keying on the major gets both right, and
removes any "was the version bumped?" waiver — bumping the major is publishing a new
contract, and deleting the old one is what breaks people.
Three classifications worth recording, because each could reasonably have gone the other way:
[Sensitive]is asymmetric. Marking a member is additive — a gate that failed the build when an engineer marks a password teaches engineers not to mark passwords. Un-marking is breaking, and the more serious half: the value then reaches logs, traces and a journal retained for the replay window, with no signature change to catch it.- Both directions of an authorisation change are breaking, under separate codes. Relaxing is a security regression. Tightening is the right change and still denies callers that worked yesterday — the gate is not saying it is wrong, it is saying that shipping it unannounced turns a security improvement into an outage.
- Adding a side effect is breaking. Nothing about the call changes, but
sideEffectsis what blast-radius review reads and what decides whether an agent confirms before invoking a tool. Every assessment made against the baseline is stale.
Deliberately never reported: source file:line, application.version, a flow's steps,
and array order. A gate that fires on every build is a gate people delete.
| Goal | The roadmap's P1 exit criterion, measured rather than assumed |
| Why | B12 passed at +0.4 % on a sample with one flow. The generator's cost scales with flows; the budget was written for a realistic solution, and one flow does not test it. Superlinear behaviour would be a far more important finding than the ratio. |
| Deliverable | A synthetic-project generator, a measurement script, and a report |
| Exit | 200 flows build within the 8 % budget, and the cost is shown to scale linearly |
| Depends on | WP-14 |
| Status | Harness done, budget FAILS. WP-18's provisional +23 % is superseded by WP-23's +18.4 %, 95 % CI [+16.3, +19.9], against +8 %. See B12-scale. |
WP-18's +23 % was directionally right and numerically inflated. It was measured under load average 2–34 with an 85 % spread within a single arm — error bars wider than the budget being tested. WP-23 rebuilt the methodology and the answer moved by five points, which is roughly what a noise floor that large is worth.
The growth is linear, and that is the more important finding. ≈ 3 ms fixed + 9.54 ms per flow, R² 0.994, replicated to within 1 % with the compiler server off. The power-law exponent is 0.91, CI [0.82, 1.08]. No interval on either metric reaches 1.2. Superlinear growth would have meant the generator does not survive a real solution; linear growth means the constant is simply too large.
Where the cost is, from Roslyn's own /reportanalyzer, marginal per flow:
| ms/flow | share | |
|---|---|---|
FlowPlanGenerator |
8.07 | 62 % |
StepBindingAnalyzer (FLOWX1020) |
4.77 | 37 % |
CapabilityAnalyzer |
0.15 | 1 % |
A third of the bill is contract-compatibility checking rather than generation, which is a
different cost/benefit conversation from "the generator is slow". WP-18's guess was
wrong: it named CapabilityAnalyzer as the first place to look, on the reasoning that
it visits ~2 500 named types; it costs 51 ms of 12 s. Reducing this is its own package and
needs a profiler.
FlowPlanGeneratoris now ≈ 29 ms per flow, against the 8.07 recorded above. Measured twice, independently: 28.7 ms/flow at 200 flows by WP-27, and +29.70 ms/flow at 50 flows in review on a quiet machine (load 2.52), CI [+1410, +1590] ms, verdict FAIL at +47.8 – +55.2 %.StepBindingAnalyzerandCapabilityAnalyzerboth reproduced their old numbers to within 7 %, which is the control that makes the third reading trustworthy.This slipped in because the scale job is advisory. That was the right call while the measurement could not separate signal from load — but the cost of it is now visible: a 3× regression merged across four packages and nothing said a word. The job cannot simply be made blocking while the criterion is failing, so the gap needed a different answer: a relative gate against the committed figure rather than an absolute one against the budget. Built at WP-31, and it would have caught this — see below.
Bisected at WP-28. One commit, not a spread:
c7ae70a, WP-22's trigger and error-catalogue emission, took the generator from 5.60 → 27.28 ms/flow (×4.9).Switch,Paralleland the sort-key fix cost nothing detectable, andFLOWX1011never appeared in the generator's number at all, being an analyzer. Stubbing the catalogue read on currentdevreturns it to 7.36 — the control that closes the argument.The mechanism was not the obvious one. Of the reader's 1 229 ms at 50 flows, 1 118 is
SemanticModel.GetTypeInfoacross 39 964 calls;GetSymbolInfois 13 ms. Asking anInvocationExpressionits type costs 0.17 ms because it resolves the overload, and 4 268 of those account for 64 % of the bill — while only 4 % of the 55 790 nodes visited areError-typed at all.Rootsasked the same question about the same node twice, once in theDescendantNodespredicate and again in theWhereafter it: 20 762 of the 39 964 binds were that duplicate. Fixed — 25.4 → 20.4 ms/flow, faster in 6 of 6 paired rounds, with the sample's manifest byte-identical to the committed baseline.The remaining ~80 % is what the feature costs, and that is now a product question. Deriving
errorsfrom code means binding every capability body, so generator cost tracks how much capability implementation exists, not how many flows do — 262 capability types at ~3 ms each, on bodies the synthetic project keeps deliberately minimal, so a real project pays more. Three larger cuts were considered and refused: skipping binds in type-only syntactic positions (fails silently if the position list is wrong), caching a factory's catalogue across capabilities (staleness), and makingerrorsopt-in (changes emission). The open question is whether an enumerated failure catalogue is worth roughly two thirds of the compile-time budget — not another profiling pass.
The harness can now decline to answer. Exit 2 = INCONCLUSIVE, returned when the within-arm spread is too wide for a verdict to mean anything. It fired on WP-23's own first run — load peaking at 46.7 on four cores — and refused to publish a +23.3 % point estimate whose CI was [+7.6, +42.6]. A measurement that reports "I cannot tell" is worth more than one that always answers.
The CI job stays advisory (continue-on-error: true) because the report records a
FAIL, which is the contract performance.yml already stated. When it becomes blocking,
exit 2 must remain non-blocking: inconclusive is not evidence of a regression, and
failing a pull request for it fails it for the weather.
| Goal | Every diagnostic that has one obvious fix offers it |
| Why | A diagnostic tells you that you are wrong; a code fix tells you what right looks like. FLOWX1001 (add partial) and FLOWX1010 (declare an authorisation stance) are mechanical, and leaving them manual is friction on every new flow. |
| Deliverable | A CodeFixProvider for the mechanically fixable diagnostics |
| Exit | The fix applies cleanly in a test harness and produces compiling code |
| Depends on | WP-13 |
| Status | Done. FLOWX1001, FLOWX1010 and FLOWX1017. Tests apply each fix to the reference sample's own files and assert byte equality with what is on disk. |
The fixes ship in their own assembly, and it must not reference the compiler. The
original instruction for this package said to add a ProjectReference from
FlowX.Compiler.CodeFixes to FlowX.Compiler. That was wrong: both are
DevelopmentDependency analyzer assets, a development dependency does not flow
transitively, and the host would be handed an assembly whose reference it cannot
resolve. A compiler extension that fails to load is dropped in silence — it would
have surfaced as "the quick actions do not appear on my machine". The diagnostic ids are
string literals instead, pinned against FlowXDiagnostics.All by a fitness test in the
test project, which may reference both; a second fitness test asserts the seam itself.
What the FLOWX1010 fix refuses is the substance of it. It offers Authenticated
and Internal only. Public would clear a security error with one keystroke and make
the capability world-readable — the outcome the rule exists to prevent. Permission and
Policy each need a name nothing in the source implies, and nothing rejects the stance
without it, so a fix emitting one would produce a declaration that compiles, reads as
enforced, and reaches the manifest as a claim about access control that nothing backs.
There is no Fix All for it either.
Not fixed, deliberately: FLOWX1014 (the only mechanical repairs are asserting an
idempotency the tool cannot verify, or deleting the retry — and the diagnostic is what
prevents a duplicate charge), FLOWX1018 (the repair is splitting a capability in two),
and FLOWX1024 (suppression needs a FLOWX-DEBT owner and expiry a tool cannot
invent).
| Goal | The determinism rule stated in two places is enforced in one |
| Why | FLOWX1011 was reserved when nothing in the DSL could declare a predicate. WP-15 shipped When, so predicates exist — and the rule was documented in 08 §3.1 and restated by FlowErrors.PredicateFailed at run time while no analyzer checked it. A documented compile error that nothing raises is the failure mode P1 exists to remove |
| Deliverable | PredicatePurityAnalyzer, its documentation page, and a code fix for the type-exact rewrites |
| Exit | An impure predicate fails the reference sample's own build; a legitimate one stays silent |
| Depends on | WP-15 |
| Status | Done. Verified by injecting DateTime.UtcNow into the real sample: the build fails at the exact span, and reverts clean. |
It separates what it proves from what it merely lists, and says which is which.
Scope is a proof: whether a symbol was declared inside the predicate or outside it comes
from DeclaringSyntaxReferences and cannot be evaded, which is what catches an injected
service reached through this — no catalogue of impure types would ever contain the
application's own interface. The known-impure statics (DateTime.UtcNow, Guid.NewGuid,
Random, Environment, File, HttpClient, …) are a list, not a proof.
What it cannot catch is documented on the page rather than left for a user to discover:
nothing is interprocedural, so ctx.Get<Order>().IsStillOpen() is accepted and its
body may read a clock; a method-group predicate has no visible body at the call site;
static readonly is treated as constant and is only shallowly so.
Severity deviates from the profile table, deliberately. 06 §5 prescribes Info under
Ephemeral; this ships a Warning. Info never surfaces in a build log, and Ephemeral is
the only profile the runtime executes today — so Info would have shipped a rule that does
nothing anywhere, which is exactly the state FLOWX1011 was already in. ADR-0003 assigns
the Info stance to FLOWX1007–1009 specifically, not to this rule, so the ADR is not
contradicted; 06 §5 was amended to record the deviation and to say the remaining rules
should be revisited as a set rather than one row at a time.
Scoped to When only. Return, Emit, EmitOnFailure, ForEach's selector,
Step<T,TIn>'s mapping and — since WP-20 — Switch's selector all take the same
delegate shape and are subject to the same argument. Extending the analyzer is
mechanical; leaving it unstated would not be.
| Goal | The manifest emits everything its own schema declares |
| Why | triggers and per-capability errors were in the schema and written by nothing. A consumer reading the schema then cannot distinguish "this capability declares no errors" from "the generator never looked" — and flowx diff, blast-radius review and the P8 AI surface all read this file to make decisions. An ambiguous absence is worse than a missing field |
| Deliverable | TriggerReader, ErrorCatalogueReader, the emission, and flowx diff rules for both |
| Exit | The sample's manifest carries its real trigger and real error codes; the CI gate stays green; two builds are byte-identical |
| Depends on | WP-6, WP-17 |
| Status | Done. 1 trigger, 3 populated catalogues and 1 empty one on the sample. Determinism confirmed by identical SHA-256 across clean rebuilds. |
Three states, three renderings. errors is written when empty ([] — "declares
none"), and withheld entirely when the catalogue could not be resolved. This is the
actual fix for the ambiguity above: a catalogue short by one entry reads exactly like a
complete one, so an unresolvable case must be visibly absent rather than quietly
approximated. Unresolvable means a cross-assembly factory, a non-literal code, or an
Error arriving as a parameter.
No error messages, only codes and categories. A code is structure; a message interpolating a runtime value is not, and the manifest's rule is the one WP-15 upheld against predicate text — structure only, never values.
A third-party TriggerAttribute subclass is skipped, not guessed at. A trigger's
Kind is an overridden property, which is executable code rather than attribute data, so
it cannot be read from metadata for a type the abstractions do not ship. Declining to
invent it is right; doing so silently is not, and that gap is recorded in
CHECKLIST §5c.
| Goal | Every architecture gate the docs claim either runs, or is named as blocked |
| Why | CHECKLIST §4 listed 23 fitness functions; six existed nowhere, and four of those are security gates cited in the OWASP mapping and in 15-Security §10. A control that is claimed and absent is worse than one never claimed: the claim is what stops anyone looking |
| Deliverable | The implementable ones, each proven able to fail; the blocked ones named with what blocks them |
| Exit | No gate in the list is both claimed and absent |
| Status | Done. Five implemented, two deliberately absent. |
ManifestContainsNoSecrets could never have passed against real output. The assertion
carrying that name forbids words, including token — and the manifest samples/ecommerce
actually emits contains PaymentToken, the name of a [Sensitive] member the manifest is
supposed to record. Pointing that list at real output fails on correct code, so it stayed
green only by running over a hand-built model. This is a sharper variant of the defect this
phase keeps finding: not a gate that is missing, but a gate whose design guarantees it can
never be aimed at the thing it claims to guard. Replaced with one that matches the shape
of a credential over every emitted manifest, asserts it found at least one so it cannot pass
by scanning nothing, and masks what it finds — a scanner that prints the credential into
the CI log has moved the leak, not caught it.
Two OWASP rows were false as written. A01 claimed Authorization.Internal is unreachable
from an external trigger; nothing in the runtime, the host or any plugin reads the stance —
it reaches the manifest and stops. A02 claimed [Sensitive] redaction is applied by the
generated serialiser so no path reaches logs, traces, journal or replay un-redacted;
redaction is not generated and three of those four sinks do not exist.
CrossTenantAccessIsDenied and RedactionCannotBeBypassed are absent, not skipped. They
need P4's policy stages and P2/P5's journal and sinks. A test named after a gate is itself a
claim of coverage.
| Goal | A red merge-class gate has a consequence, or stops being described as a gate |
| Why | WP-31 below built a relative gate because the absolute one was already failing and therefore said nothing. This is the next form of the same failure, one level up: the Benchmark budgets (B1, B3, B12 isolated) job is blocking, triggers on every push and pull request to master and dev, and has been red on dev continuously since at least run #41 on 2026-07-31 — through sixty-odd pushes and four working packages. EngineBenchmarks.SagaFailure went 40 B → 48 B → 56 B underneath it, at 744b005 (WP-29) and 16b6988 (WP-57), and nothing said so: the error list already had three entries, so a fourth was invisible, and the two unit tests over that path assert bands (> 0, < 256, < 2048) that the whole movement fits inside |
| Deliverable | The remaining failures resolved or individually accepted with a recorded reason; a red job that a merge cannot ignore |
| Exit | python3 scripts/check-benchmark-budgets.py BenchmarkDotNet.Artifacts exits 0 on a full --filter '*' run, and a red Benchmark budgets job blocks a merge |
| Status | Partial. The two engine entries are done; three failures remain and are deliberately not re-recorded |
Done. EngineBenchmarks.SagaFailure 40 → 56 B and StepLoopBenchmarks.CompensateAll
328 → 440 B are bisected over the 304 commits from the baseline's own commit, attributed to
CompensationEntry growing from 8 B to 24 B — FlowContext? Scope at 744b005, so a
compensation inside a ForEach undoes the element its own step processed, and
StepScope JournalScope at 16b6988, so the compensation row for the third element is not
keyed as the first. It is still one allocation, and still the Unwind iterator the
baseline already named: an iterator's state machine carries the value it yields, so the
entry growing by 16 B grew the iterator by 16 B. baseline.json restates both with the
reason, and UnwindingAllocatesOneIteratorPerFailedFlow now pins 56 B exactly instead
of asserting a band — so the Allocation budget (B2) job, which is green and therefore
read, catches the next byte on the commit that adds it.
The compensation-identity fix is measured clear: 56 B on both sides of it. It costs nothing.
Not done, and deliberately not re-recorded — moving four baselines in one commit to get a green tick is the behaviour that produced this finding:
StepLoopBenchmarks.BuildPlanis gated at exactly 520 B and does not reproduce — 456 B at the commit that recorded it, on the recorded runtime and the recorded 10/30 configuration; 464 B atdevon the same container; 528 B on the hosted runner. Every other allocation entry agreed across both machines in those same runs,CompensateAllto the byte, so this is one entry rather than a broken harness. Open item 15.CompilerBenchmarks.GeneratorOnlyand.WithGeneratorsit outside their 15 % band, withWithGeneratorat 51.2 ms against a committed 11.2 ms and its p95 crossing B12's 60 ms ceiling on some runs. That is compile-time cost, which B12-scale and the generator-cost gate already own; re-recording it here would erase the evidence of a regression the project is tracking.- The process question, which is the one that matters. Nothing tells anyone the job is
red: no branch protection requires it, no notification fires, and both
CHECKLIST §6and 21-Quality-Gates §7 described it as "runs". A merge-class gate whose failure has no consequence is a nightly report with a red icon. Either its failures are made consequential, or the rows claiming B1/B3/B12 are gated stop claiming it.
| Goal | A cost regression fails the build that caused it, even though the absolute budget is already red |
| Why | The 4.9× regression above merged in silence precisely because the only cost gate was absolute, against a criterion already failing. An absolute gate you are failing catches nothing |
| Deliverable | A relative gate on a deterministic proxy, a committed baseline, and a self-test |
| Exit | The gate fails on the commit that caused the incident and passes on every no-op |
| Status | Done. Blocking. Validated against c7ae70a: FAIL at +102 %, fifty times the threshold, on the commit that did it. |
Wall clock cannot do this, and the counterfactual is the most useful result. Gating the same in-process probe on elapsed time — MSBuild, restore and the compiler server already removed — a no-op commit produces a false signal of up to +166 %, while the real 4.9× regression produces +39 % to +77 %. A timing threshold wide enough not to fire on nothing is two to four times too wide to fire on the incident. No number of rounds fixes a signal smaller than its noise.
So the gated quantity is bytes allocated by one RunGeneratorsAndUpdateCompilation call.
That is close to a direct measure of what WP-28 found: the generator's cost is semantic-model
queries, answering one binds a statement, and binding allocates. It follows the precedent
check-benchmark-budgets.py already argues — allocations are exact on shared hardware,
timings are not.
Threshold +2 %, chosen from measurement. Twelve A/A runs under load 5.8–21.1: the gated
statistic's full range was 0.014 % at 25 flows and 0.071 % at 50. That is 29× headroom
over the worst deviation, and 5× the dearest real feature in the same window — Switch/Case
cost +0.41 %, Parallel +0.11 %. Confirmed in review under load average 38.6, where the
metric moved +0.01 %; the conditions that make wall clock useless move this by one part in
ten thousand.
A passing relative gate is not a met budget, and the tool says so out loud. The absolute
criterion is reprinted as ABSOLUTE CRITERION — FAIL on every run, passing ones included.
scale-overhead stays advisory, with its comment now explaining why this does not discharge it.
Stated limits, not buried: bytes are a proxy and not a conversion (2.05× here where
/reportanalyzer says 4.87×, so +2 % of bytes is not +2 % of milliseconds); the probe runs
neither MSBuild nor the analyzers and cannot see regressions there; and cross-machine
reproducibility is the one untested assumption, since Roslyn sizes some pools from
ProcessorCount.
| WP-34 | A test that can fail on the parallel context race. The window was not narrow — it did not exist: overwrites into an already-allocated dictionary slot cannot corrupt anything, and the pooled context keeps its buckets across Reset. Fixed with inserts, a fresh engine per iteration and a spin rendezvous. Verified by mutation: fails 4 of 4 unguarded |
| WP-35 | The CI suppression step deleted, not repaired — two implementations of one rule, on the same triggers, with the weaker one being what a developer meets first. Six of seven fitness functions 05 §12 claimed now exist; PluginsPassConformance is named as blocked on a conformance suite and a second plugin |
| WP-36 | The evidence ADR-0014 said nobody had. See below — the number is not the finding |
WP-36's measurement is honest about what it cannot settle, and that is why it is
useful. There is no FlowX code in the world, so the population is empty: the 39 %
withheld rate is a property of a 38-capability corpus one person chose, not a sample of
anything. What transfers is the per-pattern table, because whether
Result.Fail<T>(code, message, category) resolves is a fact about the reader rather
than about the corpus.
The serious finding is that the derived catalogue can be positively wrong. ADR-0014
§3 C rejects a declared list partly because a declared list can drift out of truth
while a derived one cannot. That premise is false. When a failure stays inside
Result<T> for its whole journey it never takes the shape of an Error, so the scan
finds nothing, finds nothing it could not follow, and emits errors: [] — the
schema's positive statement that the capability returns no declared error. flowx diff
treats that as authoritative. A wrong contract is worse than a slow build, and this is
a different object from the one the ADR argues about.
Correctness costs coverage. Fixing the under-report moves the same corpus from 39 % to 47 % withheld. That trade is the decision, and it is not one to make inside a benchmark.
Incremental invalidation is correct, and that is the cost. The transform combines
with the CompilationProvider before it runs, so it re-runs for every capability on
every edit anywhere — Roslyn reports Unchanged, never Cached. The IDE's inner loop
pays full derivation per keystroke, by construction. That answers ADR-0014's revisit
trigger without a timing run.
And a measurement defect in the ADR's own proposal. §4(1) suggests re-expressing the budget as ms per capability type. Measured, this generator reports 134 kB per capability type on the default project and 526 kB on a reuse-heavy one — 3.9× on the same compiler at the same flow count — because dividing a per-flow term by a capability count is not a per-unit figure. The proposed replacement budget has the defect it exists to fix.
The roadmap's P2 scope, broken into packages. P2 proves crash-safe execution with no duplicate effects and no split brain, and its Done-when is QR2: kill any node at any step boundary, 10 000 flows, zero duplicate non-idempotent effects, zero lost instances, resume p99 ≤ 45 s.
The design these packages are held to is
ADR-0015, written for this
phase and Accepted at WP-53: it journals the step boundary on
(instance, scope, step, attempt) and resumes through the same step loop rather than a
second engine. It also carries the take-down list — what gets deleted the day the runtime
reads ExecutionProfile, which was WP-52 and has happened. *The ADR was held Proposed
through WP-51 and WP-52 on purpose, because the only implementation holding it up was an
in-memory reference with no transaction, no unique constraint and no migration to disagree
with it. WP-53 supplied a real one: all five Decision commitments held against PostgreSQL
16.13, and the three clauses that failed are amended in
ADR-0016. Accepted does not mean
measured — B7 and B8 are still unreported. This sentence said that was "because WP-50
has not started"; WP-50 has since shipped its chaos rig and nothing else, so the reason is
now that the package delivered its correctness third and not its two latency thirds.
Two things shape the ordering and are argued in §2 rather than here: the budgets come before the journal (B12's lesson, learned the expensive way), and the runtime chain is sequential because of files, exactly as the DSL chain was.
| Goal | B7, B8 and QR2 are measurable, with a committed baseline and a stated noise floor, while there is still no journal |
| Why | This is the package that exists because of B12. A budget that becomes measurable only after the thing it constrains is built gets renegotiated instead of met — and P1 has now supplied the proof, closing over a criterion first measured at scale two-thirds of the way through the phase. JournalBenchmarks does not exist (14 §8); the chaos rig does not exist (21 §7). Both are named in P2's Must as outcomes and by nothing as tooling |
| Tests first | The benchmarks are the tests. The chaos rig gets a test of its own: run against today's ephemeral engine it must report 10 000 lost instances, because a rig that cannot see the failure it exists to detect is WP-30's ManifestContainsNoSecrets again |
| Deliverable | tests/FlowX.Benchmarks/JournalBenchmarks.cs measuring the transaction shape 11 §5 specifies — one flow_step insert, one flow_instance update, N outbox inserts, one commit — against a real Postgres; scripts/chaos-qr2.sh with a verdict and an INCONCLUSIVE exit, following WP-23's precedent; both baselines committed |
| Exit | B7 and B8 report a number with a confidence interval and an explicit pass/fail; the chaos rig reports the ephemeral floor and refuses to publish a verdict whose spread swallows it |
| Depends on | — (the only P2 package that touches nothing under src/) |
Important
One of this package's three subjects shipped on 2026-08-01. Its Goal row is "B7, B8 and QR2 are measurable"; QR2's rig is built and B7 and B8 are not, so the package is partial. Nothing below closes them, and no reader should take the rig's numbers as a durability budget being met.
What shipped. tests/FlowX.Chaos — an Exe, not a test project, so the ordinary
suite is unchanged — spawns worker and recovery-node processes against a shared
PostgreSQL and SIGKILLs the workers at a step boundary chosen so a step's effect has
happened and the engine's commit has not. scripts/run-chaos-qr2.sh runs it and
scripts/check-chaos-qr2.py renders the verdict. The record is
benchmarks/QR2-chaos.md, and what it measured belongs in
CHECKLIST §5d
rather than here, because this file says what to build and that one says what is built.
Two deviations from the rows above, stated rather than absorbed.
The deliverable row names scripts/chaos-qr2.sh; what shipped is
scripts/run-chaos-qr2.sh beside a Python checker, which is WP-23's precedent more
exactly than the row was. And the Tests-first row's ephemeral floor was not built:
the rig has no ephemeral mode, its flow is declared Durable, and no run has ever
reported 10 000 lost instances. What replaced it is a refusal — check-chaos-qr2.py
returns INCONCLUSIVE (exit 2), never PASS, when no process was killed, when no killed
worker exited 137, when nothing was recovered, or when an arm ran fewer flows than it
registered. That is a weaker guard than the floor in one way — it proves the rig killed
something, not that it can see a loss — and a stronger one in another, because it is
checked on every run rather than once.
The ordering this section argues for did not happen, and that stands. JournalBenchmarks
still does not exist; nothing under tests/FlowX.Benchmarks/ measures B7 or B8, and the
baseline WP-53 was supposed to be judged against will now be written by someone who
already knows what the journal looks like. This box said "WP-51 shipped and WP-50 did
not". By the time the rig landed, every other P2 package except WP-59, WP-62 and WP-63
had shipped at least in part; WP-59 shipped later the same day, leaving WP-62 and WP-63.
P2 is in exactly the position
§2 describes B12 as having been in, for the two budgets —
and the rig, which arrived after the thing it measures rather than before it, is now
evidence for that lesson rather than an application of it.
Two CI-gate repairs — the attribution guard and the DAST job — were carried out in an earlier round, and 21 §4 and 21 §2.6 both credited them to WP-50. They are not in this package's deliverable row and never were; crediting them here made an unstarted package look partly delivered. Both labels are corrected. The work was real and unnumbered.
What can honestly be measured before the journal exists, since the objection is obvious: the commit latency of that transaction shape is a property of Postgres and of the schema, not of FlowX, and it is the number R5 turns on. WP-3 measured an empty step loop for the same reason. What this package cannot do is price FlowX's own overhead, and its report must say so in those words.
| Goal | The two store contracts exist, in FlowX.Abstractions, with a shared conformance suite that a store either passes or fails |
| Why | ADR-0006 promises "a shared conformance suite, so Postgres, Redis, SQL Server or a custom store all behave identically" and there is neither interface nor suite. ADR-0009 fixes where they live. Writing the suite after two stores exist produces a suite shaped like those two stores — the same defect as writing a budget after the thing it constrains |
| Tests first | The conformance suite itself, run against an in-memory reference implementation; and a deliberately broken store — one that accepts a stale fencing token — which the suite must reject by name |
| Deliverable | IFlowJournal, ILeaseStore, the fencing-token type, the record shapes of ADR-0015 including scope, and FlowX.Conformance.Tests as a package |
| Exit | The in-memory store passes 100 % of the suite; the stale-token store fails on the assertion whose name says why; AbstractionsHasNoDependencies still green |
| Depends on | WP-50 |
This deliverable named the package FlowX.Conformance.Journal, and this file was the
only place that said so. 05 §5.3,
09 §11,
17 §4 and
ADR-0009 all named
FlowX.Conformance.Tests, and that is what shipped. Four documents and the code against
one row: the row was wrong and has been corrected. The name also has to hold more than the
journal — LeaseStoreConformance is in it already, and four more suites are planned — so
.Journal would have been wrong on the merits as well as by majority.
What shipped, and how it differs from the row above.
- The contracts landed in
src/FlowX.Abstractions/Durability/, which is where ADR-0009 requires them: a plugin referencesFlowX.Abstractionsand nothing else, so a contract one layer up is a contract no third-party store can implement.docs/05 §5.3had them inFlowX.Runtime.Durable/Journal/and has been corrected. FenceAsyncwas added toIFlowJournalto close a gap ADR-0015 left. The ADR rests correctness on "committed rows and a fencing token" and never says when the fence rises. If a journal learned tokens only from writes, the sequence 11 §3 draws — node-2 acquires token 8, reads history, and only then commits — leaves a window in which node-1's stale token 7 is still the highest the journal has seen, so it is accepted. That is split brain with an extra step. The fence has to rise on acquisition, and because the lease store and the journal are separate plugins with no shared transaction, the winning node is the only thing that can carry the token between them.JournalConformance.TheFenceRisesOnAcquisitionNotOnTheFirstWritepins it. The ADR should be amended to match when it is next opened.- The project is a test project and is deliberately not packable. The row says "as a
package"; that half is unmet, and the reason is recorded in the
.csproj: the only implementation held to the suite is the in-memory reference sitting beside it, and publishing a contract that nothing outside its author has pushed back on is how a contract ships wrong. It packs at WP-53/WP-54, when a second and third store exist. WP-53 met the first half of that condition and the suite is still not packed: one outside implementation is one, and the row waits on WP-54.
This was to be where ADR-0015 becomes Accepted or changes. It stayed Proposed, and that was the correct outcome, not a slip. The ADR's own condition is that the suite hold an implementation to the schema. At WP-51 the suite held an in-memory reference — a dictionary that satisfies the assertions — which proves the schema is expressible and proves nothing about whether it survives a real store's transaction boundaries, indexes or expand/contract migration.
Vindicated at WP-53. Against PostgreSQL 16.13 all five Decision commitments held and
three clauses did not — one of them, jsonb's key reordering, makes commitment 5 false
in a way no dictionary could have expressed. That is the exact class of disagreement this
paragraph was written to wait for. ADR-0015 is now Accepted, amended by
ADR-0016.
Shipped 2026-07-31. (The heading keeps its wording because three documents link to its anchor.)
| Goal | A Durable flow journals its step boundaries; an Ephemeral flow is byte-for-byte the execution it is today |
| Why | The keystone. FlowX.Runtime never read ExecutionProfile, so a Durable flow ran the ephemeral path — no journal, no lease, no resume — and a process kill lost it. That is why risk R2 was unreachable rather than mitigated, and why four diagnostics were blocked on severity. This package is the one the whole phase is named for |
| Tests first | EngineAllocationTests re-run unchanged — B2 must still be 0 B for linear, conditional and switch flows, because a durable seam that charges the ephemeral path is a second engine wearing one engine's name; a journaled run whose committed rows reconstruct the execution exactly; ExecutionProfileHonestyTests observed failing, which is the signal to delete it |
| Deliverable | The step-boundary commit gated on a plan-level flag (the ExecutionPlan.HasParallel precedent), the scope key threaded from IterationScope, the derived resume cursor, the child-instance row for SubFlow, the journaled seed that makes FlowExecutionContext.Random's own remarks true, and redaction of [Sensitive] members from journal payloads |
| Exit | Every row of ADR-0015's take-down table#what-lands-with-this-and-what-is-deleted) is discharged in this package's own commits: ExecutionProfileHonestyTests deleted rather than skipped, FLOWX1028 narrowed to Streaming, the four warning boxes corrected. B2 = 0 B, measured. A flow whose input carries a [Sensitive] member journals it redacted, proven by a test that reads the row back |
| Depends on | WP-51 |
Shipped 2026-07-31, and here is what it did not buy. The runtime reads the profile. A
Durableflow commits one row per(instance, scope, step, attempt)— a failed attempt included — capturesctx.UtcNow, the idsctx.NewId()minted andRandom's seed per step, gives a composed sub-flow its own instance row, and resumes by replaying its committed rows into the sameExecuteAsync. B2 is still 0 B on the ephemeral path;Durablecosts 192 B per step, recorded as a ceiling.ExecutionProfileHonestyTestsis deleted,FLOWX1028is narrowed toStreaming, and the take-down list is worked row by row in WP-54's commits.Lease acquisition, the recovery scan, Postgres, Redis, the outbox and
AwaitSignalare not built. The onlyIFlowJournalin the repository is the in-memory reference intests/FlowX.Conformance.Tests. Nothing has run against a store, so nothing here should be read as durability working end to end — a killed node is still lost, because nothing looks for it. ADurableflow started with no journal is now refused (flow.durability_not_configured), which until WP-55 means a durable flow is rejected at its first invocation unless its caller constructs the session itself.Three things the package found and recorded rather than absorbed. ADR-0015 said the journal must record the branch a
Switchtook and gave it no field to do so — resolved in favour of the Decision, by replaying pure predicates against the restored bag, and amended#amendments-the-implementations-forced-wp-52-wp-53). 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, and WP-61 needs a per-branch context before it is not. And 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's package, named rather than approximated.
The take-down is part of the deliverable, not follow-up. FLOWX1028 and its fitness
test are scaffolding for a gap; a scaffold nobody removes when it stops being true is
noise, and noise is what teaches people to suppress a catalogue. The reminder is written to
fail on this package and to name what to remove — leaving it red, or skipping it, converts
the one honest signal in the area into an ignored one.
Shipped 2026-07-31.
plugins/FlowX.PostgresimplementsIFlowJournal,ILeaseStore, migrations and retention againstFlowX.Abstractionsand nothing else. The WP-51 suite was inherited unmodified from a different assembly — the arrangement 17 §5 describes for a third party claiming conformance — and 45 conformance assertions plus 18 adapter tests are green against PostgreSQL 16.13. That the suite was derivable across an assembly boundary without an edit is the first evidence that the self-certification story works, and the precondition WP-70 needs before packing it.Three ADR-0015 clauses failed contact with a database, recorded in ADR-0016: payload columns are
json, becausejsonbsorts object keys and re-renders separators and so makes commitment 5 false outright;flow_leasecarries no foreign key toflow_instance, because the lease is taken before the instance row exists and the constraint would refuse every first acquisition; andflow_instance.state_bag_sequencewas added in migration0002to give B8's named mitigation the position ADR-0015 never gave it. Two of the three were in an ERD ADR-0015 already declared superseded. This is what an in-memory reference could not have found, and the argument for having held the record Proposed.The exit criterion is half met, and the unmet half is not this package's to meet. Conformance is green. B7 and B8 are unreported — the baseline they are measured against is the half of WP-50 that has not been built. This sentence said WP-50 "has not started"; it shipped its chaos rig on 2026-08-01 and neither benchmark with it. The criterion asks for an explicit pass or fail; the honest answer is neither yet, and it is recorded as such rather than quietly satisfied.
Deviation from the deliverable row, stated: the row says Testcontainers, group-commit batching and a
tenant_idpartition key. The suite ran against a directly-provisioned PostgreSQL 16.13 rather than Testcontainers; batching and partitioning are not built, and both are optimisations that WP-50's absent numbers are the only rational basis for.Two gaps found after the merge, by reading the adapter against the record. The first is closed and the second is not, and the difference is the point.
Closed. The adapter implemented no
IRecoveryIndex, so WP-55's recovery scan — shipped, tested, correct — resolved its query tonullon any Postgres-backed host and swept nothing. Both packages met their own exit criteria; the gap was between them, which is the failure mode an optional dependency produces when the only production implementation declines to supply it.PostgresRecoveryIndexand migration0003close it, and ADR-0016 decision 4 records why it is a separate class and why0002's index could never have served the query its own comment claims it was for.Standing.
state_bag_sequenceis written on every commit and read by nothing: the frontier query isWHERE instance_id = @instance ORDER BY sequence, with no lower bound. The column that exists to bound B8's read does not yet bound it. Recorded rather than fixed, because narrowing that query without a benchmark is the optimisation B12 taught this project not to make — and unlike the recovery index, nothing is broken by leaving it, only slower than the record implies.
| Goal | The reference store, passing the conformance suite against a real database in CI |
| Tests first | The conformance suite from WP-51 against Testcontainers Postgres; a migration test proving expand/contract (11 §7) rather than a breaking change |
| Deliverable | plugins/FlowX.Postgres — schema, migrations, group-commit batching, tenant_id partition key |
| Exit | Conformance green against Postgres in CI; B7 and B8 reported against WP-50's committed baseline, with an explicit pass or fail — the criterion is a stated verdict, not a passing one |
| Depends on | WP-51 |
| Goal | A second store, so "pluggable" is demonstrated rather than asserted |
| Why | ADR-0006's claim is that the two primitives are store-independent. One implementation cannot show that, and the fencing-token argument is the part most likely to be got wrong differently by a different store |
| Tests first | The same conformance suite, unmodified — if it needs a change to accept Redis, the suite was written against Postgres and WP-51 failed |
| Deliverable | plugins/FlowX.Redis lease store with monotonic token issuance |
| Exit | Identical conformance results to WP-53's lease half; the stale-token rejection proven against Redis; StepScope.Root mapped explicitly |
| Depends on | WP-51 · concurrent with WP-53 — disjoint projects, shared suite read-only. No longer concurrent in practice: WP-53 shipped and this did not, so WP-54 is now the only thing standing between "pluggable" as a claim and as a demonstration |
A portability rule this package must obey, carried in two ADRs and in no plan until 2026-07-31. ADR-0016 and ADR-0015 both record that commitment 1 works in PostgreSQL partly by luck of dialect:
StepScope.Rootrenders as the empty string, and PostgreSQL treats''as distinct fromNULL, so the flow body is a legal primary-key component. A store that folds the two — Oracle is the usual example, and a Redis key space has its own version of the question — rejects every root-scope row. Any adapter after the first must mapRootexplicitly. WP-54 is the first adapter after the first, so it is where this stops being a note and becomes a test.
Shipped 2026-07-31.
DurableLeaseacquires a lease and renews it in the background at TTL/3;LeasePolicycarries the TTL and the renewal stance.FlowRecoveryScanandFlowRecoveryServicefind instances whose lease has expired and hand each to the sameExecuteAsync— there is still no second loop, which is the property option A was rejected to preserve.A fenced-out node stops without compensating.
CompensationOutcome.Abandonedis the recorded decision rather than an emergent one: the instance belongs to whichever node holds the lease, and a loser that unwound its own work would be undoing work its successor is about to redo or has already redone.This discharges WP-52's consequence that a
Durableflow was rejected at its first invocation unless the caller built the session by hand. A host wires it now.Ordering deviation, stated: the row below depends on WP-54, and WP-54 has not started. The lease half of the suite is proved by Postgres alone, so "a second store proves the primitives are store-independent" is still unproved — that claim now rests entirely on WP-54.
The exit criterion's unmet half is now measured, by a rig this package did not build. Multi-node kill behaviour is covered by tests over the lease and the scan; this note added that "resume latency is not measured against 45 s, because that is WP-50's rig and WP-62's scenario", and the first half of that expired on 2026-08-01. WP-50's rig
SIGKILLs worker processes and reports resume latency against 45 s on every run: p99 32.9 s and 32.6 s at 10 000 flows per arm. Measured, stated, and not gated on — two other runs of the same rig reported 48.1 s and 69.9 s with every correctness row still zero, so the figure is a lease TTL plus a queueing term rather than a property of this package's code (benchmarks/QR2-chaos.md §4.4). Nothing runs it in CI; that is still WP-62.
| Goal | A killed node's instance is finished by another node, once |
| Tests first | Kill mid-step and assert one completion, one effect, and the compensation stack intact across the boundary; a zombie writer with an expired token rejected at the journal, not at the lease |
| Deliverable | Lease acquisition and renewal, the recovery scan, and re-entry into FlowEngine's loop from the derived cursor — no second loop |
| Exit | Three nodes, kill one at a step boundary: the instance completes exactly once and resume latency is measured against 45 s, stated whether or not it passes |
| Depends on | WP-52, WP-53, WP-54 |
| Goal | State and event are committed atomically, and a crash between publishing and marking republishes rather than loses |
| Why | .Emit<T>() currently compiles into the plan and the manifest and publishes nothing — that is FLOWX1024, the first warning the catalogue ever raised. The outbox is what retires it |
| Tests first | Kill between publish and UPDATE published_at, assert a duplicate delivery rather than a lost one; two publishers against one table proving FOR UPDATE SKIP LOCKED does not double-publish |
| Deliverable | The outbox table in WP-53's schema, the polling publisher, per-partition_key ordering, and the honest statement that global ordering is not offered |
| Exit | An emitted event reaches a broker; a crash produces at-least-once and never zero; FLOWX1024's status is revisited in the same commit; retention no longer purges an unpublished event |
| Depends on | WP-53 · concurrent with WP-55 |
An obligation this package inherited and did not carry until 2026-07-31. ADR-0016 records, under Retention: "purging an instance cascades to its outbox rows, including any that were never published. Today nothing publishes them, so nothing is lost; when WP-56 lands a publisher, the purge needs a guard against removing a pending event. This is a note for WP-56, not a defect in it." It was a note for WP-56 that WP-56's row did not mention, which is how an inherited obligation becomes a bug. It is now in the Exit column.
| Goal | A compensation that fails is retried under its own policy set and, if it exhausts, ends as CompensationFailed with an alert — not as a silent loss |
| Why | Strict-reverse unwind is proven for an in-process failure. Across a resume it is not: the compensation stack is rebuilt from the journal, and WP-33 made a sub-flow one entry in the parent's stack, so A · child(X, Y) · B must still unwind B, Y, X, A after the node that ran X has gone away |
| Tests first | Kill during compensation and assert the remaining unwind order; a compensation that fails permanently and reaches CompensationFailed; the sub-flow ordering above, resumed |
| Deliverable | Compensation policy sets, the CompensationFailed terminal state, the operator alert, and journal rows for compensating steps |
| Exit | The 11 §8 row with "no automatic resolution" is reachable, observable, and covered by a test |
| Depends on | WP-55 |
This package has a dependency the roadmap does not show. "Compensation with its own policies" is in P2's Must, and no policy executes at run time at all — the policy engine is P4. Either P2 builds the slice it needs (retry with backoff, at the
Consistencystage, honouring ADR-0011's fixed order) and P4 generalises it, or the item moves to P4 and P2 ships compensation with a fixed retry. It cannot ship as written without one of those two decisions being taken, and taking it silently is how a phase boundary stops meaning anything.
Shipped 2026-07-31.
DeterminismAnalyzerraises ambient time (FLOWX1007), ambient identifiers and randomness (FLOWX1008) and mutable declared state (FLOWX1009), over capability bodies as well as flow delegates.The stance was re-decided as a set, and
Infowas rejected outright. ADR-0003 specified Info underEphemeraland Error underDurable. Info never reaches a build log andEphemeralis the default profile, so an Info set would have done nothing in nearly every build — which is precisely the state all four ids were already in, and what kept them 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. Escalation is a proof rather than a guess: a capability has no profile of its own, so it escalates only when aDurableflow in this compilation names it as a step.FLOWX1011's deliberate Warning deviation stops being an exception and becomes the rule; ADR-0003's bullet is amended to record that the clause did not survive.Exit criterion met on both halves:
06 §5's table has no no — P2 rows left exceptFLOWX1006(WP-59), andFLOWX1011's deviation was re-argued rather than retired, in the same decision.ICapability's doc comment moves three rules from "required, and not enforced" to "enforced at build time", leaving one. WP-59 has since taken that last row and the last rule:06 §5's table has no no — P2 rows at all, and whatICapabilitystill lists as unenforced is half of rule 8 — contract immutability — becauseFLOWX1006raised its serialisable half and nothing refuses a mutable contract.
| Goal | The determinism rules that are blocked on severity ship, and the whole stance is re-decided once |
| Why | The analysis exists. PredicatePurityAnalyzer already proves scope from DeclaringSyntaxReferences and carries the known-impure catalogue; WP-25 rebuilt it around a table of constructs so a new subject is a row. What blocked these three is that ADR-0003 then made them Info under Ephemeral, the only profile that ran — so they would have shipped doing nothing anywhere. WP-52 removed the blocker and WP-58 shipped all three, rejecting Info outright rather than inheriting it |
| Tests first | Both directions per rule, over capability bodies as well as flow delegates; the severity asserted per profile |
| Deliverable | The analyzer extension, three diagnostic pages, and the amendment to 06 §5 — which asks for the stance to be revisited as a set, including FLOWX1011's deliberate Warning deviation |
| Exit | 06 §5's table has no no — P2 rows left except FLOWX1006; FLOWX1011's deviation is either retired or re-argued in the same commit |
| Depends on | WP-52 · concurrent with WP-59 and WP-60 only if the ids are claimed in the diagnostics index first |
Shipped 2026-08-01, and it was the last package in P2's Must with nothing written for it.
FlowEmitteremits a payload writer onto aDurableflow's dispatcher:DescribeStepdescribes every step boundary — what the step produced and the state bag as it then stands —DescribeInputdescribes the trigger input, andRestoreStatereads the bag back, because only generated code can name the contract types either way. None of it is emitted for anEphemeralflow — the contract list is empty before a line is written when the profile is notDurable, soRestoreState,DescribeInputand the state-bag helper are absent from the type an ephemeral flow loads and budget B2's hard zero is untouched by the writer existing. (An ephemeral flow that stages an.Emitstill gets WP-56's outboxDescribeStep; that member predates this package.)Both halves of the exit criterion are met. A
Durableflow whose state bag holds a contract no single generated context declares fails the build naming the member (AStateBagMemberOutsideTheGeneratedContextFailsTheBuildNamingIt,tests/FlowX.Compiler.Tests/PayloadWriterTests.cs); and every payload the writer hands over carriesSensitiveMembers— ADR-0015's commitment 5, which the note below named as the obligation this package was most likely to forget.The second exit the note below warned about was not opened, and that is the deliverable's real content. The writer composes no document:
StateBaghands named values toJournalPayload.OfStateand composition happens insideJournalPayload, ahead of the oneToJson()that redacts and stamps. Pinned two ways — the generated source must contain neitherUtf8JsonWriternorJsonSerializer.Serialize(TheStateBagIsHandedToJournalPayloadRatherThanComposedByTheWriter), and a hostileIPayloadSerializerreceives only the redacted, stamped document (tests/FlowX.Abstractions.Tests/PayloadContractTests.cs).JournalMemberhas no accessor for its value, so composition adds no route to one either.
FLOWX1006is anerroruniformly, rather than the determinism set's Warning-then- escalate, because it reports only onDurableflows — so its trigger is the escalation condition the rest of the set has to prove, which isFLOWX1012'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" (docs/diagnostics/FLOWX1006.md).
schemaVersionandIPayloadSerializershipped with it, both from ADR-0008's Decision. The stamp rides the redaction pass rather than adding a second walk of the document, and it versions the envelope — the placeholder, the case-insensitive name matching that produces it, the composition of a state bag, and the presence of the stamp itself — deliberately not the contract, whose version is already recorded beside every payload.JsonPayloadSerializer.Defaultis the JSON case of the seam.
flow_instance.inputstops being NULL on every row, which was an outstanding defect recorded before this package.FlowHostpassed the literalinput: nulland now passesdispatcher.DescribeInput(input)(src/FlowX.Hosting/FlowHost.cs:495). It was not fixable in the host — journaling an input needs aJsonTypeInfo<TIn>and only generated code can name one, which is the same reasonDescribeStepis on the dispatcher; the reasoning is written at the declaration insrc/FlowX.Runtime/IStepDispatcher.cs. Until this landed a replay could not reconstruct what was requested, the audit trail had no record of it, and[Sensitive]on an input contract protected nothing, because nothing was stored.One deviation from the Deliverable row, and it is the first item in it. "The generated STJ context ADR-0008 chose" was not built and is not owed: FlowX emits no
JsonSerializerContextof its own. The writer reads the contexts the compilation already declares and makes membership of one of them the build-time requirement, which is whatFLOWX1006's fix is — one[JsonSerializable]attribute on the author's own context. A context FlowX generated would have to be a second context declaring the same contracts, which is the "two contexts declaring one contract" case the rule itself refuses.Two things it did not move, checked rather than assumed.
samples/ecommerceisEphemeral, so no writer is emitted for it and the manifest baseline did 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 left open:
IStepDispatcher.DescribeInputis a defaulted interface member, and during this package both hand-written decorating dispatchers silently inherited the default and putinputback to NULL — open item 12.
| Goal | Anything the journal must serialise is provably serialisable at build time |
| Why | FLOWX1006 is reserved against a generated System.Text.Json context that nothing generates, so there is no membership the rule could check. WP-52 built the hole it fits: a payload reaches the journal only through JournalPayload.Of<T>, which demands a JsonTypeInfo<T> only generated code can name — so the membership is already a compile-time requirement, and no generator satisfies it yet. The shipped dispatchers describe no payloads at all |
| Tests first | A state-bag member outside the generated context fails the build; one inside it is silent; a round trip through the journal preserves it |
| Deliverable | The generated STJ context ADR-0008 chose, the payload writer, FLOWX1006, the schemaVersion stamp and IPayloadSerializer |
| Exit | A Durable flow whose state bag holds a non-serialisable type fails to build, naming the member; the payload writer consults SensitiveMembers, which the generator already emits |
| Depends on | WP-52 |
Two ADR-0008 commitments and one ADR-0015 commitment were owed to this package and named in no plan until 2026-07-31.
schemaVersion(the stamp that makes a stored payload readable by a later release) andIPayloadSerializer(the seam ADR-0008 needs before a binary plugin is even expressible) are both in that record's Decision and were in neither planning file. Separately, ADR-0015's commitment 5 states that the payload writer must consultSensitiveMembers— "nothing new has to be discovered to do it, only remembered" — and this is the package that would forget. Redaction on the journal is structural today becauseJournalPayload.ToJson()is the only exit; a generated writer is a second exit, and it is this package that opens it.The last clause was a prediction and it did not come true: the writer composes no document, so
ToJson()is still the only exit and there is still one redaction pass. It is kept rather than deleted because it is the risk the shipped design was built against, and the two assertions that pin the outcome were written from this sentence.
| Goal | A compensable flow declaring Ephemeral is told what it is giving up |
| Why | The check is one predicate — .CompensateWith under Profile = Ephemeral — and it was deliberately not raised in P1 because its only fix, Profile = Durable, changed nothing while there was no journal. A rule whose fix is a lie is worse than an unraised id. WP-52 made the fix true, on 2026-07-31. Its message must not overstate what Durable currently buys: a host with no journal wired refuses the flow (WP-55), and a resumed parent does not rebuild a skipped sub-flow's compensations (WP-57) |
| Tests first | Both directions; and the reference sample built with the rule on, because it will fire there |
| Deliverable | The analyzer, its page, and a decision — recorded — about what samples/ecommerce declares |
| Exit | The rule fires on a compensable ephemeral flow and its suggested fix produces a flow that is actually crash-safe |
| Depends on | WP-52 |
The package is the decision, not the code. It fires on every compensable flow in the repository including the sample, so landing it means choosing whether the reference sample becomes durable. That choice is worth a paragraph in this file, not a quiet edit.
| Goal | Replaying a completed durable instance produces byte-identical step inputs and identical control flow |
| Why | This contract is stated in 06 §5, cited as risk R2's mitigation in 05 §11, and verified by nothing — ReplayDeterminismTest does not exist and could not, there being no journal to replay from. R2's trigger is any replay divergence in the corpus, and the corpus does not exist either |
| Tests first | The corpus is the test: one journaled instance per DSL shape — linear, When, Switch, Parallel, ForEach with more than one element, SubFlow including a Detached child — replayed and compared byte-wise |
| Deliverable | ReplayDeterminismTest, the corpus, and the divergence report a failure produces |
| Exit | Every shape replays identically; an injected impurity (a captured clock read) is caught by the test, so it cannot pass by comparing nothing |
| Depends on | WP-55, WP-58 |
| Goal | P2's Done-when, measured |
| Tests first | WP-50's rig. This row said the rig "already reported the ephemeral floor of 10 000 lost instances". It never did and cannot: the rig has no ephemeral mode. Its guard against grading its own homework is check-chaos-qr2.py's INCONCLUSIVE exit |
| Deliverable | The chaos run in CI (nightly), its report, and its verdict against every clause of QR2 separately |
| Exit | 10 000 flows, SIGKILL at every step boundary: zero duplicate non-idempotent effects, zero lost instances, resume p99 ≤ 45 s — each reported as its own number, and an INCONCLUSIVE exit that is never converted into a pass |
| Depends on | WP-56, WP-57, WP-61 |
The rig exists and this package does not, and the difference is the whole of it. WP-50's rig has been run by hand and its results committed (benchmarks/QR2-chaos.md): 10 000 flows per arm, 97
SIGKILLs per arm, zero duplicate effects against the guarantee and zero lost instances in both arms. WP-62's deliverable is none of that. It is the chaos run in CI, nightly — a schedule, a job, and a verdict a machine reads — and nothing runs the rig on any schedule. A recorded run is evidence; a nightly job is a gate that can fail, and only the second one is what this row asks for.Two of its three exit clauses would pass against the recorded run and the third would not. Resume p99 was 32.9 s and 32.6 s on that run, and 48.1 s and 69.9 s on two others of the same rig with every correctness row still zero, so a nightly job that gated on p99 ≤ 45 s would be red on some nights for a reason benchmarks/QR2-chaos.md §4.4 attributes to lease TTL and recovery capacity rather than to FlowX. That is this package's real problem to solve and it is not solved by re-running the rig: §6 of that document argues a CI job should run the correctness clauses at whatever scale the runner affords and leave the p99 to a recorded run.
| Goal | A flow can wait for days without holding a thread, a lease or a context |
| Why | FLOWX1017 is already an error on AwaitSignal without Durable, and AwaitSignalRequiresDurableCodeFixProvider already writes Profile = Durable — a quick action whose result currently buys nothing. SubFlowMode.AwaitCompletion is refused by FLOWX1026 for the same missing suspension point, and is the one DSL mode P1 could not ship |
| Deliverable | Suspension and resumption through the journal, the scheduler for timers, the signal endpoint, and AwaitCompletion un-refused |
| Exit | A suspended instance costs one row and zero compute, proven by a memory and thread assertion over 10 000 suspended flows; FLOWX1026's second cause is deleted from its page |
| Depends on | WP-55 · concurrent with WP-64 |
| Goal | An operator can read what an instance did without a debugger |
| Deliverable | The replay verb, --mode inspect only; the other three modes are P5 |
| Exit | The verb renders a completed and a failed instance from the journal; CliDependsOnNothingButTheManifest is re-argued or the CLI's dependency rule is amended deliberately — the journal is a second input and that rule currently forbids it |
| Depends on | WP-61 |
Shipped 2026-08-01.
flowx replay --mode inspectreads the journal as rows over the published migration contract, throughNpgsql, and joins them against the manifest, which publishes the plan astep_idmeans nothing without. ADR-0020 is the decision, and it declines to publish aflowx.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 rebuilt on every build, whereas a journal schema is a promise about data at rest that no gate in this repository can enforce; andplugins/FlowX.Postgres/Migrationsis on its fourth script, so the shape has not stopped moving. A store's published contract is its DDL, and that already ships as SQL.The exit criterion's second clause was answered by finding the conflict was not one. This section said
CliDependsOnNothingButTheManifestis green,flowx replayreads a journal, and "one of the two has to give". Neither had to. The rule assertsRepositoryLayout.ProjectReferences(cli).ShouldBeEmpty(...)(DependencyRuleTests.cs:166) — it counts project links, not package inputs.Npgsqlis aPackageReference, the same kind of dependencySystem.Reflection.MetadataLoadContextalready was, so it adds no link and the assertion never came near failing. No fitness function was amended and every architecture gate stayed green. The warning was written from the rule's name rather than from its body, which is the error it is worth recording rather than deleting.What the name was hiding got its own assertion instead. The CLI is runnable against an artifact with no database was true by accident and asserted by nothing;
EveryVerbButReplayRunsWithNoStoreintests/FlowX.Cli.Testsnow runsgraph,manifest,diffandverifywith no connection string in the environment. That is a stronger property than the one it replaces, because the old one was enforced by nobody having tried.Owed here, named by ADR-0020 so it was not lost, and discharged on 2026-08-01:
CliDependsOnNothingButTheManifestis nowCliLinksNoFlowXAssembly— the name had moved further from the assertion, since a second input was added and the name mentions one. It was not done in this package because the rule lives intests/FlowX.Architecture.Tests, which WP-64 does not own, and because the six documents citing it had to move with it. All six did.
Note
Three P4/P3 packages landed on 2026-08-01 and are recorded here rather than as work packages, because none of them was numbered before it started.
Authorisation is enforced. A capability declared a stance, FLOWX1010 errored if it did
not, the manifest published it and flowx diff called a changed grant Breaking — and the
runtime never looked; there were zero occurrences of Authorization, Permission or
Authorize in FlowX.Runtime or FlowX.Hosting. Two of the five stances can refuse and now
do. Internal permits by construction — its refusal branch is unreachable, because a
trigger addresses a flow and never a capability, and any stricter reading refuses
samples/workflow's own reference flow. Policy is refused at build time by FLOWX1037
rather than skipped, because it needs a service FlowX.Runtime may not reference. Records
0027–0030.
Two things this plan believed were wrong: ClaimsPrincipal was always available in
FlowX.Abstractions — System.Security.Claims is shared-framework, so it costs neither a
package nor a project reference — and there was no open design question about identity, because
FlowContext.Principal was declared from the first commit and FlowExecutionContext.Principal
returned null unconditionally. The abstraction was whole and the wire was cut at the last inch.
Schedule is bound, so ADR-0004's "one trigger abstraction for all transports" has two
transports. A cron occurrence names the instance it starts, so N nodes racing produce one
instance with no leader and no election — the lease refuses the losers while the winner runs
and the journal's primary key refuses them for ever, both refusals reached by giving them an
id to compare. Records 0031–0034.
Bus, Stream, Change and Agent remain declaration only. And one claim in
09 §3 was false: it prints a flow serving HTTP and cron together
and calls it Q4's most visible benefit, and ADR-0004's first Positive says the same — but a
schedule's input is platform-supplied and an HTTP body caller-supplied, and a flow declares
one input.
Policy decisions are observable. Four of 10 §9's seven
metrics emit (0026); the other
three get no instrument at all, because a permanently-zero rejection counter claims
nothing is refused when the truth is that nothing refuses. FLOWX1032 is unchanged at four
kinds — RateLimit, Idempotency, Cache and Audit still do not execute, and narrowing
the rule without implementing a kind would be the lie it exists to prevent. The stated reason
for skipping Idempotency was wrong: IStepDispatcher has carried DescribeStep and
RestoreState since WP-59 and that is a per-step result seam. The real blockers are
narrower — the seam is documented Durable-only, and RestoreState receives payloads with
sensitive members already redacted and no read path that could put them back, so a replay
through it would silently drop [Sensitive] fields.
Lighter than P2 on purpose: P3's packages are mostly one shape repeated, and the value of detail here is lower than the value of naming the two things that are not repetition. Full detail is written when P2 closes and the shape is known rather than guessed.
The roadmap's Must is Kafka, RabbitMQ, Azure Service Bus, Cron with leader election, and
the conformance suite as a published package; its Should is gRPC, MQTT and webhooks with
signature verification. Done when samples/event-driven moves a flow HTTP → Kafka → cron
with zero business-logic changes, proven by an unchanged-file assertion in CI.
| Package | Goal | Mechanically checkable exit | Depends on |
|---|---|---|---|
| WP-70 — the transport conformance suite, published | One suite every transport plugin passes, written before the second and third transports | plugins/FlowX.Http passes it; a deliberately non-conforming plugin fails it by name; PluginsPassConformance — blocked since P1 — turns green |
WP-62 |
| WP-71 — the unchanged-file assertion | P3's Done-when is a command, not a judgement | The assertion runs against samples/ecommerce today, where it must pass trivially; it fails when a business file is touched |
WP-70 |
| WP-72 — Kafka | The first non-HTTP transport | Conformance green; the sample serves the same flow over Kafka | WP-71 |
| WP-73 — RabbitMQ | The second | Same suite, unmodified | WP-71 · concurrent with WP-72 and WP-74 |
| WP-74 — Azure Service Bus | The third | Same suite, unmodified | WP-71 · concurrent |
| WP-75 — Cron with leader election | A schedule fires once across N nodes | Three nodes, one fire per tick, proven under a kill | WP-70, WP-55 — leader election is a lease, which is why P3 follows P2 |
| WP-76 — Should: gRPC, MQTT, webhooks with signature verification | Breadth, plus the one transport with a security obligation | Conformance green; a webhook with a bad signature is rejected before the flow starts | WP-72 |
Three transports concurrent, one sequential. WP-72, WP-73 and WP-74 are three new plugin projects with no shared source; they read the conformance suite and do not write it. WP-75 is not one of them — it needs the lease store, and putting it in the parallel batch is exactly the kind of optimism the DSL chain punished.
Recorded here because they belong to no work package: each is a defect in something already marked shipped, and every one was found by building an application rather than by testing the compiler.
| Defect | Why unit tests missed it |
|---|---|
ctx.CapabilityId named the step being undone during an unwind |
The journal was right all along, so the audit trail read correctly while the context disagreed. 06 §7 was silent on which identity a compensation runs under — that silence is what let two readers disagree unnoticed |
OnTimeout, Delay, AwaitSignal compiled to nothing, zero diagnostics |
Nothing had ever tried to use them. AwaitSignal additionally published a hardcoded one-hour timeout in place of the author's duration |
.WithPolicy never reached the plan |
So WP-57's CompensationRetry shipped unreachable from the DSL, and HasCompensationPolicies was structurally always false |
FLOWX1014 never checked the compensation side |
Dormant while nothing could declare a CompensationRetry; the third fix made it live |
The pattern is the finding. Each fix made the next defect reachable rather than
creating it — the holes were always there and nothing could get to them. That is the
argument for samples as a gate rather than as documentation, and it is why
§6a's note that eight of nine samples were
a README.md was a correctness problem and not a tidiness one.
Recorded here on 2026-07-31 because it existed in src/FlowX.Cli/Verification/ and in
22-CLI §7 and appeared zero times in this file or the checklist —
while CHECKLIST §1 ticked "every work package from WP-0 to WP-76 with a
mechanically checkable exit criterion".
It matters more than an unnumbered verb usually would, because ADR-0003 leans on it as the standing mitigation for "a wrong profile is a real bug class". An ADR's live mitigation being invisible to the plan is the same class of gap as a work package with no exit criterion — the plan cannot tell you whether the thing an ADR depends on still works.
It reads only the manifest, so it does not disturb CliLinksNoFlowXAssembly.
What this section claimed on 2026-07-31 and what WP-60 found by reading the check. It said the verb "flags exactly the accident
FLOWX1012would catch at build time", and ADR-0003 said the same. Both were wrong, and the two rules are disjoint rather than overlapping.ProfileCostCheckselects flows whose manifest profile isDurableand reports those with no compensation, no signal and no timer. A compensableEphemeralflow is never in the set it examines. The verb catches the expensive half of a wrong profile — durability bought for nothing — and until WP-60 the lossy half had no check anywhere, at build time or after it.They are complementary: one is a judgement about intent across a whole manifest that no analyzer can make, the other a property of one source file that no manifest records. Neither retires the other. This is a small error with a specific shape worth naming — a mitigation was recorded as covering a gap because both concerned "the wrong profile", and nobody opened the file to check which half.
The counterexample register ADR-0011 requires — does not exist
ADR-0011's revisit condition is "three documented, legitimate counterexamples are collected". Nothing collects them, and no mechanism exists to. By the ADR index's own phrasing — "an ADR with no 'Revisit when' is a decision nobody can ever safely change" — a revisit condition with no collection mechanism is the same defect wearing a different shape: the condition is stated and unreachable.
This is cheap and belongs with P4, where the fixed order first executes and where anyone would first hit a case it forces badly. Recorded here rather than as an open item because it needs no decision, only somewhere to write the cases down.
Not in the table above, and deliberately given no number: it was executed as "WP-74", which this file reserves for Azure Service Bus, and the collision is recorded in §2 rather than resolved by renumbering the phase around it.
A flow that declares an HTTP route no longer needs a hand-written MapPost.
FlowXEndpoints.g.cs is emitted into the user's assembly and registers every routed flow
from one call; samples/ecommerce/Program.cs drops from 12 lines of registration to 2.
It does not cost RuntimeDoesNotReferenceAnyPlugin, which is the interesting part. The
compiler emits the string "FlowX.Http.FlowEndpointExtensions" and resolves it through
Compilation.GetTypeByMetadataName, so FlowX.Compiler knows the transport's name and not
its assembly. Manifest output is byte-identical and the generator cost gate reports −0.50 %
allocations and −0.97 % elapsed, so this is emission the existing budgets already paid for.
It belongs here rather than in P1 because it is the ergonomic half of what P3's three transports each have to repeat: the registration a plugin author would otherwise write by hand once per transport. Whether the same emitter generalises to Kafka and Service Bus is unproved — it has one transport to be right about.
Three things in P3 are not repetition, and all three are already visible:
-
FLOWX1025's open cause blocks the Must. A trigger'sKindis an overridden property — executable code, not attribute data — so a third-partyTriggerAttributesubclass cannot be read from metadata, and WP-26 shipped a warning saying so rather than guessing. Today that costs one warning. In P3 it is hit three times: each of Kafka, RabbitMQ and Service Bus either uses a first-party attribute (which makes ADR-0004's "one trigger abstraction for all transports" true only for transports we ship) or emits a manifest that cannot record its own trigger. The abstractions need a way for a plugin author to declare a readable kind, and it belongs in WP-70 or it is paid for three times. The roadmap's P3 Must does not mention it.This half of WP-70 has shipped, three phases early.
[TriggerKind(TriggerKind.Bus)]carries the kind as an enum constructor argument, which survives to metadata where an overridden property does not;TriggerReaderwalks the base chain, because Roslyn does not honourInherited = trueforISymbol.GetAttributesas reflection does. 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. It was pulled forward because it is an abstraction change: leaving it until P3 means three plugins are written against a contract that is about to change, and this is the cheapest moment it will ever be. The rest of WP-70 — aTriggerSourceConformancesuite,ITriggerSourcedeclared, the package published — has not shipped andPluginsPassConformanceis still blocked. One known hole remains and is stated in three places rather than buried: for an attribute arriving from metadata, nothing checks that the marker and theKindproperty agree, because readingKindmeans running a getter and a generator does not run what it compiles. -
P3 is not where
[Sensitive]'s remaining sinks arrive, though this file said so until P1 closed — the roadmap and 12-Observability both put them in P5, and the correction is at WP-12a. What P3 does add is transports, and a transport is a place a payload is written. The rule that matters is phase-independent and is stated once here: the phase that creates a sink is the phase that redacts it, becauseRedactionCannotBeBypassedis blocked on all of them at once and will otherwise be written against sinks that have been leaking for two phases. -
The trigger model is documented past what exists, in two places, and P3 is where both are paid for. Recorded here on 2026-08-01, from the status box 09-Trigger-Model gained on the same day; neither appeared in this file or the checklist before.
TriggerKind.Grpcdoes not exist at any level. 09 §4 givesGrpcits own row with its own delivery, reply and ordering semantics, and there is noGrpcmember ofTriggerKind(src/FlowX.Abstractions/Triggers/TriggerEnvelope.cs), no such value inschemas/flowx.manifest.schema.json's closedkindenum, and no attribute.TriggerKind.Http's own summary folds "REST, gRPC, GraphQL, webhook" into one kind. This is a ninth kind that was never declared, not a declared one awaiting implementation, and the distinction decides WP-76: shipping gRPC either accepts that it reports asHttp— which is what the abstraction currently says it is — or adds an enum member and a schemaenumvalue, and the schema is the one ADR-0017 is about to freeze.FlowExecutionContext.Triggeris a get-only auto-property that is never assigned, so every running flow readsdefault— kindManual, a nullSource, no body and a nullCorrelationId.FlowContext.TriggerandIterationScope.Triggerboth forward to it faithfully.TriggerEnvelopeis a type rather than a value in the running system: the only places that construct one areFlowX.Testing'sTestFlowContextand two test projects, so the one thing a test can set is the one thing production never sets. Three transports each producing an envelope with nowhere to put it is the shape of the cost, and it lands on WP-70 with[TriggerKind]rather than on each plugin.
Added 2026-08-01, because this plan could say what each phase owed and could not say what the platform did. Three subsystems were found in one week that were declared, published and diffed while nothing executed them — so the only useful question is which subsystems run. CHECKLIST §5e2 carries the full table and its evidence; this carries the ordering argument.
Running: compile-time orchestration · ephemeral execution · durable execution, suspension and timers · compensation · the transactional outbox and broker publication · HTTP and schedule triggers · policy stage 4 · authorisation (two stances refuse) · the manifest · traces and metrics.
Declared and inert: four policy kinds · four trigger kinds. Absent: logs · multi-tenancy · the stream engine · the AI surface · Studio.
- The four inert policy kinds —
RateLimit,Idempotency,Cache,Audit. First because it is the only item that deletes a diagnostic:FLOWX1032exists solely to tell a user their declaration does nothing, and every release that ships it ships an admission.Auditalso closes a hole this week's own work opened and named in ADR-0028: who authorised a step has two answers across a wait, and no audit event records either.RateLimitneeds a distributed store, not a process-local one — a process-local limiter admits n× the declared rate across n nodes, where a process-local breaker is merely slower to protect and never wrong. That asymmetry is why a rate limiter was started and abandoned rather than shipped. Bus, the third transport.KafkaTriggerAttributeexists andRedisStreamEventPublisherproves the output half against a real broker; the input half is unbound, so a flow can publish to a broker and cannot be started by one. It is also the kind that tests ADR-0004 hardest: at-least-once delivery, which HTTP and cron both avoid — cron by deriving an id every node agrees on, HTTP by making the caller retry.- Multi-tenancy. The largest wholly-unbuilt subsystem with a real specification behind it
(16 gives
ITenantResolver's signature, four isolation levels, six fairness mechanisms and RLS as worked DDL). §6a's P6 cell already names the one part that would be invented — journal partitioning — so the tractable scope is resolution, fairness and RLS. - Logs, once the abstraction question is decided:
AbstractionsHasNoDependenciesforbids a package reference,DiagnosticSourceis in the shared framework andMicrosoft.Extensions.Logging.Abstractionsis not. That is a decision, not effort, which is why it sits behind three things that are effort.
Not next, and deliberately: the stream engine (P7 is the least specified phase — nothing defines the checkpoint format, watermark generation or how window state is journaled, so it would be invention rather than implementation) and Studio (sixteen one-line mentions and no design at all).
Six of ten phases have no work packages here, and that is a decision rather than an omission — but the decision was never written down, so this section exists to write it. §6 states the rule for P3: "Full detail is written when P2 closes and the shape is known rather than guessed." The same rule applies further out with more force. What follows is therefore the phase's obligations and its reserved numbers, not its packages.
This section is also the work-package number allocator. Numbers have collided twice — the second time after §2's warning against it was written — because the rule said "claim the number in this file first" and there was nowhere in the file to claim one. The ranges below are that place. Claim a number by editing this table, in its own commit, before the work starts.
| Phase | Reserved | Held to | Design that already exists | Could packages be recorded, or would they be invented? |
|---|---|---|---|---|
| P4 Policy and security | WP-77 … WP-89 · WP-77 claimed 2026-08-01: stage 4 executes | ADR-0011, ADR-0007 | 10-Policy-Framework — 7 stages, a 16-row policy catalogue, 5-level resolution order, retry-safety flowchart, breaker keys; 15-Security — STRIDE, all five authorisation stances | Mostly recorded, and one third delivered at WP-77 (2026-08-01). Of the three decisions named here, one is made: ADR-0023 fixes where a stage hooks in — ExecutionPlan.HasStepPolicies plus a resolved StepNode.StepPolicy, never a per-step walk of the chain — which is the one that had to be settled to keep B2's hard zero. ADR-0024 fixes the nesting docs/10 could not, because §2's "within a stage, an order value breaks ties" names a field PolicyDescriptor has never carried and all four stage-4 kinds share one stage. ADR-0025 argues each skip separately. The other two were deliberately not written, and the reason is the one this repository acts on everywhere else: IIdempotencyStore and the audit-record schema are contracts for policies nothing implements — IStepDispatcher is type-erased, so an idempotency replay cannot reproduce the value a step put in the state bag, and an audit record the engine can write carries no payload, which makes redact vacuous. Also corrected here: the catalogue has 17 rows and only 9 are declarable — eight have no builder method, no attribute and no descriptor kind, so they are not a scope question; and the 5-level resolution order has exactly one real level, because no policy attribute exists |
| P5 Observability and replay | WP-90 … WP-99 · WP-90 claimed 2026-08-01: the telemetry seam, 11 metrics, 10 span attributes | ADR-0008 | 12-Observability — 13 span attributes, 13 metrics, all four replay modes, cardinality rules, SLOs |
Recorded. This cell said one decision had to be forced — that 22-CLI §8 recorded a conflict between flowx replay and the green fitness function CliDependsOnNothingButTheManifest, "and it is an ADR either way". The ADR was written and the conflict was not one: ADR-0020 makes --mode inspect legal without amending the rule, and explicitly does not reach simulate, resume --from or fork — all three need the engine, which is the position the rule exists to forbid. So P5 inherits a narrower question than this cell described, on worse terms: an out-of-process engine the CLI shells to, or three of the four modes not being CLI verbs. flowx_outbox_lag_seconds could not be emitted because outbox_event carries only published_at, NULL for exactly the rows the gauge is about, and that ADR-0018 had rejected a staging timestamp. Every clause is true and the conclusion does not follow. The staging instant is not on outbox_event — it is on the step row that staged it: CommitAsync passes the same sequence local to InsertStepAsync and StageOutboxAsync inside one transaction, flow_step_sequence_idx is UNIQUE on (instance_id, sequence), and flow_step.committed_at has defaulted to now() since migration 0001. So the gauge is an indexed join, staged_seq untouched, no migration. And ADR-0018's rejection was of "order by now() at staging time" on the ground that "approximately right for an ordering guarantee is the same as wrong" — an argument about ordering. A lag gauge orders nothing, and approximately right is what a duration is. Eleven of the 13 now have a producer; flowx_stream_lag_records needs P7 and flowx_trigger_* needs P3's shared admission point, and neither has an instrument created, so both are absent rather than reporting a flat zero |
| P6 Multi-tenancy | WP-100 … WP-109 | ADR-0006 | 16-Multi-Tenant — ITenantResolver with its signature, four isolation levels, six fairness mechanisms, RLS as worked DDL |
Partly. Resolution, fairness and RLS are recordable. Journal partitioning would be invented — 11 §6 names sharding as a lever and stops |
| P7 Streaming | WP-110 … WP-119 | ADR-0003 | No dedicated document. 06 §10 is one backpressure diagram; 09 §9 is a window-semantics table and a DSL sketch | Invented. Roughly one of the roadmap's five Must items is specified. Nothing anywhere defines the checkpoint format, watermark generation, how window state is journaled, or budget B13 |
| P8 AI surface and Studio | WP-120 … WP-129 | ADR-0005, ADR-0014, ADR-0017 | 13-AI-Native — the MCP tool descriptor, the tools/call sequence including refusal and confirmation |
Split. MCP and AgentTrigger are recordable. Studio is 16 one-line mentions and no design. This cell read "manifest v1.0 freeze criteria are written nowhere" until ADR-0017 wrote them. The phase's first Must now has an entry gate — and two of its eight conditions are the outbox (WP-56, P2) and a policy engine (P4), so P8's freeze is gated on two earlier phases rather than on P8's own work |
| P9 Hardening and 1.0 | WP-130 … WP-139 | all of them | The roadmap table only | Invented. The targets are unambiguous (V1–V8, Q1–Q8, B1–B13); there is no design. Note eight of the nine samples are a README.md and nothing else |
Where open item 7 lands. WP-57 needs a
retry policy that executes, and no policy executes at run time at all. Either P2 builds the
slice — retry with backoff at the Consistency stage, honouring ADR-0011's fixed order —
or the item moves into P4's range above. P4's design is complete enough that either choice
is recordable; what is not acceptable is taking it by default, which is what happens while
WP-57 sits at the head of the P2 queue with an unowned dependency.
Two of these phases would require inventing design, and that is the finding. P7 and P9 are not "not yet detailed" — they are not yet designed. A roadmap Must list is not a design, and writing packages against P7's five Must items today would produce plan text that first contact falsifies. That is the failure this document spends most of its length removing, and it would be self-inflicted.
A work package may start only when all are true. This prevents the most common failure mode in a spec-heavy project: building something the spec describes but nobody can verify.
- Its exit criterion is mechanically checkable (a command, not a judgement)
- Its tests-first artifacts are named
- Its dependencies are complete
- The documentation section it implements is identified
Deliberately absent. This is a specification-driven project with one contributor's throughput unknown; a date column here would be fiction, and fiction in a plan is worse than a blank. Sequencing and exit criteria are the real content — they hold regardless of pace.
The roadmap gantt carries indicative durations for phase-level planning only.
| # | Item | Blocks | Owner |
|---|---|---|---|
| 2 | packages.microsoft.com is not |
— | — |
| 3 | — | — | |
| 5 | — | — | |
| 7 | PolicySet.CompensationRetry executes at PolicyStage.Consistency and nowhere else, so the forward path still runs zero policies and P4 generalises rather than replaces it. Original text: "Compensation with its own policies" is in P2's Must and no policy executes at run time at all — the policy engine is P4. Either P2 builds the slice (retry with backoff at the Consistency stage, honouring ADR-0011's fixed order) and P4 generalises it, or the item moves into §6a's P4 range. P4's design is complete enough that either is recordable. What is not acceptable is the third outcome — taking it by default, which is what happens while WP-57 sits at the head of the queue |
WP-57, now | repository owner |
| 8 | [Sensitive] values three phases before the package that redacts sinksJournalPayload, whose sole exit is ToJson(), which redacts — so a store has no route to the object graph. Proved by reading all six stored strings back on a flow whose input, state bag and every step result carry a marked member. RedactionCannotBeBypassed is still blocked until P5, because it is blocked on all sinks at once |
— | — |
| 9 | DependencyLicencesAreCompatible exists in DependencyLicenceTests and reads the resolved transitive graph out of obj/project.assets.json, not only what is declared, so the scan is offline and runs before the commit rather than after it. Npgsql is vetted: PostgreSQL Licence, permissive. Two packages nobody had looked at turned out not to be MIT — SonarAnalyzer.CSharp (SONAR Source-Available Licence) and Microsoft.NETCore.Platforms 1.1.0 (a proprietary Microsoft EULA) — and both are admitted only because the graph proves they contribute no assembly. What is left is not the gate but its reach: the three projects outside FlowX.slnx have their declared references checked and their closure unread, and the template's cannot be restored until WP-70 publishes the packages it names — see DEPENDENCIES.md §5 |
— | — |
| 10 | Revisit when is "never expected" and a freeze checklist has to move as producers land. Not one of the eight holds today — seven name a gap in the repository and the eighth is the version bump itself — which is what makes the deadline datable: thirteen schema-declared fields are written by nothing — twelve since capability.authorization.value was closed and struck in ADR-0017 §1; the figure here is what the record said when this item was resolved, and F1's live count is the ADR's, not this row's; event.schemaVersion is emitted as a constant; ManifestIsComplete still covers two of Q3's four nouns and cannot cover the other two non-vacuously until WP-56 and P4 land; flowx diff has a Breaking rule (FLOWX-DIFF-015) that compares authorization.value, a field the compiler never writes, so it cannot fire; extensions — ADR-0005's stated escape hatch and the whole mitigation for "a public contract forever" — is written, read and tested by nothing. What is not resolved, and is stated in ADR-0018 §3 F8 rather than here: item 6's decision is still unmade, and the freeze is what will force it. Its fourth revisit trigger is now datable; a freeze landing with ADR-0014 still Proposed keeps the errors field by default, which is this table's own failure mode arriving on a date somebody can now read off the repository |
— | — |
| 11 | Three ADRs are stale or malformed against the project's own rules. (a) ADR-0002 does not record that its own revisit trigger has fired, while §1's kill criterion says "revisit ADR-0002 first" — and its mitigation list, declared "all mandatory", includes a ≤ 8 % gate that ADR-0014 §4(4) has since made advisory, so two ADRs disagree on whether the gate binds. (b) Revisit whenplugins/FlowX.Postgres. ADR-0008's is not, and WP-59 made it false a second time — see item 14 rather than growing this row, because the two records no longer fail in the same way. Only (a) and the ADR-0008 half of (c) are live |
Nothing builds; these mislead readers | repository owner |
| 12 | IStepDispatcher.DescribeInput is a defaulted interface member, and a decorator that forgets it is invisible. The reasoning for defaulting to JournalPayload.Empty rather than throwing is written at the declaration and is defensible: a hand-written dispatcher is entitled to run under Durable, and an instance row without an input is what every release wrote until WP-59, so a default that threw would make such a dispatcher unusable. The cost showed up inside WP-59 itself. Both hand-written decorating dispatchers — tests/Banking.Tests/TransferHarness.cs and tests/Workflow.Tests/OnboardingHarness.cs — forward most members and silently inherited this one, putting flow_instance.input straight back to NULL while every other test went on passing; the author found and fixed both. This is the second time a defaulted interface member has cost this repository the same way. The contrast is in the tree: CapabilityContext.CompensatingFor — the member added when ctx.CapabilityId was found naming the step being undone — was declared abstract, so no implementation could quietly keep the old answer and the compiler listed every one that had to change. Do not change the interface on this entry; what is owed is a decision about which of the two shapes the durable dispatcher members take, and it is worth taking once for DescribeStep, DescribeInput and RestoreState together rather than three times |
Nothing; a silent wrong journal | repository owner |
src/FlowX.Compiler/Analysis/TriggerReader.cs carries a stale remark that reads as a design statement. Its class remarks say "Nothing yet turns these attributes into endpoint registrations — the sample maps its route by hand in Program.cs".EndpointEmitter writes FlowXEndpoints.g.cs into the user's assembly and all three samples call the generated app.MapFlowX() — samples/banking and samples/workflow as well as samples/ecommerce. The paragraph now says which kinds are still declaration-only by their enum names — Bus, Schedule, Stream, Change, Agent — rather than claiming it of all of them, and splits the conclusion the raiser kept: the registration and the manifest's triggers block come from one reading of the attribute, so an HTTP route has no second copy to drift from; what remains unasserted is the other direction, a hand-written route reaching a flow at an address it never declared. Raised while writing 09-Trigger-Model's status box |
— | closed | |
FLOWX1006 as owed, one day after WP-59 shipped them.[!IMPORTANT] box no longer says IPayloadSerializer is absent or that FLOWX1006 does not exist, and three further clauses of it were found stale in the same pass — "replay is still absent" (WP-64), the schemaVersion stamp "on neither" sink, and "the generated serialiser is still WP-59". That last one is recorded as a prediction that did not come true: the box was written to worry that a generated writer would open a second redaction exit, and WP-59's writer opened none, so the paragraph now says why rather than being deleted. ADR-0015's Still not in row is struck with the construction that actually shipped — the writer meets commitment 5 by a narrower route than that commitment states — and its 06 §5 take-down line is struck too, verified: docs/06 now has zero no — P2 rows. Both records also gained what neither had, a statement of what of ADR-0008 remains genuinely unbuilt: a binary serialiser plugin, and a contract-declared schemaVersion rather than a constant |
— | closed | |
| 15 | StepLoopBenchmarks.BuildPlan's committed 520 B is not reproducible, and the gate treats allocation counts as exact and machine-independent. Same commit, same runtime version, same warmup and iteration configuration: 456 B on the container at e6fcd37, 464 B on the container at dev, 528 B on the GitHub runner at dev, 520 B in the file. Every other allocation entry in those same runs agreed across both machines, StepLoopBenchmarks.CompensateAll to the byte at 440 B, so this is one entry rather than a broken harness — and BuildPlan builds a plan through Roslyn-adjacent machinery, which WP-31 already names the suspect shape for: "Roslyn sizes some pools from ProcessorCount". Options: (a) find what makes it vary and remove it; (b) band it the way CompilerBenchmarks is banded, with the reason written down; (c) drop the entry. Not (d) — overwrite it with whichever machine ran last, which is what a green tick would cost |
The Benchmark budgets job cannot go green honestly | repository owner |
| 16 | A declared wait the compiler cannot fold publishes no timeout, and nothing says so. ADR-0021 folds .AwaitSignal<T>(timeout)'s duration at build time and, when it cannot, publishes nothing — the precedent merge set, and right for merge. It is wrong here, and the two halves of WP-63 proved it against each other rather than in theory: the timer work made samples/workflow's Waits.Countersignature an environment read so a demonstration would not wait seven days, which is unfoldable, so the merged sample silently stopped publishing timeout — flowx diff had no window to compare and ADR-0021's new field lost the only producer in the repository. That is ADR-0017's F1 failure arriving by a route nobody had listed, and the build stayed green: only Workflow.Tests.ManifestTests caught it, because it is the one test that reads the field. The sample is restored to constants with the reason recorded at the declaration. What is owed is the decision: either a diagnostic on an unfoldable wait (the shape FLOWX1036 already uses for an unreadable policy set), or a stated rule that a declared wait must be a compile-time constant, enforced where the DSL can enforce it. Not silence |
flowx diff cannot report a changed window; F1 can regress unobserved |
repository owner |
Three items in this table are live: 12, 13 and 14, all opened on 2026-08-01, plus what is left of 11. This line read "No item in this table is live", which was already wrong when it was written — item 11 was never struck through and carries a Blocks and an Owner — and the sentence below it listed 11 among the struck rows. Both are corrected rather than deleted, because a table that says it is empty is one nobody re-reads. Item 7 was resolved on 2026-07-31 by WP-57 building the retry slice; item 10 by ADR-0017.
Items 2, 3, 5, 8, 9 and 10 are struck through above rather than deleted, because a plan that silently repairs its own premises teaches nobody what it got wrong. Item 11 is struck clause by clause for the same reason: (b) and half of (c) are discharged and (a) is not. This sentence listed 11 among the fully struck rows while the row itself was live.
Closing item 10 moved one thing about the removed item 6 and did not touch the decision: ADR-0014's fourth revisit trigger — "P8 approaches manifest v1.0 freeze" — was recorded there as "not fired, and not datable". ADR-0017 makes it datable. The trigger still has not fired, and the choice is still unmade and deliberately untracked.
Three items were removed from this table on 2026-07-31 at the repository owner's direction, and the numbering is left with gaps rather than closed up so that references elsewhere still resolve:
| Removed | What it was | What removal means |
|---|---|---|
| 1 | Three infographic PNGs missing from docs/assets/ |
The four broken Markdown links remain, so the CI docs job still fails on them. No longer tracked here |
| 4 | SONAR_TOKEN not configured |
The sonar job keeps emitting a ::warning:: and a step-summary table naming the rows it did not evaluate, so a green tick still cannot be read as a pass |
| 6 | ADR-0014's undecided catalogue-versus-budget choice | ADR-0014 stays Proposed with one revisit trigger fired. The choice is deliberately left unmade |
Back to: README · Checklist · Roadmap · Quality gates