EQ-385: external strategy provenance and operator documentation - #396
Conversation
Closes the final issue in the "Add gRPC to trader for remote out of
tree strategies" milestone: makes --strategy-exec runs reproducible
and diagnosable the same way in-tree strategy runs already are.
- externalStrategyParams (cmd/trader/backtest/run.go) gains Mode
("external"), StrategyName/StrategyVersion (the guest's own
Handshake Descriptor), ProtocolVersion (protocol/strategy/v1.
ProtocolVersion, the version actually negotiated), Transport
("unix", explicit rather than assumed), Exec (resolved to an
absolute path, not the CLI's possibly-relative spelling), and
ExecDigest (a "sha256:<hex>" content digest of the executable file
at launch time, matching backtest.Manifest.ConfigDigest's own
convention) — distinguishing two different builds behind the
identical --strategy-exec path, which the path/name alone cannot.
The ephemeral Unix-domain socket path Launch generates per run is
never recorded anywhere in this struct (issue #385's own explicit
"no ... machine-specific ephemeral socket paths ... as semantic
identity" exclusion).
- New TestVerticalSlice_RunWithStrategyExec_RecordsProvenance reads
every new field back from a real "trader backtest run" JSON report
(not run.go internals directly) and asserts the ephemeral socket
directory name never appears anywhere in the recorded provenance,
even as raw text.
- docs/UsersGuide.md: `trader backtest run` now documents three
strategy paths (not two), the three new flags, and a new "External
strategies" section covering building a Go external strategy
(strategysdk, with pointers to examples/strategysdk-minimal and
examples/sma-long-hold), running it, process lifecycle and logs
(environment inheritance, stderr capture, graceful SessionEnd vs.
SIGTERM/SIGKILL, mid-run crash detection), protocol/capability
mismatch behavior at Handshake, the reproducibility provenance
fields above with a worked example, and v1's explicit limitations/
non-goals (local executable only, one strategy per run, no
sandboxing beyond OS process isolation, config schema is the
author's own responsibility).
Verification: go build/vet clean, gofmt clean, full go test ./...
green under -race, golangci-lint clean (only the 3 known pre-existing
unrelated research-runs/ errcheck findings), no stray processes.
Package coverage 82.1%.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fVsVuQCgkrhiYaXLxyUF3
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Resolve executable path and digest handling so provenance matches the launched artifact and avoids unnecessary memory spikes.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (1)
What changed in this PR
Adds external-strategy provenance tracking, executable digests, end-to-end coverage, and operator documentation for trader backtest run.
Changes:
- Records strategy identity, protocol, transport, executable path, and digest.
- Adds vertical-slice provenance validation.
- Documents external strategy workflows, lifecycle, and limitations.
| File | Summary |
|---|---|
cmd/trader/backtest/run.go |
Adds external provenance fields and executable hashing. |
cmd/trader/backtest/external_strategy_test.go |
Verifies persisted provenance through a vertical-slice test. |
docs/UsersGuide.md |
Documents external strategy usage and provenance. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| execDigest, err := execContentDigest(execAbs) | ||
| if err != nil { | ||
| return err |
rustyeddy
left a comment
There was a problem hiding this comment.
Reviewed PR #396 (EQ-385: external strategy provenance and operator documentation). The provenance shape and operator docs are generally strong, but I see two reproducibility issues to fix before merge:
- Resolve/hash the exact executable before launch, and launch that exact resolved artifact.
Copilot's finding is valid. The current order is:
process, err := external.Launch(ctx, launchCfg)
...
execAbs, err := filepath.Abs(flags.strategyExec)
execDigest, err := execContentDigest(execAbs)So the digest is computed only after the child has already started and completed Handshake. If the file is replaced/removed during startup, the recorded digest can describe different bytes than the process actually executed, or fail an otherwise valid run.
There is also a path-resolution mismatch: filepath.Abs("my-strategy") is not equivalent to exec.Command("my-strategy") when PATH lookup is allowed. The former means $PWD/my-strategy; the latter may execute /usr/local/bin/my-strategy.
Resolve once before launch using the same semantics the launcher will use (for example exec.LookPath when the command has no path separator, then filepath.Abs/Clean), hash that resolved file, and pass that resolved path as LaunchConfig.Command. Record that same path/digest in provenance. That creates one artifact identity used consistently for both launch and manifest.
I would also stream the hash via os.Open + io.Copy(sha256.New(), f) rather than os.ReadFile; executables are usually modest, but there is no reason provenance hashing needs to allocate the entire binary.
- The strategy config is identified only by pathname, not by content.
Issue #385 explicitly asks for effective strategy config/config digest and unambiguous provenance. Today externalStrategyParams.Config records only the absolute config path. The manifest's own ConfigDigest hashes StrategyParameters, which therefore includes that path but not the bytes at that path.
That means:
- run A uses
/configs/strategy.jsoncontaining{"period":20} - the file is edited in place
- run B uses the same path containing
{"period":50}
Both runs can record the same external strategy parameters/config identity even though the guest consumed different configuration. That defeats the reproducibility goal this issue is closing.
Add a ConfigDigest (or similarly named field) containing a SHA-256 of the exact config bytes handed to the guest. Compute it before launch for the same TOCTOU reason as the executable. If no --strategy-config is supplied, omit/empty it explicitly. The vertical provenance test should assert the digest shape and ideally verify it against the fixture file's actual bytes.
If you want the strongest invariant, resolve and hash both executable and config before creating the child, then use exactly those resolved paths in the launch environment/command and exactly those digests in externalStrategyParams.
The remaining fields — mode, guest Descriptor identity, protocol version, transport kind, no ephemeral socket path, and the documentation additions — look appropriate for the v1 scope.
Addresses PR #396 review findings (Rusty + Copilot's aligned finding). 1. Resolve and hash the exact executable before launching it, and launch that same resolved artifact: - New resolveStrategyExecutable mirrors os/exec's own PATH-search behavior exactly (exec.LookPath for a bare name with no path separator, plain filepath.Abs for a literal path) — an earlier version used filepath.Abs unconditionally, which is not equivalent to what exec.Command actually does for a bare command name on PATH, so the recorded digest/path could describe a different file than the one that actually ran. - buildExternalLaunchConfig now resolves the executable itself and sets LaunchConfig.Command to that exact resolved path (previously flags.strategyExec verbatim), so launch and provenance are guaranteed to agree on which file "the executable" means. - Both the executable and config digests are now computed immediately before external.Launch runs, not after the child has already started and completed Handshake — closing most of the TOCTOU window a file replaced/removed during startup would otherwise open. - fileContentDigest now streams via os.Open + io.Copy(sha256.New(), f) instead of os.ReadFile, avoiding an unnecessary full-file allocation. 2. New externalStrategyParams.ConfigDigest: a sha256 digest of the exact bytes at --strategy-config's path, computed before launch. Config alone recorded only a path — two runs using the same path whose file was edited in place between them could previously record identical provenance despite the guest consuming different configuration, defeating this issue's own reproducibility goal. Empty when no --strategy-config is given. New tests: resolveStrategyExecutable's PATH-search vs. literal-path resolution (including an unresolvable-bare-name error case), and the provenance vertical-slice test now verifies ConfigDigest against the fixture file's own real sha256 sum, not just its shape. One existing test's expectation updated: a nonexistent --strategy-exec now fails during digest computation (before Launch is ever called) rather than inside Launch itself — strictly earlier and clearer, since no child process is ever attempted for a file that doesn't exist. Verification: go build/vet clean, gofmt clean, full go test ./... green under -race, golangci-lint clean (only the 3 known pre-existing unrelated research-runs/ errcheck findings), no stray processes. Package coverage 82.4%. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fVsVuQCgkrhiYaXLxyUF3
|
Pushed ad4a588 addressing both findings (Rusty's two points, aligned with Copilot's own finding):
New tests: Verification: |
Addresses two findings from an automated /code-review pass on PR #396 (head ad4a588): - fileContentDigest now takes a context.Context, matching the architecture document's own "use context.Context on operations that may block, perform I/O, or span a use case" convention and every other I/O call in this file (src.load, nextBarOpenAfterEntry). An already-done ctx is checked before opening the file, failing fast rather than reading and hashing a file no caller will use the result of; the read itself stays a single io.Copy (bounded local disk I/O, not network I/O warranting a ctx-selecting stream). - Four new direct unit tests for fileContentDigest, previously exercised only indirectly (not-found, via a full CLI integration test): empty-file hashing (the well-known sha256 of zero bytes), not-found, permission-denied (a distinct, realistic failure mode from not-found — skipped when running as root), and an already-canceled context failing before any file I/O is attempted. Verification: go build/vet clean, gofmt clean, full go test ./... green under -race, golangci-lint clean (only the 3 known pre-existing unrelated research-runs/ errcheck findings), no stray processes. Package coverage 82.5%. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fVsVuQCgkrhiYaXLxyUF3
|
Pushed 858dc5e addressing an automated /code-review pass on PR #396 (2 findings, head ad4a588):
Verification: |
rustyeddy
left a comment
There was a problem hiding this comment.
Rereviewed latest head 858dc5e.
The two provenance blockers from the previous review are addressed well:
--strategy-execis now resolved once using the same PATH/literal-path semantics asos/exec, and that exact resolved path is used forLaunchConfig.Command, executable hashing, and recorded provenance;- executable hashing happens before launch and streams through SHA-256 rather than loading the whole binary;
- external config now has its own content digest, also computed before launch, so an in-place config edit changes provenance even if the path stays identical;
- the vertical-slice provenance test verifies the config digest against the actual fixture bytes;
- direct tests cover executable resolution and digest edge/failure cases.
I also rechecked the rest of #385's acceptance surface: guest Descriptor identity, protocol version, external mode, Unix transport, absolute executable/config paths, executable digest, config digest, and exclusion of the ephemeral socket path are all represented. The operator documentation covers the expected build/run/lifecycle/mismatch/reproducibility/non-goal workflow.
There is still the unavoidable narrow filesystem TOCTOU window between hashing a pathname and the OS opening that pathname for exec (and similarly between config hashing and the guest opening its config). Eliminating that completely would require snapshotting/copying the artifacts or launching/reading from already-open descriptors. Given #385's explicit “stable enough to distinguish different builds” wording, hashing immediately before launch/read via the exact resolved paths is a reasonable v1 boundary, not a blocker.
One tiny non-blocking test improvement: the provenance vertical test could assert params.Config == configPath in addition to checking that it is absolute, mirroring the exact-path assertion already made for Exec.
I don't see another correctness blocker. PR #396 looks merge-ready from my review. With #395 and this PR in place, the remaining External Strategies v1 milestone work appears to have the intended equivalence + provenance/operator gates covered.
GitHub currently exposes no commit-status entries for this head through the connector, so the merge-ready assessment is based on the code/tests plus the reported local verification.

Closes #385 — the final issue in the "Add gRPC to trader for remote out of tree strategies" milestone.
What changed
Provenance (
cmd/trader/backtest/run.go):externalStrategyParamsgains:Mode: "external"— explicit markerStrategyName/StrategyVersion— the guest's own HandshakeDescriptorProtocolVersion— the Strategy Protocol version actually negotiated (protocol/strategy/v1.ProtocolVersion)Transport: "unix"— explicit rather than assumedExec— resolved to an absolute path, not the CLI's possibly-relative spellingExecDigest— a"sha256:<hex>"content digest of the executable file at launch time, matchingbacktest.Manifest.ConfigDigest's own convention, so two different builds behind the identical--strategy-execpath are distinguishableThe ephemeral Unix-domain socket path
Launchgenerates per run is never recorded anywhere in this struct — issue #385's own explicit "no ... machine-specific ephemeral socket paths ... as semantic identity" exclusion. NewTestVerticalSlice_RunWithStrategyExec_RecordsProvenancereads every field back from a realtrader backtest runJSON report (notrun.gointernals directly) and asserts the ephemeral socket directory name never appears anywhere in the recorded provenance, even as raw text.Documentation (
docs/UsersGuide.md):trader backtest runnow documents three strategy paths (not two) plus the three new flags, and a new "External strategies" section covers:strategysdk, with pointers toexamples/strategysdk-minimalandexamples/sma-long-hold)SessionEndvs. SIGTERM/SIGKILL, mid-run crash detection)Testing
Full
go test ./...green under-race,golangci-lintclean (only the 3 known pre-existing unrelatedresearch-runs/errcheck findings), no stray processes. Package coverage 82.1%.🤖 Generated with Claude Code