Linear: [AERIE-2612](https://linear.app/builder-team/issue/AERIE-2612/a8-u02-a8-read-gate-kit-read-mode-gateway-table-reader-with-type) (related: AERIE-445)
## Summary
A8 unit U02: the shared plumbing every later A8 Aerie unit uses to move one runRefreshCycle Redshift read onto a Surtr Gateway parity mart. New files only, under sync/src/analytics/a8/, plus a dry-run template. It doesn't touch A4 or A5 files or any refresh path, and nothing reads the kit until a group unit (U05, U09–U11, U14, U17, U20–U23, U25) wires it in.
Generalised from A4 (school-source-directory-shadow-compare.ts, school-source-directories-gateway.ts, the per-source override in PR 1531) and A5's purge bound (PR 1530).
### Kit API (what later units copy)
read-mode.ts: the gate
- resolveA8ReadModes({ gate, sources, dbtBackedSources? }, env?, warn?) → Record<source, "legacy" | "shadow" | "gateway">. Call once per cycle.
- <GATE> sets every source; <GATE>_<SOURCE> (a8SourceOverrideVar) overrides one.
- Unset/blank: legacy. pg is accepted as a spelling of legacy (A4/A5 muscle memory).
- Unrecognized global → legacy + WARN. Unrecognized override → ignored + WARN, and capped at shadow, so a typo never selects gateway. Case-sensitive, like A4.
- dbt-backed sources stay legacy (with a WARN) unless DBT_TARGET is exactly production.
- readsGateway(mode), publishesFromGateway(mode).
gateway-table-reader.ts: the reader and the type-parity layer
- readA8GatewayTable({ source, columns, minRows, maxSourceAgeMs?, rowIdColumn?, buildMarkerColumn?, pageSize?, maxPages? }, { client?, now? }) → { rows, lineage: { source, sourceRunId, sourcePublishedAt, buildMarker }, pages }.
- columns maps each legacy SQL output column to its Redshift type (varchar | super | smallint | integer | bigint | numeric | real | double | boolean | date | timestamp | timestamptz). Only these columns are returned, in pg shape, so the legacy query's own mapRows runs unchanged.
- Type parity: each Gateway (Data API) value is rebuilt as the text Redshift sends over the pg wire and run through the pg driver's own text parser (pg.types.getTypeParser). BIGINT → string, NUMERIC → string, DATE/TIMESTAMP → local-time Date, TIMESTAMPTZ → Date, SUPER → its JSON text as returned, REAL/DOUBLE → the 6/15-significant-digit number pg parses. Parity holds by construction, including pg's local-time DATE handling.
- Rules, each a thrown A8GatewayReadError: Zod on every row; an absent column fails; integers must be within their Redshift range; date/time text must name a real calendar day; population floor; one non-empty source_run_id and one source_published_at across all pages; max age (default 6h); unique non-empty mart_row_id (default; rowIdColumn: null opts out); optional single build marker for dbt copies.
- The default client is built per call, not at import, so a script that loads dotenv first sees the key.
- assertSharedLineage(lineages): one source_run_id across several reads (SIS rollups + members, Forecast V2 + operands).
- gatewayValueToPg(type, value): the parity function on its own.
keyed-shadow-compare.ts: the shadow check
- compareA8Keyed({ source, keyFields, keyOf?, duplicateKeys?, valueAllowlist? }, pgRecords, gatewayRecords): keyed multiset compare of mapped records, order-free and type-strict ("5" ≠ 5, Date ≠ ISO string, absent ≠ undefined).
- duplicateKeys: "never_clean" (default) or "multiset" for pre-dedupe row sets (Q5 without #n, D2 before last-row-wins, vendors).
- Output: counts, per-field mismatch counts, value *types*, and up to 10 examples. Values appear only for valueAllowlist fields; a key appears only when every key field is allowlisted, and always as those fields' values (a custom keyOf's output is never shown). Everything else is "[redacted]".
- runA8ShadowCheck({ spec, pgRecords, readGateway, skew?, log? }): never throws, never publishes, logs exactly one a8_shadow_check line (info when it counts as clean, WARN otherwise). Outcomes:
- clean;
- source_advanced: EDUCRM rule, skew: { rule: "source_advanced", rereadPg }. After a mismatch, pg is re-read once; if the re-read equals the Gateway, the result counts as clean;
- stale_copy: dbt rule, skew: { rule: "stale_copy", pgBuildMarker }. Different build markers mean the check is skipped, not a mismatch;
- mismatch;
- degraded: the Gateway read failed (swallowed).
- countsAsClean and compared fields drive the shadow-window count. A check whose log line couldn't be written is degraded.
purge-guard.ts
- resolveA8MaxPurgeFraction("<GATE>_MAX_PURGE_PCT") (default 5%, invalid → 5% + WARN), countA8WouldPurge(existingIds, incomingIds), checkA8PurgeBound(label, counts, fraction), and assertA8PurgeBound (throws A8PurgeGuardError). Counts only in messages. U05 uses it for purgeStalePrograms.
operator-safe-error.ts
- describeA8Error(error): the kit's and the Gateway client's own messages as they are (trusted by instanceof, never by error.name); Zod reduced to codes and paths; anything else to its name and code, each shown only if it looks like an identifier. A kit-local copy of PR 1531's describeErrorForOperator, which isn't on main yet.
sync/src/scripts/dry-run-a8-shadow.ts: the template
- Loads dotenv, then await import()s every app module.
- Ships with one live canary: A4's SIS organization directory read through the kit (varchar + TIMESTAMPTZ parity).
- Exit 0 only when every check counts as clean.
- Run: cd sync && pnpm exec tsx src/scripts/dry-run-a8-shadow.ts. No package.json script is added, to keep this PR new-files-only; each group's copy can add one.
### One deliberate reading of the recipe
The rule "only "" and null map to null" is applied to typed columns (numbers, dates, booleans), where a real value can never be empty. Text columns (varchar, super) keep "": pg returns "" for an empty string, and the shared mapper must see the same input on both transports. Mapping it to null would make the Gateway path diverge from legacy (e.g. a non-nullable z.string() in a legacy mapper). Legacy mappers that already turn "" into null keep doing so on both sides.
## Business Value
A8 is the largest slice of the Aerie EC2 → Surtr migration: about 20 Redshift reads in runRefreshCycle. This PR is the foundation for the 11 Aerie units that follow. Building it once:
- makes each group unit smaller and consistent, so Mercy reviews less and every cutover behaves the same way;
- puts the plan's biggest technical risk (transport type parity, §8 risk 1) in one tested place instead of 11 hand-rolled readers;
- builds in the safety rules (fail-closed reads, PII redaction, purge bound, never-throw shadow) once, so a later unit can't forget one.
Together these move the worker's direct Redshift credential and analytics-worker toward deletion.
## Manual Effort Estimate
Proposed: ~12 focused hours for Keval by hand, without AI. Keval, please confirm or adjust.
- Type-parity research (Data API field union, pg-types/postgres-date behaviour, Redshift float text): ~2.5h
- Reader and parity layer: ~2h
- Keyed multiset compare, redaction, classifiers, orchestrator: ~2.5h
- Read-mode gate and purge guard: ~1.5h
- Tests (158): ~3h
- Dry-run template and PR write-up: ~0.5h
## Testing / evidence
All commands ran in the worktree on this branch, rebased on origin/main 3d4fe1a97 (after Mercy round 3).
| Check | Result |
|---|---|
| cd sync && pnpm typecheck | pass |
| pnpm lint (repo root: boundaries, convex-paths, read-bounds, test-architecture, knowledge, biome) | exit 0. The 2 warnings are pre-existing, in unrelated chat/skill/forge-api/scripts/sindri.mjs |
| cd sync && pnpm test --maxWorkers=2 | 86 files, 1,521 tests passed |
| New kit tests alone, under TZ=Asia/Kolkata, TZ=UTC and TZ=America/Los_Angeles | 5 files, 158 tests, pass in all three |
| lefthook pre-commit (biome + typecheck-sync) | pass |
Type parity (the §8 risk 1 fixtures). One mart row is defined in both transport shapes:
- Data API: BIGINT and INTEGER as numbers; NUMERIC, DATE, TIMESTAMP, TIMESTAMPTZ and SUPER as strings; DOUBLE as the full double.
- node pg: BIGINT and NUMERIC as strings; DATE and TIMESTAMP as local Dates; TIMESTAMPTZ as an absolute Date; SUPER as JSON text.
The tests assert that:
- the reader turns the Gateway shape into exactly the pg shape;
- the pg fixture is what pg's own parsers return, so the fixture itself is pinned;
- a legacy-style mapper built from Aerie's superString / dateToDateString helpers gives identical records from either transport, and the shadow compare is clean;
- every type has accept and reject cases, and no reject reason echoes the value.
Other coverage:
- Reader: every rule above, with messages checked for the absence of a PII fixture value.
- Compare: redaction of values and keys, multiset mode, both classifiers and their edge cases, a throwing key function or log sink, and frozen pg records left unchanged.
- Gate: every global and override value, typos, and the DBT_TARGET precondition.
- Purge guard: bounds, and the "60 of 90 programs from one run" case.
Dry-run template (local only, no prod):
- With no env file: prints the missing vars, exit 1.
- With a scratch env pointing Redshift and the Gateway at 127.0.0.1:1: config loads before the app modules, the pg read fails as Error (ECONNREFUSED) (details withheld), exit 1.
Mercy round 1 (both findings fixed):
- The canonical encoding is now collision-free: every value at every depth is a type-tagged [tag, payload] array, object keys sit inside the payload, and the absent-field marker uses a tag no value produces. A test pins that marker-shaped data ({"$u":1}, {"$absent":1}, ["u"], nested too) never equals undefined, an absent field, a Date or a bigint.
- The dry-run's pg canary no longer has a LIMIT, so both sides see the same snapshot boundary. The template notes now tell each group copy to keep it that way.
Mercy round 2 (all three findings fixed):
- A key from a custom keyOf is never displayed. Examples show the group's keyFields values instead, and only when every key field is allowlisted, so shown data is always allowlisted data.
- describeA8Error trusts kit and Gateway errors by instanceof, not by the spoofable error.name. A name, error code or Zod path segment is shown only when it looks like an identifier. Spoofed-name regression tests are added.
- If the log sink throws, the check becomes degraded (never counted clean) and a best-effort line goes to the default sink.
Mercy round 3:
- Fixed: date, timestamp and timestamptz text must name a real calendar day (a UTC round-trip, so the TZ can't affect it). The pg parser would otherwise roll 2026-02-31 over into a different real date.
- Fixed (nit): SMALLINT/INTEGER/BIGINT values must be within Redshift's ranges, and BIGINT text must be canonical.
- Answered in-thread: a read capped by maxPages can't reach the reader. SurtrGatewayClient.listAll throws when has_more is still true after maxPages, and a new test pins that with the real client.
Throughput (synthetic): 100k rows × 40 columns parse in about 4.6s and compare in about 2s. The largest A8 source is expected to be a few hundred thousand rows, read hourly.
## Not covered
- No live Gateway run. No prod, no keys in this unit. The canary dry-run needs Keval's key and runs as X5. G1 (U05) remains the first live proof of SUPER/DATE/BIGINT parity against real marts (plan §8).
- The float rule is modelled, not yet observed live. REAL/DOUBLE are rounded to 6/15 significant digits, matching Redshift's text output at extra_float_digits=0. The first A8 mart with a float column should confirm it in its shadow run. If it's wrong, the per-field mismatch counts will name the column.
- No wiring, .env.example gate lines or package.json script. Those arrive with each group unit.
- describeA8Error duplicates PR 1531's helper. Fold the two together once PR 1531 merges.
🤖 Generated with [Claude Code](https://claude.com/claude-code)