Skip to content

Close ten engineering-standard conformance gaps - #31

Merged
tmosleyIII merged 9 commits into
mainfrom
chore/engineering-standard-conformance
Oct 23, 2026
Merged

tmosleyIII merged 9 commits into
mainfrom
chore/engineering-standard-conformance

Conversation

@tmosleyIII

Copy link
Copy Markdown
Contributor

What and why

Brings flowctl into conformance with the OBSRVR Engineering Standard v2.0 on as many points as can be done in one change, and publishes docs/engineering-profile.md so the remaining gaps are tracked rather than rediscovered.

flowctl is classified in §13 as network service + CLI tool, so §2–12 all apply. The audit that started this put the repo at roughly 25–30% conformant. This closes ten gaps outright and advances six more.

Four commits, meant to be reviewed in order. The first is a pure gofmt sweep so it doesn't pollute the rest.

Gaps closed

Gap Rule What changed
GAP-1 DOC-1, SEC-11/12 LICENSE (MPL-2.0 — the README has claimed it since the first commit with no file present), SECURITY.md, CONTRIBUTING.md, AGENTS.md
GAP-3 CLI-15, CLI-11 Exit codes 2–8 by cause; 130/143 on clean signal shutdown
GAP-5 VER-1/3/8, DOC-4 Contract owners, docs/adr/ with two ADRs, PR template for change classification
GAP-6 VER-7 One apiVersion validator instead of three that disagreed
GAP-9 CLI-22 Control-character stripping for untrusted strings
GAP-10 §9 merge gate gofmt, staticcheck, -race, govulncheck, gitleaks, buf
GAP-15 CLI-9 FLOWCTL_ env prefix, XDG config, config show
GAP-16 CLI-3 --output no longer means three different things
GAP-17 CLI-7 NO_COLOR, TERM=dumb, TTY detection
GAP-22 BUILD-4 Non-root containers, OCI annotations

Advanced but not closed: GAP-2 (3 of 14 commands converted to the new output path), GAP-7 (buf in CI; the flowctl.v1 rename deferred under ADR 0002), GAP-11, GAP-18, GAP-19, GAP-21.

Three real bugs the new gates found

These are the argument for the gates, so they're worth calling out separately:

  1. Data race in the control plane. GetComponentStatus and ListComponents returned pointers into the mutex-protected service map, so gRPC serialised a status struct while the health janitor was writing to it. go test -race caught it on its first run. Both now clone.
  2. Terminal escape injection. The control plane accepts registrations from processes flowctl didn't start and printed their names and errors to the terminal unescaped. A crafted service name could set the window title, plant a misleading OSC 8 hyperlink, or write to the clipboard via OSC 52.
  3. SIGINT during startup killed the process. The handler was installed only after the pipeline was running, so an interrupt during startup bypassed graceful shutdown and orphaned child components.

Plus two smaller ones: internal/core/pipeline.go discarded errors from src.Events and snk.Write behind a // TODO (a dropped sink write is data loss), and parseFileMode had a byte < 0 comparison that could never be true.

Contract classification (VER-3)

flowctl is at v0.0.12; VER-2 permits a minor bump to break before 1.0. Every break is in CHANGELOG.md:

  • Failures no longer always exit 1. Scripts testing $? -eq 1 should test $? -ne 0.
  • flowctl init --output <file> → --file / -f.
  • flowctl processors find --output <type> → --output-type.
  • flowctl list --table / -t removed; table is the default.
  • --output yaml and --output wide now error instead of being silently ignored.
  • apiVersion: v1 is rejected; use flowctl/v1. It was already rejected by flowctl validate — only flowctl run accepted it, and ran it as v1 anyway.

Neither flag rename keeps a deprecated alias, which is a deliberate call: a local --output shadows the global one, so an alias would make --output json silently mean "write to a file named json" — the exact confusion CLI-3 exists to prevent.

ADRs: 0001 (output and exit codes), 0002 (deferring flowctl.v1, with the CONF-1 exception recorded).

On the linter exclusions

staticcheck.conf excludes exactly three checks — ST1000, ST1003, SA1019 — each with a written reason and a gap ID. The other 32 pre-existing findings are fixed, not excluded, so the gate is real rather than decorative. SA1019 is the grpc.DialContext → NewClient migration, which isn't mechanical (NewClient never blocks) and needs its own change.

Testing

  • make lint passes (gofmt, go vet, staticcheck — zero findings)
  • make test passes
  • make test-race passes across every package
  • make verify-repro — two builds, identical hashes

New tests: golden tests for every output envelope, exit-code mapping including wrapped gRPC statuses, terminal-injection cases drawn from real OSC sequences, and fuzz tests for the sanitiser and apiVersion (the latter found a zero-padding case that corrected the test's invariant).

Not in this PR

  • GAP-12, control plane authn/authz — the largest remaining gap, and it needs a design decision (workload identity vs per-caller keys, where the read/operate split falls) that belongs in an ADR first.
  • GAP-13/14, health service and metrics — the next thing worth doing; GAP-14 blocks Lake's freshness and completeness SLOs.
  • GAP-4 + GAP-7, event envelope fields and the flowctl.v1 rename — both wire-breaking, so they should cost one coordinated migration, not two.

docs/engineering-profile.md has the full register with status on all 25 gaps.

🤖 Generated with Claude Code

https://claude.ai/code/session_01H3BjiVyh7ifuS7xSrRUe2m

tmosleyIII and others added 4 commits October 20, 2026 08:08
Mechanical gofmt sweep so the gofmt CI gate added later in this branch
starts from a clean tree. No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes GAP-1 and the structural half of GAP-5 from docs/engineering-profile.md.

- LICENSE: MPL-2.0 verbatim. The README has claimed MPL-2.0 since the first
  commit with no licence file present, which made the claim unenforceable.
- SECURITY.md: private reporting path, response targets, and an explicit scope
  that names the unauthenticated control plane (GAP-12) as a known limitation
  rather than letting people rediscover it as a finding (SEC-12).
- CONTRIBUTING.md: setup, Conventional Commits, and the contract-change
  classification table from VER-3.
- AGENTS.md: agent instructions per DOC-1. CLAUDE.md now points at it rather
  than duplicating, so the two cannot drift.
- docs/adr/: MADR template, plus 0001 (the exit-code and output contract this
  branch implements) and 0002 (deferring the flowctl.v1 proto rename, with the
  CONF-1 exception recorded).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements ADR 0001. Closes GAP-3, GAP-9, GAP-15, GAP-17 and the foundation
of GAP-2 and GAP-18 from docs/engineering-profile.md.

New packages:

- internal/exitcode: the CLI-15 code vocabulary, plus Of() which classifies
  gRPC statuses, context deadlines and filesystem errors so commands get a
  useful code without hand-classifying every call site. It walks the error
  chain, because status.FromError does not unwrap and commands wrap with %w.
- internal/sanitize: strips C0/DEL/C1 from untrusted strings (CLI-22). The
  control plane accepts registrations from processes flowctl did not start,
  and printed their names and errors straight to the terminal; a crafted
  service name could emit OSC sequences. JSON output is deliberately not
  sanitised — encoding/json escapes control characters already, and stripping
  would corrupt data the consumer is entitled to receive intact.
- internal/cliout: one Printer owning format selection, the JSON/NDJSON
  envelope with schema identifiers (CLI-6), stdout/stderr separation (CLI-5)
  and colour suppression (CLI-7).

Behaviour changes:

- Errors now exit 2-8 by cause instead of always 1. Unknown flags and
  unimplemented --output values exit 2; an unreachable control plane exits 7.
- --output yaml|wide were advertised and never implemented; they are now a
  usage error rather than silently ignored.
- FLOWCTL_ env prefix. viper.AutomaticEnv had no prefix, so it was binding
  bare OUTPUT, CONTEXT, NAMESPACE and LOG_LEVEL from the environment.
- Config honours XDG_CONFIG_HOME, and layers a project flowctl.yaml over the
  user file. Config files are located by exact name: viper's SetConfigName
  search with a config type also matches extension-less files, and would try
  to parse the flowctl binary in the working directory as YAML.
- Logs are JSON unless stdout is a colour-capable terminal, carry service and
  version, and timestamp in RFC 3339 UTC (OBS-1, DATA-3).
- flowctl config show reports each setting with its source (CLI-9).
- flowctl list drops --table/-t; table is the default and --output json
  replaces it.

test/tools.go pins flowctl-sdk, which only test/testdata imports. The Go tool
ignores testdata, so tidy dropped it and the delivery tests failed at run time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes GAP-5, 6, 10, 11(partial), 16, 21(partial), 22 and advances GAP-2, 7,
18, 19 from docs/engineering-profile.md.

CLI contracts
- apiVersion is validated by one shared package (internal/apiversion). The
  CUE schema required flowctl/v1 but both Go loaders only checked non-empty,
  so 'apiVersion: v99' was rejected by validate and silently run as v1 by
  run — the reinterpretation VER-7 forbids. A test asserts the Go pattern and
  the CUE constraint describe the same language, so they cannot drift.
- init --output is now --file/-f, and processors find --output is now
  --output-type (CLI-3). No deprecated aliases: a local --output shadows the
  global one, so an alias would make --output json mean 'write to a file
  named json' — the exact confusion the rule prevents.
- The SIGINT handler is installed before startup rather than after it. A
  signal arriving during startup previously killed the process outright,
  orphaning child components and leaving the run row marked running.

Bug fixes found by the new gates
- Data race: GetComponentStatus and ListComponents returned pointers into the
  mutex-protected service map, so gRPC serialised a status struct while the
  health janitor mutated it. Both now clone. Found by go test -race on its
  first CI-equivalent run.
- internal/core/pipeline.go discarded errors from src.Events and snk.Write
  behind '// TODO: Handle error'. A dropped sink write is data loss; both are
  now logged.
- parseFileMode compared a byte against 0, which can never hold. The
  validation was still correct (the upper bound catches underflow) so this is
  dead code, not a vulnerability.

Merge gate (§9)
- gofmt, go vet, staticcheck, go test -race, govulncheck, gitleaks, buf lint,
  buf breaking against the last release tag, and a check that generated
  protobuf is current.
- staticcheck.conf enables everything except ST1000, ST1003 and SA1019, each
  with a recorded reason and a gap ID. The other 32 pre-existing findings are
  fixed rather than excluded, so the gate is real.
- A PR template prompts for the VER-3 change classification.

Build and supply chain
- toolchain pinned in go.mod (BUILD-2).
- -trimpath everywhere, and the only embedded timestamp is the commit's.
  Deliberately not the ambient SOURCE_DATE_EPOCH: the Nix shell exports
  315532800, which would stamp every build 1980. make verify-repro builds
  twice and compares hashes, and runs in the release gate.
- goreleaser gains keyless cosign signing and SBOM generation (SEC-10);
  release.yml gains id-token: write and the release-gate checks.
- Containers run non-root with OCI source/revision/version annotations. The
  duckdb-consumer Dockerfile also loses a builder stage that pulled a Go
  toolchain and discarded it.

Also fixes the smoke scripts and docs that invoked the renamed flags, and
rewrites CHANGELOG.md to cover the release history it previously omitted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-20T12:59:08.513912Z 206ac29 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 206ac29b04

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread cmd/list/list.go
Comment on lines +104 to +105
row := cliout.Row{
"id": service.ServiceId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Sanitize C1 controls before JSON encoding

When a registered component supplies an ID containing a C1 control such as U+009B, this value is passed unchanged to JSON/NDJSON output. Go's encoding/json escapes C0 characters such as ESC but emits U+0080–U+009F as raw UTF-8, so displaying flowctl list --output json in a terminal can still execute a crafted control sequence. Sanitize component-provided IDs and metric keys before placing them in output rows, including machine-readable formats.

AGENTS.md reference: AGENTS.md:L96-L99

Useful? React with 👍 / 👎.

Comment on lines +178 to +180
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sigChan)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Cancel startup when a signal arrives

Registering sigChan here only buffers SIGINT/SIGTERM; nothing reads it or cancels r.ctx until startup has completely finished. If the signal arrives while components are launching or registering, the process continues starting work and can spend 30 seconds per component in waitForComponentRegistration—or indefinitely in another startup call—before honoring the shutdown request. Consume the signal concurrently during startup, cancel startup work, and run cleanup immediately.

Useful? React with 👍 / 👎.

Comment thread cmd/root.go
return err
}

format, err := cliout.ParseFormat(output)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Read the output format from resolved Viper configuration

When FLOWCTL_OUTPUT=json or a loaded flowctl.yaml sets output: json, Viper resolves that value, but the Cobra-bound output variable remains its table default unless --output was explicitly passed. Consequently config show reports the environment/file value while every command still renders a table. Parse viper.GetString("output") here (and likewise use the resolved value for other bound settings) after loading configuration.

Useful? React with 👍 / 👎.

The three gate jobs that failed on the first CI run each needed a
different answer.

govulncheck (SEC-9) — 13 reachable vulnerabilities, so the gate was
working. Upgraded x/net to v0.60.0, grpc to v1.83.2, docker to
v28.3.3 and mapstructure to v2.3.0, which clears 11 of them.

The remaining two are Moby daemon issues with no fixed version
published: an AuthZ plugin bypass and an off-by-one in plugin privilege
validation. flowctl imports docker/docker only as a client
(api/types/container, client, pkg/stdcopy), runs no daemon, serves no
Docker API and manages no plugins; govulncheck reaches the module
through package init alone. Rather than ignore the exit code, they are
exempted individually in .vex/flowctl.openvex.json with a justification
and an impact statement, and scripts/govulncheck.sh fails on anything
not covered there — so the gate cannot quietly rot into a no-op. SEC-12
asks for exactly this.

gitleaks (SEC-6) — the Action wrapper requires a paid licence for
organization repos. The binary itself is open source, so CI installs a
pinned release and runs it directly, scanning both the working tree and
the commits a PR adds.

buf lint (VER-5) — the STANDARD rule set flags pre-existing naming:
RPC request/response types, a service suffix, shared Empty/Pong
messages, and three *_UNKNOWN enum zero values. Every one requires
renaming something that is visible on the wire or in generated Go, which
is the migration already deferred in ADR 0002 — doing them piecemeal
would spread one breaking change over several releases. Those five rules
are excepted with reasons; everything else in STANDARD is enforced, and
buf breaking is unaffected.

Also makes buf the single protobuf generator. make generate and
scripts/generate.sh previously shelled out to a local protoc, so the
committed output depended on whichever plugin versions a developer had
installed — which made the 'generated code is current' check unstable.
Plugin versions are pinned in buf.gen.yaml; the 18-line diff under
proto/ is the resulting version-header change, and regeneration is now
idempotent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tmosleyIII

Copy link
Copy Markdown
Contributor Author

Update: all seven gate jobs green

The first CI run failed three jobs. Each needed a different answer, and two of them were the gate doing its job rather than the gate being wrong.

govulncheck — 13 reachable vulnerabilities. Upgrading x/net → v0.60.0, grpc → v1.83.2, docker → v28.3.3 and mapstructure → v2.3.0 cleared 11.

The last two are Moby daemon issues with no published fix — an AuthZ plugin bypass and an off-by-one in plugin privilege validation. flowctl imports docker/docker only as a client (api/types/container, client, pkg/stdcopy), runs no daemon, serves no Docker API and manages no plugins; govulncheck reaches the module through package init alone. Rather than ignore the exit code, they're exempted individually in .vex/flowctl.openvex.json with a justification and an impact statement, and scripts/govulncheck.sh fails on anything not covered there — so the gate can't quietly rot into a no-op. SEC-12 asks for exactly this. I verified the failure path too: with the VEX file removed, the script exits 1.

gitleaks — the Action requires a paid licence for org repos. The binary is open source, so CI installs a pinned release and runs it directly, scanning both the working tree and the commits a PR adds.

buf lint — pre-existing naming. The STANDARD set flags RPC request/response type names, a service suffix, shared Empty/Pong messages, and three *_UNKNOWN enum zero values. Every one needs renaming something visible on the wire or in generated Go — the migration already deferred in ADR 0002. Doing them piecemeal would spread one breaking change across several releases, so those five rules are excepted with reasons and everything else in STANDARD is enforced. buf breaking is unaffected.

I also made buf the single protobuf generator. make generate shelled out to a local protoc, so committed output depended on whichever plugin versions a developer had — which is why the "generated code is current" check was unstable. Plugin versions are now pinned in buf.gen.yaml; the 18-line diff under proto/ is the resulting version-header change, and regeneration is idempotent.

Job Result
Lint (gofmt, vet, staticcheck) pass
Build and test pass
Race detector pass
Vulnerability scan pass
Secret scan pass
Protobuf contracts pass
Onboarding smoke tests pass

The previous commit added internal/sanitize and wired it into cliout, but
only three commands render through cliout. status, pipelines, chunks,
processors and dashboard still printed component-supplied strings with
raw fmt.Printf, so the CLI-22 hole was open on most of the surface —
including chunks.go printing a component-relayed Error string verbatim,
and dashboard writing component ids into a continuously redrawn TUI.

Claiming GAP-9 closed was wrong; this actually closes it.

Adds sanitize.Message, a protoreflect walk that returns a sanitised
clone of any protobuf message: strings, repeated strings, map keys and
values, recursing into nested messages, leaving bytes fields alone since
they carry encoded payloads rather than display text. Every unconverted
command now calls it on each control-plane response.

Sanitising a clone rather than in place matters: a command that later
gains JSON output must emit the original bytes, because encoding/json
escapes control characters already and stripping them would corrupt data
the consumer is entitled to receive intact. The comment at each call
site says to drop it when the command moves to cliout, which sanitises
at the point it formats a cell — the better boundary.

Two approaches were rejected. Wrapping ~70 print sites individually is
easy to get wrong and leaves future prints uncovered. Wrapping stdout in
a filtering writer would also strip flowctl's own intentional ANSI.

Verified end-to-end rather than only by unit test: registering a service
named "evil\x1b]0;PWNED\x07\x1b[2Kbenign" with metadata keys and values
carrying an OSC 52 clipboard write, then byte-scanning stdout of list,
status, processors list, pipelines list and chunks list. Zero ESC bytes
on every path; the name renders as inert printable text.

That scan also caught a CLI-7 violation the earlier commit missed:
status emitted raw ANSI colour and a screen-clear unconditionally, so
redirecting it to a file embedded escape codes and piping it to grep
could fail to match. Colour there now resolves once through
cliout.ShouldColor. dashboard is left to lipgloss's own detection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tmosleyIII

Copy link
Copy Markdown
Contributor Author

Correction: the escape-injection fix was incomplete

Asked to confirm the five bugs were resolved, I re-checked each against the committed code rather than from memory. Four were fully fixed. The escape-injection one was not, and I had described it as though it were.

internal/sanitize was only ever called from internal/cliout, and only three commands render through cliout. The other seven — status, pipelines, chunks, processors list/show/find and dashboard — still printed component-supplied strings with raw fmt.Printf. chunks.go:133 printed a component-relayed Error string verbatim, and dashboard.go:260 wrote component ids into a continuously redrawn TUI. So the hole was open across most of the surface while the changelog said it was closed.

Now actually fixed, in 4411e82.

sanitize.Message — a protoreflect walk returning a sanitised clone of any protobuf message: strings, repeated strings, map keys and values, recursing into nested messages, leaving bytes fields alone since those carry encoded payloads rather than display text. Every unconverted command calls it on each control-plane response.

Cloning rather than mutating matters: a command that later gains JSON output must emit the original bytes, because encoding/json escapes control characters already and stripping them would corrupt data the consumer is entitled to receive intact. Each call site carries a comment saying to drop it when the command moves to cliout, which sanitises at the point it formats a cell — the better boundary.

Two alternatives rejected: wrapping ~70 print sites individually is easy to get wrong and leaves future prints uncovered; wrapping stdout in a filtering writer would also strip flowctl's own intentional ANSI.

Verified end-to-end, not just by unit test. Registered a service named evil\x1b]0;PWNED\x07\x1b[2Kbenign with metadata keys and values carrying an OSC 52 clipboard write, then byte-scanned stdout of list, status, processors list, pipelines list and chunks list:

list            ESC=0
status          ESC=0
processors list ESC=0
pipelines list  ESC=0
chunks list     ESC=0

The name renders as inert printable text: evil]0;PWNED[2Kbenign.

That scan also caught a CLI-7 violation the earlier commit missed. status emitted raw ANSI colour and a screen-clear unconditionally — the first scan showed ESC=5 from flowctl's own colorizeStatus, into a pipe. So flowctl status > file embedded escape codes and flowctl status | grep RUNNING could fail to match. Colour there now resolves once through cliout.ShouldColor; dashboard is left to lipgloss's own detection. GAP-17 was also overstated as closed, and now is.

All seven gate jobs green.

tmosleyIII and others added 3 commits October 20, 2026 10:50
…e to cliout

Advances GAP-2 from 3 of 14 commands to 11. Each converted command now
emits --output json and --output ndjson with a schema identifier, and
drops its sanitize.Message call: cliout strips control characters as it
formats a cell, and machine output must keep the original bytes.

New schemas, all contracts under VER-3:
  flowctl.processor_list.v1 / flowctl.processor.v1
  flowctl.chunk_run_list.v1 / flowctl.chunk_run.v1
  flowctl.pipeline_list.v1 / flowctl.pipeline.v1
  flowctl.pipeline_run_list.v1 / flowctl.pipeline_run.v1
  flowctl.pipeline_status.v1
  flowctl.translation.v1

Machine output carries full values where the table condenses: every
event type rather than "first (+2)", raw counts, real timestamps, and
health as a stable lowercase string. The table layout is explicitly not
a contract.

BREAKING: translate --output/-o is now --target/-t. It selected a
deployment target, not an encoding, so it shadowed the global --output —
the third instance of the CLI-3 collision, after init and processors
find. Docs and examples updated.

status keeps its grouped human layout, which no generic renderer would
reproduce, and emits a single document in machine mode carrying the
summary and every component — so a caller needs one call, not two. Its
--watch is now a usage error when combined with a machine format, since
redrawing a terminal has no meaning for a consumer reading one document.

Two latent bugs fixed on the way:

- pipelines sliced run.RunId[:8] for its short-id column, which panics
  on any id shorter than eight bytes. The control plane is a trust
  boundary and the id comes off the wire; shortID now slices by rune
  count and tolerates short input.
- status built its component groups twice, once in a loop whose appends
  were then discarded. Caught by staticcheck.

Verified against a live control plane: every converted command emits a
parseable envelope, the grouped status table still renders, exit codes
hold (3 for an unknown processor, 2 for --watch with json), and a
service registered under a hostile name still produces zero ESC bytes on
stdout across all five list paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes GAP-2 and GAP-18. Every command that produces records now emits
--output json and --output ndjson with a schema identifier; the twelve
schemas are listed in CHANGELOG.md and are contracts under VER-3.

Converted in this commit: validate, init, sandbox status. Guarded or
routed: dashboard, run, server, the --show-status live monitor, and the
component downloaders in internal/registry and internal/components.

Commands that produce no records say so rather than pretending:

- dashboard is a full-screen TUI. It rejects a machine format and points
  at 'flowctl status --output json', and refuses to run at all when
  stdout is not a terminal instead of writing escape codes into a pipe.
- run and server are long-running. Their startup banners were on stdout
  and are now on stderr, so 'flowctl run' writes nothing to stdout.

BREAKING: translate --output/-o is now --target/-t. That was the third
instance of the CLI-3 collision, after init and processors find.

Also fixed while converting:

- init prompted without checking whether stdin is a terminal, so piping
  anything into it read EOF for every answer and silently built a
  pipeline from defaults. It now errors with a pointer to --preset and
  --non-interactive (CLI-8). In table mode it prints just the created
  path, which makes `flowctl run "$(flowctl init --preset ...)"` work.
- validate exited via a bare os.Exit(1), bypassing the exit-code
  contract; it now exits 8 on failure and 3 on a missing file. It also
  emits a result document even when the file fails to load, so a caller
  never has to handle two shapes.
- translate reported an incomplete pipeline as a generic failure; a
  missing image is a data validation failure, so it exits 8.
- The live monitor and status cleared the screen unconditionally.
  Writing escape codes into a redirected stream corrupts it (CLI-7).

After this there is no bare fmt.Print* to stdout left anywhere outside
one TTY-gated screen clear.

Verified against a live control plane: all ten record-producing commands
emit exactly one parseable document under --output json, zero ESC bytes
reach stdout on any path, and exit codes hold (2 for a machine format on
dashboard or init, 3 for a missing file, 8 for failed validation).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…erator test to the contract

Two problems the race-detector run surfaced.

grpc.NewClient replaced grpc.DialContext+WithBlock during the
conversion, and NewClient connects lazily: a dead control plane no
longer failed at dial time, so formatPipelinesControlPlaneError never
ran and the most common error an operator hits degraded to a bare
"connection refused". cliout.UnreachableInterceptor restores the
actionable message on every unary call, in one place rather than at each
of the seven call sites, and keeps the Unavailable status so the exit
code is still 7.

TestFlagshipOperatorFlow asserted on the exact column spacing of
pipelines run-info — "Pipeline:      operator-smoke" — which ADR 0001
explicitly declares is not a contract. Now that --output json exists the
test binds to the schema instead, and checks the human path only for
content rather than layout.

The new runCommandJSON helper captures stdout and stderr separately and
parses stdout alone, so the test asserts CLI-5 as a side effect: any
progress line leaking onto stdout now fails the parse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tmosleyIII

Copy link
Copy Markdown
Contributor Author

GAP-2 and GAP-18 closed: the output conversion is finished

Every command that produces records now emits --output json and --output ndjson with a schema identifier. Twelve schemas, all contracts under VER-3:

Command Schema
version flowctl.version.v1
list flowctl.service_list.v1 / flowctl.service.v1
config show flowctl.config_setting_list.v1 / flowctl.config_setting.v1
status flowctl.pipeline_status.v1
pipelines list flowctl.pipeline_list.v1 / flowctl.pipeline.v1
pipelines runs / active / run-info / stop flowctl.pipeline_run_list.v1 / flowctl.pipeline_run.v1
chunks list / show flowctl.chunk_run_list.v1 / flowctl.chunk_run.v1
processors list / find / show flowctl.processor_list.v1 / flowctl.processor.v1
sandbox status flowctl.sandbox_container_list.v1 / flowctl.sandbox_container.v1
validate flowctl.validation_result.v1
translate flowctl.translation.v1
init flowctl.pipeline_init.v1

Machine output carries full values where the table condenses: every event type rather than first (+2), raw counts, RFC 3339 timestamps, status as a stable lowercase string.

Commands that produce no records say so rather than pretending. dashboard is a full-screen TUI — it rejects a machine format, points at flowctl status --output json, and refuses to run when stdout is redirected. run and server are long-running; their banners moved to stderr, so flowctl run now writes nothing to stdout.

There is no bare fmt.Print* to stdout left anywhere outside one TTY-gated screen clear.

Third --output collision

translate --output/-o selected a deployment target, not an encoding. BREAKING: it is now --target/-t. That makes three (init, processors find, translate) — the rule earns its place.

Five more bugs found while converting

  1. pipelines sliced run.RunId[:8], which panics on any run id shorter than eight bytes. The id arrives over the wire from the control plane, which is a trust boundary.
  2. init prompted without checking whether stdin is a terminal (CLI-8). Piping anything into it read EOF for every answer and silently built a pipeline from defaults.
  3. validate exited via a bare os.Exit(1), bypassing the exit-code contract. Now 8 on failure, 3 on a missing file — and it emits a result document even when the file fails to load, so a caller never handles two shapes.
  4. translate reported an incomplete pipeline as a generic failure. A missing image is data validation: exit 8.
  5. The live monitor and status cleared the screen unconditionally, corrupting redirected output (CLI-7).

One regression I introduced, and caught

Swapping grpc.DialContext+WithBlock for grpc.NewClient changed connection to lazy, so a dead control plane stopped failing at dial time and formatPipelinesControlPlaneError never ran — the most common operator error degraded to a bare "connection refused". cliout.UnreachableInterceptor restores the hint on every unary call in one place, keeping the Unavailable status so the exit code is still 7. Caught by TestFlagshipOperatorFlow, not by me.

That test also had to change: it asserted on the exact column spacing of pipelines run-info ("Pipeline: operator-smoke"), which ADR 0001 explicitly declares is not a contract. It now binds to the schema, and its new runCommandJSON helper captures stdout and stderr separately — so the test asserts CLI-5 as a side effect: any progress line leaking onto stdout fails the parse.

Next gap

docs/engineering-profile.md now names GAP-13 + GAP-14 — the gRPC health service and the first metrics. GAP-14 is the one with consequences outside this repo: without last-ledger, lag-behind-tip, backlog and gap-count signals, Obsrvr Lake cannot publish the freshness and completeness SLOs OBS-4 requires of it.

All seven gate jobs green.

@tmosleyIII
tmosleyIII merged commit be8c605 into main Oct 23, 2026
7 checks passed
@tmosleyIII
tmosleyIII deleted the chore/engineering-standard-conformance branch October 23, 2026 00:53
@tmosleyIII tmosleyIII mentioned this pull request Oct 23, 2026
6 tasks done
tmosleyIII added a commit that referenced this pull request Oct 29, 2026
* fix(security): require Go 1.26.10 for the crypto/x509 fix

GO-2026-6809: compromised intermediate certificates were not distrusted
by crypto/x509. Fixed in go1.26.10; we required 1.26.9.

flowctl reaches it through TLS and mTLS to the control plane —
govulncheck traces it via x509.Certificate.Verify — so this is reachable
rather than merely linked.

Nothing in this repo changed to cause it. The advisory was published
after #31 ran, and the vulncheck gate added in that PR caught it on the
first run against main. That is the gate working as intended: it pulls a
live vulnerability database, so it can go red without a code change.

Raises the go directive rather than only the toolchain line, so an older
toolchain refuses to build:

    go: go.mod requires go >= 1.26.10 (running go 1.26.9; GOTOOLCHAIN=local)

With only a toolchain line and a lower go directive, GOTOOLCHAIN=local
silently builds against the affected standard library, which is the
outcome worth preventing for a security fix. No separate toolchain line
is kept: it is redundant once the two match and 'go mod tidy' — which CI
enforces — removes it.

Note for Nix users: nixpkgs ships Go 1.26.6-1.26.9, so the dev shell's
Go is older than the minimum and Go downloads 1.26.10 itself. Recorded
against GAP-21 in the engineering profile, which already tracks CI and
the flake disagreeing about the toolchain.

Verified: govulncheck clean, lint clean, full suite and -race green
across all 19 packages, and the build still reproduces byte-for-byte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* build(nix): move the dev shell to Go 1.27.3

The dev shell was on go_1_26, which nixpkgs ships as 1.26.9 — older than
the 1.26.10 this branch now requires. Every Nix developer would have
relied on Go downloading a toolchain itself.

No flake.lock change: the pinned nixpkgs already carries go_1_27 as
1.27.3, a stable patch release. An earlier note on this PR claimed it
was 1.27rc3; that came from the local flake registry, which is stale,
not from the lock.

staticcheck had to be rebuilt with the same Go. It links go/types, so a
binary built with 1.26 cannot parse the 1.27 standard library, and
make lint failed in the shell with 'method must have no type parameters'
from math/rand/v2. Not a version problem — nixpkgs has the current
2026.2.2 — so the fix is the override rather than a bump. CI was never
affected; it uses dominikh/staticcheck-action independently of Nix.

Verified inside nix develop: Go 1.27.3, lint clean, 19 packages green,
govulncheck clean, build still reproduces byte-for-byte.

Records GAP-26 for a pre-existing failure found on the way:
nix build .#default runs protobuf generation without the protoc plugins
in nativeBuildInputs and fails identically on main. nix develop, the
path BUILD-1 documents, is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant