Drift gates
The committed-generated-artifact family — why two build outputs are committed rather than produced, how each is re-derived and compared, and why the comparison styles differ.
A few build outputs in Vike Trader are committed rather than produced at build time, each to keep a whole toolchain — or a whole upstream — out of a clean checkout. That trade buys a fast, dependency-light build and costs one thing: a committed artifact can drift from the source it came from, silently, and no compiler will notice.
The family rule is three clauses, and every member obeys all three: re-derive from source in CI, compare exactly, and pin the generator beside the artifact — not in a central document, which is the copy that rots.
The two members
The cTrader protobuf bindings. crates/bridges/ctrader/src/generated/openapi.rs is
pre-generated and committed, so a normal build never runs prost-build or protoc — they stay out of
the build graph and the supply chain. A path-filtered workflow regenerates from the vendored
schema with the pinned toolchain and fails unless the committed file is byte-identical, a plain
cmp whose reason crates/bridges/ctrader/src/proto.rs's generated module states beside the
include!: the committed file is prost-build's verbatim output for the vendored schema, renamed on
commit.
The vendored IBKR wire-protocol client. crates/bridges/vike-ibkr/vendor/ibapi is a
workspace-excluded copy of an upstream crate, re-derived by a path-filtered workflow that runs a
standalone drift script. The check lives in a script rather than inline in the workflow because it
is not one command: it reconstructs, in stages, a vendoring procedure a person performs by hand.
crates/bridges/vike-ibkr/vendor/ibapi/VENDORED.md's Refresh procedure is that procedure —
re-clone upstream at the desired tag, re-copy src/, Cargo.toml and LICENSE, re-apply the
three manifest trims and the three test-module drops, update the record's own fields — and it ends
by telling the refresher to "run the gate before pushing", naming the same script the workflow
runs. A check the next refresher must run locally cannot live inside a workflow file.
Both are separate, path-filtered workflows rather than jobs in the main CI run, and both carry SHA-pinned actions, a read-only token and a job timeout — the runner also runs live trading.
Why the comparison styles differ
The looser-looking gate is the correct one
The bindings are compared byte-for-byte. The vendored client is compared three different ways in one
run, and its src/ under rustfmt normalization rather than as raw bytes. That is not a weaker gate
written by a tired author — it is the only comparison that could have been green the day it was
written.
The drift script compares the three things that were copied — src/, Cargo.toml and LICENSE,
the set crates/bridges/vike-ibkr/vendor/ibapi/VENDORED.md's "What was copied" names — and each on
its own terms, argued beside the code that performs it.
LICENSE gets a raw cmp: nothing processes it on either side.
Cargo.toml gets a raw cmp after applying the record's documented trims and two declared
normalizations — measured 2026-08-22 against the pinned upstream commit, the trims are
byte-reproducible, "the whole difference being the vike provenance banner at the top of the
committed file and one trailing blank line."
src/ is compared as rustfmt-normalized text, because the raw bytes are not reproducible and
never were. Upstream ships a rustfmt.toml with max_width = 150; that file was not among the
copied ones, and the copy was reformatted under this repository's own config —
crates/bridges/vike-ibkr/vendor/ibapi/VENDORED.md records both, in its "What was copied" section.
Measured 2026-08-22 at the pinned commit, 276 of the 280 committed .rs files differ from
upstream, every difference line-wrapping and none of it code — and running this repository's
rustfmt over upstream's src/ collapses that to zero. A byte gate there would have been red on the
day it was written.
The normalization is honest about both sides of the trade. Both trees go through the same rustfmt, which re-prints parsed source while preserving comments verbatim — so a changed identifier, a changed literal, an added or removed statement, an edited comment, a reordered item and an added or removed file all survive it and redden the gate. What it cannot see is whitespace and wrapping inside the committed files: "rustfmt erases nothing but layout, so the residual is layout alone."
Where each pin lives
The third clause keeps the other two meaningful. The bindings' regen recipe is a committed,
standalone project that =-pins its codegen crates and commits its own lockfile — freezing the
pretty-printer whose output must match byte-for-byte — and CI regenerates with --locked. Those
pins may be bumped only together with a re-commit of the generated file.
For a vendored copy the only build input is an upstream commit, so the pin lives in the vendored
tree's own VENDORED.md and the gate parses it there rather than carrying a second copy a
refresh would have to remember to update. It insists on a full 40-hex commit, because "a tag or a
branch can be moved under us, and an abbreviated sha can collide", fetches that commit rather than a
ref, and cross-checks the recorded version against the committed manifest's — "a half-finished
refresh is exactly the state where they disagree."
The vendoring is not a pure copy. One set of local edits — three test-only modules dropped from
src/ — was undocumented until the gate measured it, and they are reconstructed rather than
ignored, because "a gate that merely IGNORED files missing from the copy would let the next refresh
drop a load-bearing source file in silence."
The member that left, and the rule it left behind
The JForex sidecar jar was the third member: it was committed, and a dedicated workflow rebuilt it
from the committed Java sources to prove the two still agreed. It is a runtime artifact rather
than a compile input, so the release workflow now builds it from those sources on every tag and
attaches it to the Release, checksummed in that release's SHA256SUMS, and a release-tools fetch
script installs it into the runtime-tool directory crates/vike-model/src/state_path.rs's
PROJECT_BIN_DIR names — "so there is no second copy to drift from and nothing a git diff could
fail to notice."
Deleting the workflow with the question would have been wrong, because two properties it rested on
are still true and now more load-bearing: "the release ships bytes nobody reviews, and a
SHA256SUMS line is only worth anything if somebody can re-derive the bytes it describes." So it
became a reproducibility gate — it builds and tests the jar, runs a protocol smoke, then builds
it twice from a clean directory and compares the two byte for byte. The build script had claimed
"same sources plus same JDK gives a bit-identical jar" all along, a claim nothing ever checked,
because the old gate compared a build against a human-committed artifact and "could not tell
reproducibility from luck."
The rule this leaves: an artifact a deployed binary must find is a release asset, never a committed one — commit the pin, ship the build.
The pin outlived the membership. crates/bridges/dukascopy/tests/jdk_pin_gate.rs holds the JDK
version equal across four files — the gate, the release that builds the shipped jar, and both
provisioning scripts — because javac output differs across JDK releases. Its header names the
failure with no other detector: if the gate's pin and the release's pin drift, "the gate reproduces
a jar nobody has, the release publishes a jar nothing checked, and both workflows stay green."
Related: Supply chain · Release gates · Docs gates
Release gates
Five gates over what Vike Trader ships — the container image, the shipped units, the fxcm-linked artifact, the public mirror, and the dispatcher's boot behaviour.
Decision records
One page per architectural verdict, what each field holds, and how to read a Status line — including which status may never be cited as settled.