Distributed security scans — design (Cap. 3)
Status: deepsec parity SHIPPED. Local-mode primitive + runtime fields + bundle integration + cloud-mode (HTTP POST to the server's launch endpoint) all wired. See "Status & shipped pieces" at the bottom for the exhaustive list. Parity target: deepsec's
pnpm deepsec sandbox process --sandboxes N --concurrency M.
Motivation
sec-audit-source V1 is single-process: one iterion run reads every file, runs scanners, triages, revalidates. For repos > 100k LOC this takes hours and easily $100+ in revalidate tokens. deepsec solves this by sharding the file list across N worker pods, each doing a slice. We want the same shape.
The Cap. 1 FileRecords work is the prerequisite: with per-file append-only records, each shard can write its own without coordinating — the parent (or a subsequent run) aggregates.
Constraints
Single-machine path must work. Not every user has a NATS cluster. The same bundle should fan out to N subprocesses on one host when no queue is configured, and to N runner pods when a queue is configured. Same DSL, same outcomes — only the data plane differs.
No new top-level DSL primitives. Sharding is a property of the security bundle, not the language. Wire it through a hidden CLI subcommand + bundled
toolnodes.Polling-based completion. No new NATS results topic in V1. The parent polls the run store for each child until terminal status. Adds latency (poll interval) but avoids a new data path. Future iteration may add an event topic.
Data model
RunMessage extensions (pkg/queue/types.go)
type RunMessage struct {
// ... existing fields ...
// Cap. 3 — distributed security scans
ParentRunID string `json:"parent_run_id,omitempty"` // empty = root run
ShardIndex int `json:"shard_index,omitempty"` // 0..ShardCount-1
ShardCount int `json:"shard_count,omitempty"` // total shards
ShardLabel string `json:"shard_label,omitempty"` // human-friendly tag for UI
}Adding fields is backward-compatible: existing publishers/consumers ignore unknown fields. No migration needed.
run.json extensions (pkg/store/store.go)
type Run struct {
// ... existing fields ...
ParentRunID string `json:"parent_run_id,omitempty"`
ShardIndex int `json:"shard_index,omitempty"`
ShardCount int `json:"shard_count,omitempty"`
}Allows the studio + iterion inspect to surface "this run was shard 3/8 of <parent>". Cheap to add.
CLI surface
One new hidden subcommand: iterion __scan-shards.
iterion __scan-shards \
--parent-run-id=<id> \
--workflow=<path-to-bundle> \
--files-json=<path-or-stdin> \
--shard-size=<files-per-shard> \
--base-vars-json=<json-blob> \
--store-dir=<dir> \
--max-concurrency=<n> \
[--mode=auto|cloud|local] \
[--poll-interval=2s] \
[--timeout=2h]Behavior:
Plan. Read
files-json, split intoceil(len/shard_size)shards. Each shard gets a deterministicshard_idderived fromsha256(parent_run_id + shard_index)(so a re-run of the parent produces the same child IDs).Submit.
- In
cloudmode (NATS available): publish NRunMessages withParentRunIDset,Varsincluding the shard's file list under a well-known key (security_shard_files). - In
localmode: fork N subprocesses (iterion run <workflow> --var security_shard_files=... --parent-run-id=...), respecting--max-concurrency. - In
automode: cloud ifITERION_QUEUE_NATS_URLis set, else local.
- In
Wait. Poll the run store every
poll-intervalfor each child's status. Collect terminal statuses. Fail-fast onfailed(unless--continue-on-failure).Aggregate. Emit a JSON envelope:
json{ "parent_run_id": "...", "shard_count": 8, "shards": [ {"shard_index": 0, "run_id": "...", "status": "finished", "files_audited": [...], "issues_created": [...]}, ... ], "merged_issues_created": [...], "errors": [] }Cleanup. Child runs remain in the store for audit; their FileRecords are already merged into the parent's
.sec-audit/files/(cross-run, append-only).
Bundle integration
sec-audit-source/main.bot grows two new nodes between scan_join and triage, gated by --var shard_size=<N> (default 0 = no sharding):
compute plan_shards:
## Lists every source file the scanners touched. If shard_size > 0,
## emits a shards plan; otherwise emits a single-shard plan (no fan-out).
input: plan_input
output: plan_output
expr:
enabled: "vars.shard_size > 0"
...
tool dispatch_shards:
## Calls `iterion __scan-shards`. Skipped when plan_shards.enabled is false.
command: `iterion __scan-shards --parent-run-id={{run.id}} ...`
...
# Edges:
scan_join -> plan_shards
plan_shards -> dispatch_shards when enabled
plan_shards -> triage when not enabled
dispatch_shards -> done # children emit their own issues + FileRecordsWhen sharding is enabled, the parent doesn't run triage / revalidate / report_card itself — the children do, each on their slice. The parent's role is just to plan + dispatch + collect.
When sharding is disabled (default), the existing single-process pipeline runs unchanged.
Cloud path
Required infra:
- NATS JetStream cluster (already used by iterion cloud mode)
- Runner pods (already supported by
pkg/runner/) - KEDA scaler on queue depth (already wired in
charts/iterion/)
The change is only the RunMessage field additions and the parent-aware execution path in the runner. Runner logic:
// pkg/runner/loop.go, after the run finishes:
if msg.ParentRunID != "" {
// No new emission needed in V1 — parent polls the store.
// V2 can publish an event to a per-parent results topic.
}Local path
iterion __scan-shards --mode=local spawns N subprocesses sharing the same --store-dir. Each subprocess is a normal iterion run, just with --var security_shard_files=... and --parent-run-id=....
Concurrency is bounded by --max-concurrency (default runtime.NumCPU() / 2).
This path is fully functional without NATS — useful for development, single-machine large-repo audits, and CI runners that can dedicate one big box to a scan.
Tests
- Unit:
pkg/queue/types_test.go— extendedRunMessageround-trips. - Unit:
pkg/runner/loop_test.go— runner recordsparent_run_idon the run if present. - Integration (local-mode): e2e test spawns 4 shards via
iterion __scan-shards --mode=localon a fixture workspace, asserts 4 child runs reached terminal status, 4 FileRecord files were written. - Integration (cloud-mode): gated on
ITERION_QUEUE_NATS_URLset; skipped in normal CI.
Open questions
- Children writing to the parent's FileRecords directory — V1 has them all write to
<workspace>/.sec-audit/files/. Two children scanning overlapping files would race. Decided: shard boundaries are by file path, so each child writes a disjoint set. Future: a child computes its filename hash range and only writes files in that range. - Children creating board issues — V1 has each child call
mcp__iterion_board__create_issue. Two children scanning overlapping files would create duplicate issues. Same mitigation: shard by path is disjoint by construction. - Run lifecycle UI — the studio should show the parent run with N children as a tree.
pkg/server+pkg/runviewextensions to surface this. Out of scope for V1.
Implementation order
- ✅ Cap. 1 FileRecords (done — see this bundle's V1.1).
- Runtime:
RunMessage+Runfield additions, runner records them. Unit tests. - CLI:
iterion __scan-shards(local mode only first). Test harness. - Bundle:
plan_shards+dispatch_shardsnodes. e2e test (local mode). - CLI: cloud-mode publishing path in
__scan-shards. Integration test gated on NATS. - Studio: parent-with-children tree view. (Stretch — V2.)
Total estimate: 3-5 days for steps 2-5 (step 6 deferred).
Status & shipped pieces (as of V1.1)
✅ Step 1 — Cap. 1 FileRecords (V1.1, see this bundle's skills/file-records.md).
✅ Step 2 — Runtime field additions:
pkg/queue/types.go—RunMessage.ParentRunID,ShardIndex,ShardCount,ShardLabel(back-compat optional fields).pkg/store/run.go— same four fields onstore.Runso they persist + surface in the studio.- Tests in
pkg/queue/+pkg/store/pass unchanged.
✅ Step 3 — CLI primitive — iterion __scan-shards (local mode) implemented at cmd/iterion/scan_shards.go. Deterministic shard ids (sha256(parent_run_id || index)), bounded concurrency via semaphore, child subprocess spawn, store polling on completion, aggregated JSON envelope on stdout. Unit tests (cmd/iterion/scan_shards_test.go): plan determinism, disjoint-parent isolation, remainder shard, id format, empty input.
✅ Step 4 — Bundle integration: bots/sec-audit-source/main.bot now has --var shard_size (default 0 = no fan-out), --var shard_concurrency (default 4), --var file_filter (CSV of paths). Two new tool nodes:
plan_shards: walks the workspace (same exclusions as the scanners) and writes the file list to<scan_dir>/shard-files.jsonwhenshard_size > 0(scan_dir defaults to the engine's out-of-tree run scratch,${PROJECT_SCRATCH_DIR}/sec-audit-source/scan).dispatch_shards: invokesiterion __scan-shards, captures the JSON envelope to<scan_dir>/shards.json, and returns a structured summary. Children re-run the same workflow withshard_size=0so they fall through to the single-process pipeline on their assignedfile_filterslice. Thetriageprompt + schema acceptfile_filterand DROP every candidate whosefileis not in the list before downstream.
✅ Step 5 — Cloud-mode publishing: iterion __scan-shards --mode=cloud (or auto when ITERION_SERVER_URL is set) POSTs to the server's /api/v1/runs/launch endpoint per shard, passing parent_run_id, shard_index, shard_count, shard_label. The server (cloudpublisher) persists those on the queued Run AND on the published RunMessage. The existing runner pool drains the queue; the parent (the __scan-shards invocation) polls the shared store for each child's terminal status. No new NATS topic or event-driven aggregation — polling matches the local path.
⏳ Step 6 — Studio parent/child tree view. Deferred to V2. The persisted fields are in place; the SPA still renders shards as plain peer runs until a future iteration groups them.
How to use the primitive today (manual integration)
Until step 4 lands, an operator can invoke iterion __scan-shards directly from a tool node (or from a wrapper script):
# Build a JSON array of files to shard:
find . -type f -name '*.go' | jq -R . | jq -s . > /tmp/files.json
# Fan out across 4 child runs, 50 files per shard:
iterion __scan-shards \
--parent-run-id="$(uuidgen)" \
--workflow=bots/sec-audit-source/main.bot \
--files-json=/tmp/files.json \
--shard-size=50 \
--max-concurrency=4 \
--store-dir=$(pwd)/.iterion \
--base-vars-json='{"workspace_dir":"'"$(pwd)"'"}' \
--shard-var=security_shard_files \
| jq .The JSON envelope on stdout reports each child's terminal status
- paths, and the JSON's
errors[]is non-empty when a shard failed.
In a sandboxed run (when host_state: auto is in effect), the iterion binary is bind-mounted into the container, so __scan-shards resolves to the same binary and the children run inside the same sandbox.
