Validate Spark Scala to Snowpark Connect Migration
You are the orchestrator. Keep the workflow simple, stateful, and easy
to audit. The reusable runtime lives in harness-scala/; agents should
not re-describe or re-invent it from scratch.
Inputs (set by the migrate skill's hand-off)
$CONVERSION_ROOT— path containingOutput/(the migrated SCOS source).$ORIGINAL_SOURCE— path to the original Scala source.$CONNECTION_NAME— Snowflake connection name.$SKILL_DIRECTORY— this skill's directory.$VALIDATOR_SCRIPTS—$SKILL_DIRECTORY/../validate-pyspark-to-snowpark-connect/scripts(the canonical PySpark validator scripts, reused by this skill).
Progress UI Emit Hooks
Non-fatal one-liners that feed the live dashboard started by the migrate skill.
$CONVERSION_ROOT must be the same Conversion-SCOS-* dir the migrate UI was
launched against (it already contains .migration-ui/). Skip entirely when
<config.enable_progress_ui> is no or .migration-ui/ is absent.
BUS="python3 $SKILL_DIRECTORY/../../scripts/progress_bus.py"
UI_RUN="$CONVERSION_ROOT"
| When | Command |
|---|---|
| Validation starts (Step 0) | $BUS phase-start --run "$UI_RUN" --phase survey || true |
| Survey / batch prep done | $BUS phase-end --run "$UI_RUN" --phase survey || true |
| Phase A begins | $BUS phase-start --run "$UI_RUN" --phase phase-a || true |
| Phase A done | $BUS phase-end --run "$UI_RUN" --phase phase-a || true |
| Phase B begins | $BUS phase-start --run "$UI_RUN" --phase phase-b || true |
| Per-entrypoint result | $BUS validation-ep --run "$UI_RUN" --ep "$EP_ID" --phase a|b --status passed|failed|… --total $N || true |
| Phase B done | $BUS phase-end --run "$UI_RUN" --phase phase-b || true |
| Harvest / merge-reports | $BUS phase-start --run "$UI_RUN" --phase harvest || true |
| Merged report ready | $BUS report-ready --run "$UI_RUN" --file "$REPORT" --phase harvest || true |
| Validation finished | $BUS milestone --run "$UI_RUN" --phase validation-complete --message "Validation complete" || true then $BUS summary --run "$UI_RUN" --data '{"validation_complete":true}' || true |
Pool path (Step 4B): $VALIDATOR_SCRIPTS/batch.py pool emits these
automatically when it finds .migration-ui/ under $CONVERSION_ROOT.
Inline path (Step 4A): emit the table above yourself at each boundary.
Constraints
- Single Snowflake connection. All entrypoints in a single run must target the same Snowflake database via the same connection.
- Scala source files and JVM projects only. Entrypoints must be
.scalafiles, sbt/Maven/Gradle projects, or Databricks notebooks whose dominant language is Scala. Pure Python entrypoints are out of scope; usevalidate-pyspark-to-snowpark-connectfor those. - Explicit table dependencies. All table reads must be declared under
Validation/shared/schemas/entrypoints/<id>/tables/with"access": "read"(or"readwrite") andcategory: "table". - Explicit file dependencies. All file reads must be declared the same way
with
category: "file"and amock_filereference. - All discovered entrypoints by default. Larger workloads are split into
batches via
prepare-batches --max-entrypoints 8(a batch packing cap, not a selection cap) and validated in parallel across git worktrees.
Coordinator Notes (operational pitfalls — read before starting)
Validation/source/must be a real copy, not a symlink.scos-analyze.jardoes not follow symlinks; it will report0 file(s)and produce an emptyast_facts.json. Usersync -a --exclude='.git' --exclude='target' src/ dest/to populate it.String weights in schemas. Prefer mining via
schema_mine.py, which writes numeric weights intoschemas/manifest.json. Older runs may still have"weight": "high"/"medium"/"low"string labels;batch.pyprepare-batchesaccepts these labels. If you seeinvalid literal for int() with base 10: 'low'you are on an older build — convert the strings to integers manually (high→20, medium→10, low→5) before callingprepare-batches, or re-runschema_mine.py.Two different
analysis.jsonfiles. Migration$CONVERSION_ROOT/analysis.jsonis a flat issue array fromanalyze_scala.py. Validation usesValidation/shared/schemas/as source of truth;Validation/shared/analysis.jsonis a generated JVM shim (entrypoint catalog). Do not confuse them.merge-reportspermission denied on parquet files. Spark writes Phase A/B result and mock-data parquet files with read-only permissions (mode 444).merge-reportscopies them into theValidation/batches/tree and will fail if those copies are already present and read-only. Runchmod -R 755 $CONVERSION_ROOT/Validation/batchesbefore callingmerge-reportsto clear any stale locked copies.consolidate— correct invocation. Runconsolidateagainst the primary repo, not a worktree:$RUN consolidate --conv-root $PRIMARY_CONV_ROOT --base-sha $BASE_SHATarget a single batch's branch with
--branches <branch_name>to avoid scanning other batches' stale MIGRATION-FIX commits.git filter-branchleavesrefs/original/backup refs. After usingfilter-branchto rename commits, delete the backup before runningconsolidate:git -C $WORKTREE update-ref -d refs/original/refs/heads/<branch> git -C $WORKTREE gc --prune=nowOtherwise the consolidate scanner finds the old SHAs in
--alland rejects them.
Critical Rules
Entrypoint selection is fully automatic — validate all discovered entrypoints by default.
prepare-batchesscopes each worktree to its batch. Workers never ask which entrypoints to validate. (The orchestrator may narrow the whole run to a subset once, up front, in Step 1.6 — that is the only entrypoint-selection prompt, and it is orchestrator-level, not per-worker.)Use
Validation/as the workspace root for this skill.Keep
Validation/source/andOutput/as the two code trees under test.Use the shared test kit in
harness-scala/kit/for both phases.Local Phase A always uses a local Spark + Delta runtime (
SparkSession.master("local[1]")).Migrated Phase B must use real
SnowparkConnectSession.builder(). getOrCreate(); do not stub it.There are no shims or mock filesystems. Non-Spark I/O (cloud reads/writes,
dbutils, JDBC, HTTP, secrets, widgets) is rewritten by the patch blueprint into native Spark reads + env-var indirection (System.getProperty), or deleted. Every rewrite is added viascos_state.py patch-add.Keep per-entrypoint runs isolated:
- local: fresh per-test warehouse dir and Delta checkpoint path
- SCOS: clone a pre-provisioned golden Snowflake schema per trial
Because each trial is fully isolated, always run the selected entrypoint specs in bounded parallel — one batched
sbt testover the whole tests dir (one forked JVM per spec, capped bySCOS_TEST_PARALLELISM; when--parallelismis omitted,run-phase-a/run-phase-bauto-cap from host RAM), in BOTH Phase A and Phase B. Never dispatch onetestOnlyper entrypoint and never run serially (serial multi-entrypoint validation is unacceptably slow). Only lowerSCOS_TEST_PARALLELISM(e.g.1) for a specific, reproducible resource limit, and report it as harness friction.If Phase A cannot produce a trustworthy baseline, still run Phase B and flag the result for human review.
All test-only
Output/changes (the blueprint I/O patches) are committed on thevalidation/<run-id>branch with the[TEST-PATCH]prefix; genuine SCOS code fixes use[MIGRATION-FIX](viascos_state.py commit --kind migration-fix --trial-ids <id>). Harvest (Step 9) cherry-picks only[MIGRATION-FIX]onto the deliverable;[TEST-PATCH]commits are never cherry-picked.[MIGRATION-FIX]commits must be production-safe — the committer rejects any that addSCOS_*harness identifiers toOutput/.In multi-batch mode, each worktree has a unique
run_idso its golden Snowflake schema ({slug}_{run_id}) never collides with another batch's. Never sharestate.jsonacross worktrees.[MIGRATION-FIX]commits are cherry-picked per-batch viascos_state.py consolidate(serialized by git's own index.lock; callers retry on exit 6).[TEST-PATCH]commits are never consolidated.
Phase A vs Phase B: environment differences
Phase A runs the source Scala workload on local Spark + Delta. Some SQL
constructs (e.g. QUALIFY, Databricks-specific MERGE INTO variants)
are not supported by open-source Spark SQL. When Phase A genuinely cannot
execute due to such an environment difference, the trial is marked
phase_a_skipped with a --reason naming the specific construct (the
record-trial-status gate rejects a blank or generic reason), and Phase B
proceeds without a local baseline. Phase B runs on real SCOS which supports the
full Snowflake SQL surface. A successful Phase B run without a baseline is
derived to passed_no_baseline — never set directly — and it carries the
preserved skip reason so the operator report always explains the missing
baseline.
A skip is a last resort, not a shortcut. Missing/unmocked tables and columns
are inline schema repairs; connector reads, 3-part names, and external I/O are
blueprint patches; running out of iterations means escalate (fixer dispatch or a
documented repair-exhaustion track) — none of these are valid skip reasons. A
skipped Phase A ships with no parity check and is flagged for human review.
run-phase-a also runs a deterministic mock-data guard (schema_mine + datagen
seed/verify) and hard-fails on unseedable mocks, so an empty baseline surfaces as
an actionable datagen error rather than a silent skip.
Prerequisites
Before starting the workflow, verify Snowflake connectivity and tooling.
# Java 8/11/17 required for Phase A's local Spark 3.5 (Spark 3.5 does NOT support
# Java 21+).
java -version || echo "PREREQ_FAIL: Java not found"
# sbt, Maven, or Gradle (based on the workload's build tool)
sbt --version || mvn --version || gradle --version \
|| echo "PREREQ_FAIL: No Scala build tool found"
# Snowflake connector (Python; replaces the old JDBC driver requirement)
uv run --project $SKILL_DIRECTORY/.. python -c "import snowflake.connector" \
|| echo "PREREQ_FAIL: snowflake-connector-python not available"
uv --version || echo "PREREQ_FAIL: uv not installed"
# Analyze JAR — the only JVM piece left: the deterministic `analyze` command
# (Scalameta AST facts) used by the data-synthesizer agent. Provision, compare, datagen,
# and patch reuse the canonical PySpark scripts at $VALIDATOR_SCRIPTS; state
# (scos_state.py) and schema mining (schema_mine.py) are this skill's own scripts/.
# The jar is small (~45 MB, circe + Scalameta only); build it with `sbt assembly` in
# harness-scala/control/:
test -f "$SKILL_DIRECTORY/harness-scala/control/target/scos-analyze.jar" \
|| echo "PREREQ_FAIL: scos-analyze.jar not built; run sbt assembly in harness-scala/control/"
# Snowflake connection check
uv run --project $SKILL_DIRECTORY/.. python -c "
import snowflake.connector
snowflake.connector.connect(connection_name='$CONNECTION_NAME').cursor().execute('SELECT CURRENT_ACCOUNT()')
" || echo "PREREQ_FAIL: Snowflake connection failed"
# notebook_io (stdlib-only; needed only for notebook workloads)
python3 -c "
import sys
sys.path.insert(0, '$SKILL_DIRECTORY/../scripts')
from notebook_io import flatten_cells_to_script
print('notebook_io OK')
" || echo "INFO: notebook_io unavailable (only needed for notebook workloads)"
Workflow
The orchestrator always follows Steps 0–4 below. For small workloads (≤ 8
entrypoints or a single logical section), Step 4A runs the single batch inline;
for larger workloads Step 4B fans out to a pool of concurrent workers. In both
cases each worker (or the inline orchestrator) follows agents/batch-runner.md.
Step 0 — Capture base SHA
Before branching any worktrees, capture the current HEAD so every worktree starts from the same commit:
# Runs in CoCo bash sandbox (Linux only)
BASE_SHA=$(git -C $CONVERSION_ROOT rev-parse HEAD)
Step 1 — Survey and weight
Mine the workload into Validation/shared/schemas/ with a single deterministic
command (PySpark parity). Scalameta runs under the hood via scos-analyze.jar;
agents do not hand-build analysis.json.
# Ensure Validation/source/ is a real copy (not a symlink) first — see Coordinator Notes.
mkdir -p $CONVERSION_ROOT/Validation/shared
# If Validation/source is not yet populated:
# rsync -a --exclude='.git' --exclude='target' $ORIGINAL_SOURCE/ \
# $CONVERSION_ROOT/Validation/source/
uv run --project $SKILL_DIRECTORY/.. \
python $SKILL_DIRECTORY/scripts/schema_mine.py \
--conv-root $CONVERSION_ROOT
Produces Validation/shared/schemas/manifest.json — every entrypoint id, path,
numeric weight, and llm_todo gaps. Also writes a generated
Validation/shared/analysis.json JVM shim (do not hand-edit; regenerate with
scos_state.py schemas-to-analysis after schema repairs).
Skip re-mining if schemas/manifest.json already has complete entrypoints[].
Do not ask the user to pick entrypoints here — workers never ask; optional
narrowing is Step 1.6 only.
Step 1.6 — Scope to a subset (optional)
By default the whole workload is validated. Some runs only need a specific set
of entrypoints (one pipeline, a few files the user is iterating on). Ask once
with a single AskUserQuestion:
"Survey found N entrypoints. Validate all of them, or just a subset? For a subset, reply with the entrypoints you want — ids, file paths, or a description (e.g. 'just the ingestion DAG')."
Options: "All N entrypoints" and "A subset (I'll list them)".
- All — skip the rest of this step; go to Step 2.
- Subset — the user names the entrypoints in their next message. Resolve
their answer to concrete entrypoint ids from
schemas/manifest.json(oranalysis.jsonshim) yourself (match onid/path/section intent), confirm the resolved id list back to the user in one line, then prune to exactly that set:
uv run --project $SKILL_DIRECTORY/.. \
python $SKILL_DIRECTORY/scripts/scos_state.py \
scope-entrypoints --conv-root $CONVERSION_ROOT --ids "ep1,ep2,ep3"
scope-entrypoints rewrites schemas/ (and the analysis shim) to keep only the
listed ids (exit 2 on unknown ids). Everything downstream (sectioning, batching,
the pool, the merged report) then sees only the kept subset. This is the only
entrypoint-selection prompt; it is orchestrator-level, not per-worker.
Step 2 — Semantic sectioning (inline — orchestrator, no subagent)
Group entrypoints into sections by shared schema/lineage. Create
Validation/shared/sections.json directly (inline; no subagent needed):
[
{"section_id": "orders", "name": "Orders pipeline", "ep_ids": ["ep1","ep2"]},
{"section_id": "billing", "name": "Billing pipeline", "ep_ids": ["ep3","ep4"]}
]
Each ep_id must appear exactly once (enforced by prepare-batches coverage
check). Group entrypoints that share mock tables to reduce cross-batch data
re-use friction. A single catch-all section is valid.
Step 3 — Prepare worktrees
uv run --project $SKILL_DIRECTORY/.. \
python $SKILL_DIRECTORY/scripts/scos_state.py \
prepare-batches \
--conv-root $CONVERSION_ROOT \
--sections $CONVERSION_ROOT/Validation/shared/sections.json \
--original-source $ORIGINAL_SOURCE \
--connection $CONNECTION_NAME \
--base-sha $BASE_SHA \
--max-entrypoints 8 \
--max-weight 40
This validates coverage, LPT-bins entrypoints into balanced batches, creates one
git worktree per batch under Validation/worktrees/<batch_id>/ at $BASE_SHA,
inits each worktree with a unique run_id, copies + scopes schemas/ (and the
analysis shim) per batch, and writes Validation/shared/batches_prepared.json
(batch plan + worktree map). Exit 1 if any batch failed setup; re-run with
--force to retry.
--max-entrypoints 8 is a batch packing cap (split into more batches), not a
selection cap — every scoped entrypoint is still validated.
Step 3.5 — Prewarm (background overlap)
Right after prepare-batches (each worktree is already inited), kick off
scos_state.py prewarm in the background for each worktree so kit staging +
sbt/Coursier warm-up overlaps with Step 4 authoring. Join before Phase A —
never start Phase A on a cold kit, and never defer prewarm until after
patch-author.
# Per worktree (background). Honest prewarm: exits non-zero and does NOT set
# venv_prewarmed if JDK cannot be resolved or sbt is missing.
uv run --project $SKILL_DIRECTORY/.. python $SKILL_DIRECTORY/scripts/scos_state.py \
prewarm --conv-root <worktree>
For the inline single-batch path, agents/batch-runner.md Step 1 also checks
the milestone and runs prewarm if needed. Prefer overlapping it with analyze /
patch-author rather than waiting until Phase A.
Step 4A — Single batch (inline, no SDK sessions)
When batches_prepared.json has exactly one batch, or you prefer inline
execution without launching an SDK pool:
Read the sole batch entry from
$CONVERSION_ROOT/Validation/shared/batches_prepared.json and capture its
worktree, run_id, and validation_branch. Set the batch-runner inputs:
# Runs in CoCo bash sandbox (Linux only)
export CONVERSION_ROOT=<batch.worktree>
export PRIMARY_CONV_ROOT=<primary $CONVERSION_ROOT from Step 0>
export BASE_SHA=$BASE_SHA
export ORIGINAL_SOURCE=$ORIGINAL_SOURCE
export CONNECTION_NAME=$CONNECTION_NAME
export SKILL_DIRECTORY=$SKILL_DIRECTORY
export batch_id=<batch.batch_id>
Read agents/batch-runner.md and follow it end-to-end in this session
(prewarm → analyze → patch-author → prevalidate --phase a → Phase A →
provision → phase-reset --to b → prevalidate --phase b → Phase B →
summary → harvest → batch learnings), dispatching each phase agent as its own
subagent. Do not run batch-runner as a subagent itself — run it inline.
There is no pool_status.json in this path — progress is visible
directly in-session. Proceed to Step 5 only after the harvester completes and
scos_state.py summary exited 0.
Step 4B — Multiple batches (parallel pool)
When there are 2+ batches, launch the async worker pool:
uv run --project $SKILL_DIRECTORY/../validate-pyspark-to-snowpark-connect \
python $VALIDATOR_SCRIPTS/batch.py pool \
--prepared $CONVERSION_ROOT/Validation/shared/batches_prepared.json \
--primary-conv-root $CONVERSION_ROOT \
--original-source $ORIGINAL_SOURCE \
--connection $CONNECTION_NAME \
--skill-directory $SKILL_DIRECTORY \
--pool-size 3 \
--control-script scos_state.py \
--retries 1
The pool spawns up to 3 concurrent SDK sessions, each running
agents/batch-runner.md for one batch. It polls each worktree's state.json
every 10 s, writes Validation/pool_status.json (live + terminal), and
auto-runs merge-reports on completion.
JVM concurrency: pool_size × SCOS_TEST_PARALLELISM concurrent forked JVMs.
Default --pool-size 3. When --parallelism is omitted, run-phase-a /
run-phase-b auto-cap from available RAM (<8 GB → 1, <16 GB → 2, else → 4);
an explicit --parallelism N always wins. Lower further only for Snowflake
rate-limits on small warehouses. The Coursier/Ivy cache (~/.cache/coursier,
~/.ivy2) is shared across worktrees — dependency downloads happen only once
even with multiple concurrent workers.
Multi-batch merged artifacts:
Validation/run_index.json— merged master manifest (all batches)Validation/results/REPORT.md— merged human-readable summaryValidation/pool_status.json— per-batch pool status (Step 4B only)Validation/worktrees/<batch_id>/— per-batch artifact trees
Step 5 — Merged report
Pool path (4B): batch.py pool runs batch.py merge-reports automatically.
Read pool_status.json → merge_report_path
(= $CONVERSION_ROOT/Validation/results/REPORT.md) and surface the path.
Inline path (4A): pool_status.json does not exist. Run merge-reports
yourself (idempotent) and take the REPORT.md path from its stdout:
uv run --project $SKILL_DIRECTORY/../validate-pyspark-to-snowpark-connect \
python $VALIDATOR_SCRIPTS/batch.py merge-reports \
--prepared $CONVERSION_ROOT/Validation/shared/batches_prepared.json \
--out $CONVERSION_ROOT/Validation
Writes Validation/run_index.json and Validation/results/REPORT.md.
View the report:
uv run --project $SKILL_DIRECTORY/.. python -m streamlit run \
$SKILL_DIRECTORY/scripts/report/validation_report_app.py \
-- --run-root $CONVERSION_ROOT/Validation
Step 6 — Cleanup gate
Use AskUserQuestion once to ask whether to:
- (a) Drop ALL per-batch golden Snowflake schemas (list each
run_idfrombatches_prepared.json). - (b) Tear down git worktrees and
validation-base/*branches. Keep thevalidation/<run-id>branches for inspection unless the user asks.
Only on an affirmative answer, for each batch in batches_prepared.json:
uv run --project $SKILL_DIRECTORY/../validate-pyspark-to-snowpark-connect \
python $VALIDATOR_SCRIPTS/cleanup.py --conv-root <worktree> --force
git -C $CONVERSION_ROOT worktree remove <worktree>
git -C $CONVERSION_ROOT branch -D validation-base/<batch_id>
If declined, give the user the exact commands to run later. Never auto-clean.
Step 7 — Final display
After Step 5 wrote REPORT.md, post one final message to the user:
- Terminal status counts — read
Validation/run_index.json→totalsand print them verbatim (overall verdicts + comparison verdicts). - Full entrypoint table — one row per EP from
Validation/run_index.json(entrypoints[], keyed bybatch_id). Columns: Batch, Entrypoint, Overall, Comparison, Time (s), Reason. The Reason cell isentrypoints[].verdict.reason— already inrun_index.json, no extra lookups needed. Sort bybatch_id. Inline path (4A): build the table fromrun_index.jsonalone;pool_status.jsonis absent (Reason still comes fromverdict.reason). 2a. Flag no-baseline / stuck EPs. For every row whose Overall ispassed_no_baselineorhard_stuck, call it out explicitly as needs human review and print itsverdict.reason. - Finish with the on-disk paths already surfaced in Step 5 (
REPORT.md,run_index.json, and the streamlit viewer command).
Do not recompute totals from the EP list — the merger already did it.
Orchestration notes (efficiency)
These keep wall-time and token use down across the multi-agent run:
- Snapshot growing state files per dispatch.
events.jsonlandrun_index.jsongrow as the run proceeds; re-reading them in full on every turn is wasteful. Read them once when you dispatch a runner agent and pass that snapshot down, rather than re-reading the whole file each turn. - Poll
state.json, do not dead-wait. Run the Phase A / Phase B runners as foreground agents and pollValidation/state.jsonfor trial-status progress, so a stuck trial can be intervened on. Do not block on a single longagent_output(wait=true)that can sit idle until the 900s timeout. - Batch the trial run. Dispatch one batched
sbt testover all selected specs (bounded bySCOS_TEST_PARALLELISM) and process results in one pass — not onetestOnlyper trial. Seeagents/scos-runner.md/local-runner.md. - Prewarm is Step 3.5, not an afterthought — overlap kit/
sbtwarm-up with analyze + patch-author; join before Phase A. A warm Coursier cache also speeds every later iteration. - Gate both phases with
prevalidate. Before Phase A runscos_state.py prevalidate --phase a; between phases runphase-reset --to bthenprevalidate --phase b. Both writeValidation/shared/prevalidation_report.jsonand must exit 0 (or 2 with only warnings) after batch-fixing every blocking finding in one pass. Phase A also blocks on incompletecli_args, missingintermediate_tablesschemas,local[0]/repartition(0), and dynamic-pathllm_todos. Phase B additionally blocks on unpatched excel/mongo/file I/O and non-table sink strategy gaps. After the last Phase A/B iteration, run once with--verify-all. Useknown-patches suggest(Scala-native) before patching;run-phase-a/bauto-retry once on transient startup errors; provision is hash-gated (provision --force-reseedto reload all).build-doctoris a fallback diagnostic when a compile finding needs a fuller JAR/classpath report — not a required main-flow step. - In multi-batch mode, share the Coursier/Ivy cache. Set
COURSIER_CACHE=~/.cache/coursierandSBT_OPTS="-Dsbt.ivy.home=$HOME/.ivy2"in the env before launching the pool. All worktrees reuse the same local artifact cache, so the hundreds-of-MB Spark/Delta download happens only once across N concurrent workers.
Stopping Points
- Missing hand-off inputs: stop and report the missing input.
prepare-batchesexits 3 (sections.json coverage error — entrypoint duplicated, unsectioned, or unknown): fixsections.jsonso every entrypoint appears in exactly one section, then rerun Step 3. No worktrees are created on a coverage failure. If it prepares some batches but reports a per-batch error (exit 1), skip those, surface them, and continue.- A batch ends
failedafter the pool's retry: the pool exits 1; surface the failedbatch_id(s). Other batches' results are still valid and already merged. scos_state.py consolidateexits 1 (run from the harvester): surface the error to the user.- Cherry-pick conflicts that cannot be resolved by the harvester: surface the conflicting commit SHA and files. Other batches continue unaffected.
Success Criteria
- Every prepared batch session reported back: pool exit 0 (Step 4B), or —
single batch (Step 4A) — the inline batch-runner reached
summaryexit 0 and harvester success. OR a batch is reported failed with a clear explanation (pool exit 1 / harvester conflict; batch listed inpool_status.jsonfor 4B or reported inline for 4A). - All
[MIGRATION-FIX]commits are on the deliverable branch — workers self-reported harvest success. batch.py merge-reportscompleted — run automatically bybatch.py pool(Step 4B) or manually by the orchestrator (Step 4A) —Validation/run_index.jsonandValidation/results/REPORT.mdwritten.- The merged report explains which results are safe matches, which diverge, and which need human review.
Output
- Primary:
scos_state.py summary - Durable state:
Validation/state.json(includesgit.{original_branch,validation_branch,harvested})Validation/shared/schemas/(source of truth for I/O contracts)Validation/shared/analysis.json(generated JVM shim — do not hand-edit)Validation/shared/patch_blueprint.json(the test-patch record)Validation/shared/mock_data/Validation/tests/Validation/results/
Run artifacts
After a run completes, the canonical artifacts are:
Validation/run_index.json— master manifestValidation/events.jsonl— append-only timeline of all state transitionsValidation/state.json— orchestrator stateValidation/results/REPORT.md— human-readable summaryValidation/results/{phase_a,phase_b}/<trial_id>/— captured outputs- diffs
Validation/results/phase_b/<trial_id>/stage_snapshot/— Snowflake table snapshots (passed_no_baselineonly)
Validation/run_index.json schema
Master manifest for downstream consumers (UIs, dashboards). Generated
by scos_state.py build-index, called automatically from scos_state.py summary.
{
"run": {
"id": "<uuid>",
"started_at": "<ISO timestamp>",
"completed_at": "<ISO timestamp> | null",
"status": "passed | partial | in_progress",
"skill_version": "...",
"connection": "<connection_name>",
"database": "<database>",
"schema_namespace": "<schema>"
},
"milestones": {"<name>": {"status": "done|pending", "completed_at": null}},
"entrypoints": [
{
"id": "<trial_id>",
"source_path": "...",
"phase_a": {
"verdict": "baseline_produced | no_baseline | phase_a_skipped",
"iters": "<int>",
"captured_outputs": [{"name": "...", "path": "...", "rows": null, "schema": null}],
"patches_applied": [...],
"errors": [...]
},
"phase_b": {
"verdict": "<trial status>",
"iters": "<int>",
"captured_outputs": [...],
"patches_applied": [...],
"errors": [...],
"scos_query_ids": [...],
"fixer_dispatches": [...],
"stage_snapshot_paths": [...],
"migration_fix_commits": [{"sha": "...", "subject": "...(no [MIGRATION-FIX] prefix)", "body": "...(optional)"}]
},
"comparison": {
"verdict": "match | cosmetic_divergence | real_divergence | no_baseline",
"diffs": [{"table": "...", "diff_path": "...", "schema_match": true, "row_count_a": null, "row_count_b": null, "verdict": "..."}],
"documented_divergences": [...]
},
"trial_dir": "results/phase_b/<trial_id>/",
"verdict": {"overall": "<status>", "reason": "..."}
}
],
"artifacts_index": {
"analysis": "shared/analysis.json (generated JVM shim)",
"schemas": "shared/schemas/",
"patch_blueprint": "shared/patch_blueprint.json | null",
"mock_data": [{"trial_id": "...", "files": [...]}],
"auxiliary_sql": [...],
"rendered_tests": [...]
},
"events": "events.jsonl | null",
"fixer_dispatches": [...],
"documented_divergences": [...],
"warnings": [...],
"parse_errors": [{"path": "results/phase_b/<trial_id>/_index.json", "error": "...", "trial_id": "...", "phase": "phase_a|phase_b"}]
}
Troubleshooting
See $SKILL_DIRECTORY/../references/scala/troubleshooting.md for common issues and solutions,
including:
- Honest prewarm /
prevalidate/phase-reset(build-doctor as fallback) prevalidateblockssinks=[]whenast_factsstill shows writes (no fakeno_sink_baseline); run-index surfacesphase_a.verdict=no_sink_baselinefor confirmed smoke-only trialsallow_emptyis intentional-empty only — UDF/connector gaps useexpected_divergences(scope=udf); empty Phase B capture is not a soft pass- Transient startup retry kills stale SCOS servers before the 900s re-run;
fix hang root causes before raising
SCOS_TRIAL_TIMEOUT_SECS - Thin-jar + filtered dependency classpath for Phase A
- Mock-guard hard-fail and filter/join mock enrichment
- Host-aware
SCOS_TEST_PARALLELISMcapping - JAR classpath conflicts between the workload and the kit
- SCOS session connection (local-server mode:
SNOWPARK_CONNECT_PYTHON_VENV+SNOWFLAKE_DEFAULT_CONNECTION_NAME; do not setSPARK_REMOTE). "Local-server" = the translation server runs locally; Phase B compute still executes in Snowflake. - Scala version mismatches (2.12 vs 2.13)
- Delta table path conflicts in local Phase A
- Snowflake JDBC authentication issues
ParquetFileFormat.$deserializeLambda$failures on JVM 17 + Spark 3.5 (SerializedLambda / URLClassLoader conflict during Phase A): setSCOS_PHASE_A_SUBPROCESS=1to run the workload in a child JVM (SubprocessLauncher) instead of in-process viaReflectionEntrypoint