Skip to content

feat(fctl): add portable Flows plugin - #213

Draft
Dav-14 wants to merge 13 commits into
mainfrom
codex/mvp5-flows-plugin
Draft

Dav-14 wants to merge 13 commits into
mainfrom
codex/mvp5-flows-plugin

Conversation

@Dav-14

@Dav-14 Dav-14 commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Adds the 16-command Flows portable plugin, exact SDK pin guards, typed portable failure boundary, concrete output schemas, and dotted-path RenderHints.\n\nValidation:\n- post-origin/main plugin race suite passes\n- coverage 81.9% overall, 82.9% core\n- 11 table hints and 5 evidence-bound omissions\n\nOpen external gates:\n- product tests requiring Docker could not run locally\n- Nix gate not completed locally

Preparation tranche for the fctl Flows plugin (fctl-v2 programme Task 10B).
Contains no plugin: no runtime, component entry point, HTTP client, generated
bindings or ABI. It delivers the one artefact that does not depend on the
fctl-v2 MVP4 contract freeze — a versioned, test-pinned inventory of the
orchestration operation surface and its mapping onto the legacy fctl command
baseline.

Derived from openapi.yaml at this commit, cross-checked against pkg/client:
35 operations (17 orchestration.v1, 18 orchestration.v2), 35 unique
operationIds, none deprecated. All 16 executable legacy fctl `orchestration`
commands at fctl 693c58e2 map onto a current operation — there is no exclusion
to justify — reaching 17 distinct operations. The baseline is v1 throughout
except `orchestration triggers test`, which calls V2.TestTrigger because v1
declares no trigger-test operation.

Records all 35 operations as blocked, separated from the verified facts:
- B1: the generated pkg/client does not build and cannot be imported. Its
  go.mod declares `module openapi` while 94 of its 221 Go files import
  `openapi/...` and formance.go imports `github.com/formancehq/flows/pkg/client/...`;
  its go.sum holds three `/go.mod` hashes and no module-content hash. Task 10B
  requires routing every operation through this client, so nothing is
  admissible. Not repaired here: the client is regenerated by
  `just generate-client`, so its path is a generation-configuration decision
  between the root module and VCS namespaces, not a hand edit.
- B2: seven collection reads have no declared way to bound them; the three v1
  listings emit no SQL LIMIT and the four history reads return the whole array.
- B3: v1 GET /triggers returns at most 15 triggers, computes the next-page
  cursor and then drops it, while declaring no pagination parameter at all —
  a wrong answer, not an error.
- B4: runWorkflow/v2RunWorkflow with wait=true block until the Temporal
  workflow terminates, with no server-side deadline.

Plus eight spec-versus-server divergences, including: /_info answers
unauthenticated though declared under orchestration:read; GET /v2/_info is
declared and generated but not routed, so the version probe is only ever
available unprefixed; scope checking is method-derived and off by default;
`testTrigger` is the only v2 operationId without a v2 prefix; and `cancelEvent`
aborts an instance rather than cancelling an event.

This module declares github.com/formancehq/orchestration/plugins/fctl — a
subdirectory of the authoritative root module path — and imports nothing from
this repository.

Determinism: `just fctl-audit` regenerates the committed report and tables,
`just fctl-audit-check` gates them, and invariant tests pin every count quoted
in prose. Wired into tidy, lint, tests and pre-commit.

No Task 10B acceptance box is ticked. Remaining gates are listed in
plugins/fctl/docs/command-inventory.md §9.
…ted pins

The lock still pinned the SDK to `Dav-14/fctl-v2-poc` at `545521bf`. The
authoritative remote is now `formancehq/fctl-v2-poc` and the programme's
current fctl revision is `e9b1395f`, so the pin was stale in both fields.

Repins `fctl-sdk.lock.json` to `e9b1395f46f3100b381dbe00f5213de28e6df0e1`
of `https://github.com/formancehq/fctl-v2-poc.git` with the SDK content hash
`sha256-DnTiEFya3R9KCYmgv5SO/1StKTCPmndObQrrVHf79Xk=`. The WIT hash is
unchanged: the lifecycle interface is byte-identical across the two revisions,
so the vendored `wit/plugin.wit` needs no change. `pkg/plugin/go.mod` and
`go.sum` are also unchanged between the revisions, so the plugin module
metadata stays tidy.

`nix/fctl-component-tools.nix` bumps its provenance label only. Every
definition it copies from the fctl authoring toolchain — Rust 1.91.1,
wasm-tools 1.239.0, componentize-go 0.4.1, wasi-virt 448f6df8 with all four
source and cargo hashes, and the closed-terminal stdio patch byte-for-byte —
is identical at `e9b1395f`. The 37 lines fctl added to
`nix/portable-go-probe.nix` between the revisions are the jco TypeScript
toolchain, which this repository does not copy.

Closes two gaps the repin exposed:

- Five tracked files restated part of the lock (the Nix tool pin, four
  fixtures in the wrapper contract test, and the revision in both documents)
  with nothing gating their agreement, so a repin could silently leave a
  superseded revision, remote or hash behind. `scripts/fctl_sdk_lock_pins_test.go`
  now anchors each restatement to exactly one value and requires it to equal
  the lock. Each of the five assertions was verified to fail when its file
  drifts. It also re-derives the vendored WIT hash from the lock without
  needing an SDK checkout.
- `producthttp` at `e9b1395f` classifies a non-2xx product response as the
  typed `product_http_error` with `httpStatus` details and 5xx retryability
  instead of the opaque `product_response_failed`. No Flows test covered that
  boundary. `TestProductHTTPErrorsReachTheHostTyped` pins the code, the status
  and the retryability verdict for 404, 409 and 503; it fails against the
  superseded SDK, which proves it pins the new contract rather than restating
  whatever the bridge happens to do.

Documents both facts in the plugin README and records the typed failure in the
inventory's implemented-surface list.

Verified against a checkout of `e9b1395f`: the wrapper and tidy contract
tests, `tidy --check`, the three build-gate self-tests, `go vet`,
`go test -race ./...`, the `fctl_component_guest` entrypoint race suite, the
81.6% coverage gate and `specaudit -check`. Nix was not run in this wave, so
`nix develop -c just precommit`, `just build-component` and the lint recipe
remain unverified; the SDK content hash was computed by an independent NAR
serialiser validated against the previously recorded hash for `545521bf`.

No Task 10B acceptance box is ticked.
…emote

The pin gate added with the repin anchored one value per site, which left two
ways for a superseded value to survive. The README restated the SDK remote with
no anchor at all, so reverting it to the retired `Dav-14` URL kept the suite
green. And because the exactly-one rule constrains matches of the anchor
pattern, a superseded revision written in any other phrasing — prose, a comment,
a "previously pinned" note — was never looked at. The README and the commit
message both claimed otherwise.

Anchor the README's remote, and add TestNoSupersededSDKValueSurvivesInATrackedFile
as the anchors' complement: it walks every SDK-shaped value in the four
restating files and in the lock itself — 40-hex revisions, the canonical WIT
hash, SRI content hashes, the SDK remote owner and the SDK module path — and
fails on any that the lock does not pin, wherever and however it is written.
The handful of unrelated revisions and hashes those files legitimately carry
(the negative fixtures, the WASI-Virt upstream revision and cargo hashes, this
repository's origin/main and the legacy fctl baseline) are listed with their
reason, so adding one is a decision rather than an oversight.

Anchors now tolerate any whitespace between their literal words, so reflowing a
paragraph or respacing a table no longer fails with a message that reads like a
stale pin.

Mutation-verified on every vector: each of the eight anchored sites drifted in
turn fails; a superseded revision, remote owner or module path appended as
prose to either document fails; a superseded content hash appended as a Nix
comment fails; a wrong WIT hash appended to the contract script fails; and both
a README reflow and an inventory table respacing with correct pins stay green.

The README's gate sentence is rewritten to say exactly what the two tests do.
It also records what the lock does not say: the pinned revision is reachable
from the SDK's codex/mvp5-integration branch, not its default branch, and the
lock carries no reason field. The inventory records the same, plus the fact that
nothing gates the copied Nix toolchain against the SDK at the locked revision —
the equality was checked by hand and can drift silently.
… boundary

TestProductHTTPErrorsReachTheHostTyped did not test what its name asserted. It
called the adapter in process and read its returned sdk.Failure, which is not
what a host sees. Flows' only entrypoint is the portable component, and the
pinned SDK's terminal frame carries a failure code and nothing else: the
component rebuilds the wire failure as FailureFromSDK(sdk.Failure{Code: ...}),
so the message, the httpStatus details and the retryable verdict are discarded
by construction. The test could not have detected a boundary that drops them,
because the boundary already does, and the README promised a host-visible HTTP
status and retryability no host can observe.

Rename the adapter test to TestProductHTTPErrorsSurfaceTypedFromTheAdapter and
say in its comment that its status and retryability assertions are adapter-local.
Its value is unchanged: it still forward-pins the typed classification the older
SDK did not have.

Add component.TestOnlyTheTypedFailureCodeCrossesThePortableHostBoundary, which
drives the real portable lifecycle over the protocol — start, host request,
product response, termination — for 404, 409 and 503, and asserts what the wire
termination carries: the code is product_http_error, and message, details and
retryable are empty. The second half is deliberate: it pins today's narrowing,
so a future SDK frame that widens the boundary fails here and forces the README
to be corrected rather than silently becoming true.

Mutation-verified: inverting either assertion fails all three statuses.

The README now states the real end-to-end guarantee — a host sees
product_http_error where it used to see the opaque product_response_failed, and
cannot build a retry policy on the status or the retryability verdict. The
protobuf dependency becomes direct because the new test encodes wire envelopes.
The catalogue declared no RenderHints, so a host had nothing but the raw JSON
to lay out. Declare one compact, ordered table per command — but only where
real result evidence supports a truthful column.

Eight commands get one. Trigger reads carry ID, Name, Event, Workflow ID and
Created At; occurrences carry Date, Trigger ID and Instance ID; workflow reads
carry ID, Created At and Updated At; the instance listing carries ID, Workflow
ID, Created At, Updated At and Terminated.

The other eight get none, and nothing is invented for them. The four no-content
mutations emit the canonical empty result. `triggers test`, `workflows run`,
`instances show` and `instances describe` emit only nested objects or arrays —
the composite presentation reads pair two whole resources, and the trigger-test
result is a filter verdict object and a variables map — so no stable flat
column can name anything in them. Their exact IDs and reasons are recorded in
the tests and in both documents.

Tests came first and assert the exact ordered headers and fields, then prove
them against reality: TestTableRenderHintsDescribeActualResultFields executes
all 16 commands against generated-client fixtures and checks that every column
names a flat field the emitted result actually carries, that a command without
a hint emits no flat field at all, and that every flat field a result does emit
is either a column or an exclusion with a recorded reason — so a new product
field forces a decision instead of disappearing.

Nested, unbounded and low-signal fields are excluded deliberately: trigger
`vars`, `filter` and `version`, the occurrence and instance `error` reasons,
the instance `terminatedAt` and the workflow `config`. No sensitive field is
exposed; the inventory records that this surface declares none, and the
user-authored expressions it does carry are precisely what the exclusions keep
out of a default table.

PublicOutputSchema is untouched: it stays the exhaustive, unnarrowed result
schema and stays byte-equal to RawOutputSchema, which the pinned SDK requires
for an ordinary JSON result. Render hints select what is displayed, not what is
contracted. No fctl renderer is modified; this repository only declares hints.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant