## Summary
This is A8 plan unit U16 (SURTR-1547). It adds the third A8 runner, for copies of Aerie's dbt marts, and both reads behind Aerie's Admissions Pipeline report. That lets Aerie's queryAdmissionsPipelineRows and queryAdmissionsPipelineCrosswalk move onto the Surtr Gateway; the Aerie gate is U20.
- New runner pipelines/runners/mart-aerie-dbt-publication-refresh (Lambda, bundling: true, src/requirements.txt).
- Schedule: cron(0/10 * * * ? *). Aerie's dbt build is not a Surtr pipeline, so there is no success event to trigger on. Instead, each procedure detects a new build itself.
- The schedule ships disabled (Mercy round 1). Its objects are out-of-band DDL, so it is enabled in a one-line follow-up once the DDL is applied and an on-demand run is verified.
- What it runs: it CALLs each procedure in REFRESH_PROCEDURES with (run_id, force), then checks the mart read-only: non-empty, unique mart_row_id, one source_run_id and one source_build_marker. The run_id must be the platform UUID before it is inlined. U19 (SIS) and U24 (Forecast V2) will append their procedures here.
- Result per mart: published, unchanged (the build was already copied, which is most runs) or failed.
- Failure handling: the same as U03. A failed procedure makes the run partial_failure, and the run fails if every procedure fails or a commit outcome is unknown.
- Freshness: a copy whose dbt build is older than SOURCE_MAX_AGE_MINUTES (180) makes the run partial_failure. The copy is still exact, but dbt has stopped publishing (PIPELINE §5.4).
- 071/072 aerie_admissions_pipeline_detail (slug aerie-admissions-pipeline-detail, PII) is a style-C dbt publication copy.
- The candidate is Aerie's SQL. It is copied verbatim from admissions-pipeline.ts:154-173 (Aerie e366e27d0, unchanged since 92fd47992), bound to the production relation sandbox_education.mart_admissions_pipeline_dtl. The one other edit drops the trailing ORDER BY: a table has no row order, the Gateway pages by mart_row_id, and U20 re-sorts.
- The build marker. source_build_marker = pg_class_oid:<oid>. dbt's table materialization swaps in a new relation on every build, so the OID changes. When the published marker is current, the procedure no-ops unless p_force.
- Switching to dbt_invocation_id (plan §9 D4) changes only the one v_source_build_marker := assignment.
- Lineage: source_run_id is this copy's own run; source_published_at is the relation's creation time (pg_class_info.relcreationtime).
- Coupling guard (the lockstep rule). Before copying, the procedure compares the dbt relation's column types, by OID, with the mart's pinned types. Only the six ::text columns are exempt, and varchar width is ignored. On any drift it raises and keeps the previous publication.
- A mid-copy dbt swap is detected by re-reading the OID after the copy. In that case the procedure publishes nothing, and the next tick copies the new build.
- Other guards: it fails closed on a missing relation, an empty candidate, a candidate count different from the source's, or a duplicate mart_row_id. mart_row_id = MD5 of MD5(pipeline_key) and its occurrence number, ordered by every other column.
- PII: SELECT is revoked as well as writes, and the 11 PII columns carry PII: comments.
- 073/074 aerie_admissions_pipeline_tenant_crosswalk (slug aerie-admissions-pipeline-tenant-crosswalk).
- Aerie's SQL is copied verbatim from admissions-pipeline.ts:201-208.
- It is EduCRM-backed, so it is appended to U03's mart-aerie-admissions-refresh REFRESH_PROCEDURES, and it uses the observed sales-educrm-mart-sync provenance for mart_pipeline_dtl.
- Aerie's 1:1-per-tenant assertion stays in Aerie.
- DDL: pipelines/cdk/sql/mart_education/070-074; U16 owns 070-079. 070-072 are applied by the new runner's scripts/apply_ddl.py, and 073-074 by U03's.
- The U03 DDL test now requires every aerie_admissions file to be applied by exactly one of the two runners.
- Both apply_ddl.py scripts now have no default target (Mercy round 1). They refuse to send a statement unless REDSHIFT_CLUSTER_IDENTIFIER, REDSHIFT_DATABASE and REDSHIFT_DB_USER are all set.
- README: it documents the dbt-copy PIPELINE §13 exception (WAREHOUSE §2.2 and §2.5, and PIPELINE §4 for the external dbt writer; §7 is met through the explicit build marker), the coupling rule and the lockstep steps, the marker, and PII.
- Gateway registration (U04, #2082) checked: both slugs map to exactly these table names, ordered by mart_row_id. No change was needed.
## Business Value
- The G2 Admissions Pipeline report can leave the EC2 analytics worker. It is Aerie's widest PII read, at 30,929 rows and 53 columns. Aerie can read it through the Surtr Gateway with its unchanged row mapper. That is the SURTR-735 quarterly commitment, and a step toward tearing the worker down.
- dbt stays with Vladimir, with no fork. The copy follows each hourly dbt build within 10 minutes and carries lineage to the exact build. A dbt column change that would break Aerie's parity now fails loudly in Surtr, instead of silently drifting.
- U19 (SIS enrollment) and U24 (Forecast V2) reuse this runner, adding only a procedure and one REFRESH_PROCEDURES entry.
## Manual Effort Estimate
About 14 focused hours (roughly 2 days) to build by hand without AI. That covers:
- reading the Aerie reader, the dbt materialization and the catalog to design the build marker, the swap guard and the coupling guard;
- two procedures, the runner, and its freshness reporting;
- tests, reconciliation, and the read-only probes.
Keval: please confirm or adjust.
## Testing / evidence
- uv run pytest: 91 passed (new runner) and 105 passed (mart-aerie-admissions-refresh, including 14 new crosswalk contract tests and the explicit-target apply_ddl tests).
- The SQL contracts pin both Aerie queries. They assert:
- each candidate is exactly that SQL plus the allowed edits;
- the reconciliation uses the same candidate;
- the guards come before the DELETE;
- the coupling guard exempts only the ::text and lineage columns;
- the marker is a single assignment.
- Ruff 0.15.22: ruff check pipelines and ruff format --check pipelines are clean.
- CDK: real-pipeline-configs.test.ts passed (590). The app also synthesized with Docker bundling skipped (CDK_CONTEXT_JSON aws:cdk:bundling-stacks=[]). Pipeline-mart-aerie-dbt-publication-refresh-prod contains:
- one Lambda (handler.handler, python3.11, 900 s);
- one Step Functions state machine;
- the rule pipeline-mart-aerie-dbt-publication-refresh-schedule-prod cron(0/10 * * * ? *) ENABLED;
- 4 alarms.
- Read-only reconciliation was run with psql as CQL_download_OM, SELECT only, reading counts only:
| mart | aerie rows | candidate rows | aerie − candidate | candidate − aerie | parity |
|---|---|---|---|---|---|
| aerie_admissions_pipeline_detail | 30,929 | 30,929 | 0 | 0 | PASS |
| aerie_admissions_pipeline_tenant_crosswalk | 57 | 57 | 0 | 0 | PASS |
- The candidate as the mart stores it (every column CAST to the mart's declared type) is also EXCEPT 0/0 against Aerie's SQL: 30,929 and 57 rows. So the INSERT changes no value.
- Negative controls on the detail compare: dropping a row gives 1 / 0, and duplicating a row gives 1 / 1.
- The procedures' exact mart_row_id expressions give 30,929 and 57 distinct values. pipeline_key is unique and non-null (30,929).
- The source today: sandbox_education.mart_admissions_pipeline_dtl is a table (relkind r) with OID 20602348, created 2026-09-29 11:39:17 UTC (the 11:30 dbt build), owned by vladimir.pikalov. It is readable by CQL_download_OM.
- The coupling guard's pinned columns are 39 character varying, 5 boolean and 3 numeric(18,2). The guard's catalog query returns 0 against the source itself.
- EduCRM mart_pipeline_dtl: the observed run ed781ef8… is the latest run (SUCCESS), and rows_loaded 33,585 = snapshot 33,585.
- Not yet run: Query 3 (detail) and Query 2 (crosswalk) compare against the published marts, and need the DDL.
- scripts/apply_ddl.py --dry-run passes. The statements are the committed SQL files verbatim, in this order:
- new runner: 070_aerie_dbt_publication_refresh_writer_mutex.sql (5 statements), 071_aerie_admissions_pipeline_detail.sql (26), 072_sp_refresh_aerie_admissions_pipeline_detail.sql (4);
- mart-aerie-admissions-refresh: 006-011 unchanged, then 073_aerie_admissions_pipeline_tenant_crosswalk.sql (10) and 074_sp_refresh_aerie_admissions_pipeline_tenant_crosswalk.sql (4).
## Keval steps
1. Crosswalk DDL before merging. A merge reaches production within the hour, and the EduCRM trigger then CALLs the crosswalk every 30 minutes. Until 073-074 exist, that CALL would make each run PARTIAL (amber, throttled); nothing wrong is published. Run:
cd pipelines/runners/mart-aerie-admissions-refresh && REDSHIFT_CLUSTER_IDENTIFIER=redshift-cluster-1 REDSHIFT_DATABASE=finance_dw REDSHIFT_DB_USER=CQL_download_OM uv run python scripts/apply_ddl.py
It applies 006-011 (idempotent) and 073-074.
2. PII sign-off (A8 plan §9 D2) for exposing aerie_admissions_pipeline_detail over the Gateway. It holds parent and child names, emails and phones, and child date of birth and gender.
3. Merge. Mercy withholds auto-approve on pipelines/cdk/ paths. The new runner deploys with its schedule disabled.
4. Apply the detail DDL (070-072):
cd pipelines/runners/mart-aerie-dbt-publication-refresh && REDSHIFT_CLUSTER_IDENTIFIER=redshift-cluster-1 REDSHIFT_DATABASE=finance_dw REDSHIFT_DB_USER=CQL_download_OM uv run python scripts/apply_ddl.py
5. Run mart-aerie-dbt-publication-refresh on demand.
- The first run should report published with about 30,929 rows.
- A second run should report unchanged.
6. Run both reconciliation files. Each comparison against the mart must report parity = PASS. For the detail, first check that the mart's marker equals Query 2's current marker.
7. Enable the schedule: a one-line follow-up PR setting "enabled": true in the new pipeline.json.
8. Optional: ask Vladimir to add {{ invocation_id }} AS dbt_invocation_id to mart_admissions_pipeline_dtl (plan §9 D4). The switch is then one assignment per procedure.
## Not covered
- The Aerie gate and shadow compare (U20), SIS (U19) and Forecast V2 (U24).
- The procedure bodies have not been executed in Redshift, because no DDL was applied. Their candidate SELECTs, the type-projected candidate, the mart_row_id expressions, the catalog queries (source OID, creation time, the coupling guard's shape) and the EduCRM observation were run read-only instead.
- A procedure-only change does not republish by itself. With an unchanged dbt marker, the procedure no-ops. After a lockstep migration or procedure fix, run on demand with force: true, as the README says.
- OID reuse. The marker assumes Redshift does not reuse the relation's OID across builds. OIDs are 32-bit and only wrap after about 4 billion allocations.
🤖 Generated with [Claude Code](https://claude.com/claude-code)