Why SignalPilot passed where base failed
Evidence chips jump to the exact transcript momentTwo small, silent mistakes: one table was off by exactly 1 row, the other 5,237 rows too large. Neither would be caught by a quick glance. SignalPilot matched both correct counts exactly.
Real-world impact
5,237 trips with no known station were counted in the neighbourhood analysis, and one real trip quietly vanished from the record. Both errors are invisible without checking the counts.
- Added a remove-incomplete-records filter nobody asked for to a straight raw copy, dropping exactly one real row.
- Kept trips whose station had no neighbourhood match, letting 5,237 unmatched trips into the final table.
Jump to the exact moment
- Stated in its plan that the raw copy takes no filters, and recorded the expected 426,887 rows.
- Tested both join options against the data first; only the stricter one produced the correct 413,689 rows, and the decision was written down before coding.
Jump to the exact moment
The numbers
| Check | Correct answer | Base Claude Code | SignalPilot |
|---|---|---|---|
| Trip data copy | 426,887 rows | 426,886 (1 lost) | 426,887 |
| Trips with neighbourhoods | 413,689 | 418,926 (5,237 extra) | 413,689 |
Verification workflow
Where the eight-step workflow caught it.
- 01Map the project
Scan every model: dependencies, required columns, macros, hazards.
- 02Load supporting skills
SQL, database, and domain rules loaded before any writing.
- 03Validate the project
Parse check, plus rebuild of stale time-dependent upstreams.
- 04Discover project macros
Find macro-derived columns the YML contract does not list.
- 05Research the data
Driving table, cardinalities, contract, sibling patterns, categorical values.
Research ran the counts first, the loose join path keeps 418,926 rows while the strict station join lands exactly on 413,689, so the strict join won over the skill's own LEFT JOIN default; base Claude Code never ran either count.
- 06Write the technical spec
A written plan of sources, joins, filters, and grain per model.
The spec pinned the raw copy at 426,887 rows with filters set to none, so no cleanup filter could sneak in; base Claude Code added a five-column NOT NULL filter nobody asked for and lost one real trip.
- 07Write and build models
SQL from the spec, with column mapping checked before each model.
- 08Verify and fix
Two read-only verifiers cross-check structure and values until every check passes.
Two verifiers re-checked both tables against their sources and confirmed the exact 426,887 and 413,689 counts; base Claude Code shipped without comparing its output to anything.
SignalPilot
claude code + signalpilot governed stack
All 8 steps complete. Here's a summary of what was built:
✅ Task Complete — All Checks PASS
Models Built
stg_divvy_data (426,887 rows)
- Reads from
source('main', 'divvy_data') - Computes
r_idsurrogate key asmd5(ride_id || '-' || CAST(started_at AS VARCHAR)) - Renames
member_casual→membership_status - Casts
start_station_id/end_station_idto VARCHAR (matching pre-built schema) - Rounds lat/lng to 3 decimal places →
DECIMAL(18,3)
facts_divvy (413,689 rows)
- Reads from
ref('stg_divvy_data'), INNER JOINsref('dim_neighbourhoods')twice (once for start, once for end station) - Duration filter:
BETWEEN 1 AND 1440 minutes(1 min – 24 hrs, per YML description) start_station_id/end_station_idcome from the neighbourhood lookup (not raw source IDs)duration_minutescomputed as(epoch_us(ended_at) - epoch_us(started_at)) / 60000000.0start_location/end_locationformatted as"lat,lng"from lookup coordinatesstart_neighbourhood/end_neighbourhoodfromprimary_neighbourhoodindim_neighbourhoods
Fixes Applied
- Removed duplicate
source.main.divvy_datadefinition inmodels/schema.yml - Removed incorrect
database: tidal-cipher-381616+schema: divvy_data_rawfrommodels/staging/schema.yml
Now let me create tasks for Steps 2–8 and load the supporting skills.
Step 1 complete. Now loading all three supporting skills for Step 2:
All three skills loaded. Now Step 3 — validate the project:
There's a duplicate source definition. Let me read both schema files to fix it:
The root models/schema.yml duplicates the divvy_data source already defined in models/staging/schema.yml. I'll remove the duplicate, keeping only divvy_stations_lookup:
Validation passes. No current_date hazards were flagged in the scan, so no rebuild needed. Now Step 4 — discover macros:
No macros directory — no macros to discover. Step 4 complete. Now Step 5 — run blueprints for both stubs in parallel:
Excellent blueprints. Now I need to explore the source tables in detail before writing the technical spec:
Key finding: station IDs in divvy_data (239, 234, 296) differ from divvy_stations_lookup (465635, 449360). Need to check if they overlap. Let me also examine the pre-built facts_divvy table to understand expected formats:
Critical insight: pre-built facts_divvy uses station IDs from the lookup (403992, not 239). It joins on station name. Let me verify this and check the location format:
Confirmed: join is on station_name, and station_id + primary_neighbourhood come from the lookup. Let me now check the r_id key format and duration formula:
Pre-built stg_divvy_data confirmed — r_id is MD5, uses raw station IDs. Duration formula verified. Now let me check the YML refs for facts_divvy and run map-columns for both models:
413,689 matches the pre-built facts_divvy exactly — confirmed INNER JOIN on both station lookups plus duration filter. Let me read the YML files:
Now let me verify the r_id MD5 formula and run map-columns for both models:
Both formulas confirmed. Now run map-columns for both models:
Let me check a few more details about the pre-built table types and location string format:
All data confirmed. Now let me move to Step 6 — write the technical spec:
Technical spec written. Now Step 7 — write and build the models:
Writing stg_divvy_data first (dependency order):
Now write facts_divvy:
Now build both models in dependency order:
The source has wrong database and schema settings. Let me fix the staging schema.yml:
Both models built successfully. Now Step 8 — verify with both subagents in parallel:
DB is live. Dispatching both verifiers in parallel:
Both verifiers return all PASS. Let me mark the workflow complete:
Base Claude Code
claude code, no additional stack
stg_divvy_data: FAIL — row count mismatch gold=426887 pred=426886 facts_divvy: FAIL — row count mismatch gold=413689 pred=418926
All 3 models materialized successfully. Here's a summary of what was done:
Created two missing SQL model files:
-
models/staging/stg_divvy_data.sql— Cleans the rawdivvy_datasource:- Generates a surrogate key
r_idviamd5(ride_id || '-' || started_at) - Renames
member_casual→membership_status - Casts station IDs to VARCHAR and coordinates to
DECIMAL(18,3) - Filters out rows missing required fields
- Generates a surrogate key
-
models/core/facts_divvy.sql— Builds the enriched fact table:- Joins
stg_divvy_datatodim_neighbourhoodstwice (for start and end stations) - Calculates
duration_minutesfrom the epoch difference - Filters trips to only those between 1 and 1440 minutes (1 min – 24 hrs)
- Adds
start_neighbourhood,end_neighbourhood,start_location,end_locationfrom the stations lookup
- Joins
Also fixed: Removed a duplicate BigQuery source definition from models/staging/schema.yml that was conflicting with models/schema.yml.
Results:
| Table | Rows |
|---|---|
stg_divvy_data | 426,886 |
dim_neighbourhoods | 1,669 |
facts_divvy | 418,926 |
Now I have a clear picture. I need to create two missing SQL model files. Let me read the schema files for exact column details first.
Let me also check the actual data in DuckDB to understand the schema precisely.
Let me check the format of the existing facts_divvy table to match it exactly.
The r_id uses md5(ride_id || '-' || cast(started_at as varchar)). Now I have everything needed to write the models.
Now I'll create the two missing SQL model files.
Now run dbt to materialize all models:
There's a duplicate source definition. The models/staging/schema.yml has a BigQuery source definition that conflicts with models/schema.yml. I need to remove it.
All 3 models ran successfully. Let me do a quick sanity check on the output: