## What this is
A read-only pull API for app customers checking data health, plus a third Pipeline API Keys tab on the APIs page for minting the keys that open it. Two GET endpoints. No push, no webhooks, no subscriptions, no new health computation, and no change to the alerting Surtr already has.
## The core property: this layer decides nothing
It is a serialization layer over records Surtr already keeps. There are no thresholds, freshness SLAs, row-count rules, null-rate checks or heuristics anywhere in it.
| Signal | Read from | Vocabulary returned |
| --- | --- | --- |
| Run outcome | run-record store — Redshift staging_other.pipeline_runs_prod + pipeline_registry_prod (Postgres fallback locally), via pipelineQueries | success · failed · partial · running · timeout — the runners' own values, lower-cased exactly as the query layer already does |
| Observer | DynamoDB surtr_pipeline_observations, via the observer store | OK · WARN · CRITICAL · UNAVAILABLE — the observer's own verdict, plus its own severities C/H/M/L |
Both are returned, side by side, never merged. An earlier revision of this branch had a single state field that blended them using the dashboard's bucketing rules — that was this layer inventing a judgement Surtr never made. The two signals are independent and can legitimately disagree (a run succeeds while the observer flags the data; a run fails while the last observation still says OK), so last_run.status and observer.verdict are reported separately and unmapped. There is no mapping table anywhere in the diff.
No signal is never "healthy." Never observed, observation switched off for the pipeline, and observation store unreadable all report Surtr's own UNAVAILABLE with a distinguishable unavailable_reason. A pipeline that has never run reports last_run: null. Nothing silently defaults to green.
## Endpoints
| Requested | Implemented |
| --- | --- |
| GET /pipelines | GET /v1/pipelines |
| GET /pipeline/<id> | GET /v1/pipeline/{id} |
/v1 is the repo's existing public-read prefix and already has a Next.js rewrite to the Hono backend. A bare /pipelines rewrite would have shadowed the existing /pipelines UI page, so the prefix isn't optional. These two routes are explicitly exempted from the /v1 SURTR_API_KEYS shared-secret gate and authenticate with their own pak_ keys — tested in both directions.
### GET /v1/pipelines
?limit= (default 50, max 200) &offset= &status= (run outcome) &verdict= (observer) &name= (id or name prefix)
{"as_of": "2026-08-04T01:34:43.159Z",
"count": 2, "total": 2, "limit": 2, "offset": 0,
"run_status_counts": { "success": 1, "failed": 1 },
"data": [
{
"id": "aws-bedrock-token-metrics",
"name": "AWS Bedrock Token Metrics Pipeline",
"description": "Fetches Bedrock token metrics by model and loads them into the warehouse",
"schedule": {
"expression": "cron(0 7 * * ? *)", "enabled": true, "timezone": "UTC",
"next_run_at": "2026-08-04T07:00:00.000Z"
},
"last_run": {
"run_id": "run-9f2c1a", "status": "success",
"started_at": "2026-08-04T01:28:43.159Z",
"completed_at": "2026-08-04T01:32:17.159Z",
"duration_ms": 214000, "trigger_type": "schedule", "error": null
},
"observer": {
"verdict": "WARN", "unavailable_reason": null,
"last_evaluated_at": "2026-08-04T01:34:43.159Z",
"open_finding_count": 2, "worst_severity": "H", "window_days": 7
}
},
{
"id": "gcp-billing-pipeline",
"name": "GCP Billing Pipeline",
"description": "Loads the GCP billing export into the warehouse",
"schedule": { "expression": "rate(6 hours)", "enabled": true, "timezone": "UTC",
"next_run_at": "2026-08-04T07:28:43.159Z" },
"last_run": {
"run_id": "run-3b7e", "status": "failed", "duration_ms": 12000,
"trigger_type": "schedule",
"error": "BigQuery export table not found: billing_export_v1"
},
"observer": {
"verdict": "UNAVAILABLE",
"unavailable_reason": "No observation recorded in the last 7 days.",
"last_evaluated_at": null,
"open_finding_count": 0, "worst_severity": null, "window_days": 7
}
}
]
}
Note the second row: the run failed and the observer has nothing to say. Both facts are visible; neither is resolved into a verdict.
### GET /v1/pipeline/{id}
?runs= (default 20, max 100)
{"as_of": "2026-08-04T01:34:43.159Z",
"id": "aws-bedrock-token-metrics",
"name": "AWS Bedrock Token Metrics Pipeline",
"description": "Fetches Bedrock token metrics by model and loads them into the warehouse",
"schedule": { "expression": "cron(0 7 * * ? *)", "enabled": true, "timezone": "UTC",
"next_run_at": "2026-08-04T07:00:00.000Z" },
"last_run": { "run_id": "run-9f2c1a", "status": "success", "duration_ms": 214000, "error": null },
"runs": [
{ "run_id": "run-9f2c1a", "status": "success",
"started_at": "2026-08-04T01:28:43.159Z", "completed_at": "2026-08-04T01:32:17.159Z",
"duration_ms": 214000, "trigger_type": "schedule", "error": null },
{ "run_id": "run-8a11", "status": "failed",
"started_at": "2026-08-03T01:28:43.183Z", "completed_at": "2026-08-03T01:29:24.183Z",
"duration_ms": 41000, "trigger_type": "schedule",
"error": "Throttled by CloudWatch GetMetricData (429) after 5 retries" }
],
"observer": {
"verdict": "WARN", "unavailable_reason": null, "enabled": true,
"score": 86,
"summary": "Loaded, but the newest partition lags a day and volume dipped.",
"last_evaluated_at": "2026-08-04T01:34:43.159Z",
"last_evaluated_run_id": "run-9f2c1a",
"window_days": 7,
"findings": [
{
"category": "freshness",
"title": "Latest partition is 26h old",
"severity": "H",
"evidence": "max(usage_date) = 2026-08-03 vs run date 2026-08-04",
"recommendation": "Confirm the upstream export completed before the 07:00 window.",
"occurrences": 2,
"first_fired_at": "2026-08-03T01:28:43.159Z",
"last_fired_at": "2026-08-04T01:28:43.159Z",
"last_fired_run_id": "run-9f2c1a"
},
{
"category": "volume",
"title": "Row count 18% below 7-day median",
"severity": "M",
"evidence": "18,442 rows vs median 22,600",
"recommendation": "Check whether a linked account dropped out of the scan.",
"occurrences": 1,
"first_fired_at": "2026-08-04T01:28:43.159Z",
"last_fired_at": "2026-08-04T01:28:43.159Z",
"last_fired_run_id": "run-9f2c1a"
}
],
"ignored_findings": [
{ "category": "cosmetic", "title": "Deprecation warning in boto3",
"reason": "known, harmless", "ignored_at": "2026-07-26T20:28:43.183Z" }
],
"history": [
{ "run_id": "run-9f2c1a", "verdict": "WARN", "score": 86, "evaluated_at": "2026-08-04T01:34:43.159Z" },
{ "run_id": "run-8a11", "verdict": "OK", "score": 100, "evaluated_at": "2026-08-03T01:28:43.159Z" }
]
}
}
Both samples are real output from the routes, not hand-written.
## ⚠️ The observer records no per-check thresholds or observed values
The spec asked for, per observer check: *what it checks · current verdict · configured threshold · observed value · when it last evaluated · when it last fired.* Four of those exist; two do not, and I did not invent them.
Surtr's observer is not a set of standing checks with configured thresholds. It is a per-run LLM evaluation that emits findings, each with severity · category · title · evidence (free text) · recommendation. There is no per-check threshold and no structured observed value anywhere in the store.
| Asked for | Returned as | Source |
| --- | --- | --- |
| what it checks | category + title | the finding |
| current verdict | severity per finding, verdict overall | the finding / observation |
| configured threshold | omitted | does not exist |
| observed value | closest is evidence (the observer's own free-text measurement, e.g. "18,442 rows vs median 22,600") | the finding |
| when it last evaluated | observer.last_evaluated_at | observation |
| when it last fired | last_fired_at (+ first_fired_at, occurrences) | the observations the finding appeared on |
I also dropped the thresholds block the earlier revision exposed — it was the observer's *global* scoring rubric (severity→deduction weights and verdict bands), not a per-check threshold, and shipping it under that name would have implied a precision the observer doesn't have.
If you want real per-check thresholds and observed values, that's a change to what the observer records, not to this API — say the word and I'll scope it separately.
## Because the audience is external
Everything below is deliberately omitted from both endpoints. There's a test that greps the raw detail response for each of these strings and fails if any appears:
| Omitted | Why |
| --- | --- |
| step_function_arn, step_function_url | internal topology |
| cloudwatch_log_group, cloudwatch_logs_url, log_stream | internal topology |
| AWS account ids, console links | internal topology |
| owners (name + email) | internal staff identities |
| triggered_by | can be an internal user id |
| output_summary | names internal warehouse tables (core_finance.…) |
| model_id, observer_version, braintrust span ids | internal observer implementation |
| deployed_at | internal deploy metadata; doesn't answer "can I trust this data right now?" |
| run input_params | can carry caller-supplied values |
Kept, as a judgement call: run_id (a Surtr domain id, not infra — it's the join key between the run history and last_fired_run_id, and useful in a support conversation) and trigger_type (schedule / manual — explains *why* a run happened, reveals nothing). Tell me if you'd rather either went.
Error text is the pipeline's own failure reason — unwrapped from its orchestration envelope, cut before any stack trace or SQL dump, capped at 500 chars, and with any surviving infrastructure identifier redacted. See the section below: this was a real leak, not a precaution.
## 🔴 A real leak, found by running it against live data
Calling the endpoints locally against the live registry surfaced something the unit tests could not: Step Functions task failures are stored as States.TaskFailed: {<the entire ECS task description>}. The original stack-frame trim didn't touch a single-line JSON blob, so hubspot-admissions-funnel and quickbooks-ap-sync were serving subnet ids, ENI ids, MAC addresses, private IPv4s, ip-….ec2.internal hostnames, cluster and task ARNs, the ECR image URI and the AWS account id to whoever held a key.
Fixed in 36a11e48 and a600d2c8. Failures are now unwrapped rather than trimmed:
- a Lambda error (RuntimeError: {"errorMessage": …, "stackTrace": […]}) yields its errorMessage — the pipeline's own reason, which is what a customer wants;
- a Step Functions blob contains no reason at all, only topology, so it is dropped and only States.TaskFailed survives;
- a blob with neither says the detail was omitted, which beats a null that would read as "no error" on a failed run;
- then first line only, then ARN / subnet- / eni- / i- / ECR URI / private IP / MAC / account-id redaction, then the length cap.
The second commit exists because the runners interpolate Redshift errors containing bare double quotes into the payload without re-escaping, so JSON.parse refuses the blob and five real pipelines lost their reason to a bare "RuntimeError". errorMessage is now salvaged from the malformed text.
Verified against all 90 live pipelines: every one of arn:aws, subnet-, eni-, 12-digit account ids, private IPv4 ranges, MACs, ec2.internal, /aws/lambda/, /aws/ecs/ and dkr.ecr scans clean, while the ten currently-failing pipelines still report their own reasons (docker source is stale: max=2026-W29, lag=3 weeks, relation "…" does not exist, TimeoutError, …).
## Contract details
- snake_case throughout, matching the specified as_of, and matching the repo's other public JSON.
- as_of on every response — the newest instant Surtr computed anything reflected in the payload (max(last run's completed_at, observer's last_evaluated_at)), never the serve time. Per-item freshness is also visible via last_run.completed_at and observer.last_evaluated_at.
- ISO-8601 UTC everywhere; durations are duration_ms consistently.
- Enum values are Surtr's, and ?status= / ?verdict= validate against those same lists, so a filter value and a serialized value can never drift.
- run_status_counts is the cheap convenience rollup (from the registry read we already do). Run outcomes only — an observer rollup would force a fan-out over every pipeline, and this is explicitly a convenience.
## Key minting — third tab
APIs → Pipeline API Keys (pak_…), built from the same components and the same mint / copy-once / revoke flow as the existing Gateway tab, with scope cards where that tab has source cards: a pipelines:read scope card, label + optional expiry, one-time gold panel with Copy / Dismiss and a ready-to-paste curl, and a key table with scope chips and Revoke. A load failure renders "Failed to load keys …" and explicitly not "no keys".
The picker offers exactly one scope, because exactly one capability exists. An earlier revision also listed a greyed-out "Trigger / modify pipelines · soon" card; it was removed — a placeholder advertising an API nobody is building is worse than no entry.
Storage mirrors gateway_keys exactly: pipeline_api_keys in Postgres, only the sha256 of the raw key persisted, secret returned once. tRPC gains listPipelineApiScopes / listPipelineApiKeys / createPipelineApiKey / revokePipelineApiKey, all api_admin-gated.
I did not invent a scope system — I mirrored the gateway's. A separate table (rather than reusing gateway_keys) is deliberate: a gwk_ key can never open the pipeline API, a pak_ key can never open the gateway, and each surface stays independently revocable. No key from another tab grants access, and neither does a SURTR_API_KEYS shared secret.
## Unhappy paths
Gateway-style envelope, { "error": "<code>", "message": "…" }:
| Case | Status |
| --- | --- |
| no key | 401 unauthorized |
| unknown / revoked / expired key (indistinguishable, on purpose) | 401 unauthorized |
| key-store read throws | 401 unauthorized (fails closed) |
| live key without pipelines:read | 403 forbidden |
| unknown pipeline id | 404 not_found |
| bad status / verdict / limit / offset / runs | 400 bad_request |
| pipeline registry not configured | 503 service_unavailable |
No silent empty-200s. The 401 body names the header and never echoes the presented key.
## Tests
56 new tests, none touching AWS/Redshift/Postgres — the key validator and the observation reader are injected via the same seam the gateway tests use.
test/pipeline-api/pipeline-api.test.ts (46) · test/pipeline-api/schedule.test.ts (10) · test/ui/pipeline-apis-tab.test.tsx (5)
Beyond the happy paths, 404, 401, 403 and 503, the ones worth calling out:
- a failed run alongside an OK observer verdict returns both, unmerged
- the response has no state / health / top-level status field at all
- never-observed → UNAVAILABLE + reason, not OK
- observation store throws → UNAVAILABLE + the store's reason, and the run outcome is unaffected
- ignore-list read throws → UNAVAILABLE, never "no findings"
- operator-disabled observer → UNAVAILABLE + enabled: false
- the raw detail response contains none of ~10 internal strings (ARNs, log groups, account id, owner email, Clerk user id, model id, warehouse table name)
- the observer block has no thresholds / scoring, and no finding has threshold / observed_value
- as_of is older than the serve time and equals the newest underlying computation
- stack-trace stripping (Python and Java frames), blank-line truncation, 500-char cap
- both cross-surface auth-leak directions
### Results — actually run
| Gate | Result |
| --- | --- |
| pnpm lint (biome) | pass — 95 files, no fixes applied |
| pnpm build (tsc) | pass — clean |
| pnpm test:unit (what CI runs) | pass — 45 files, 653 tests |
| pnpm build:ui (next build) | pass |
| pnpm test (full suite) | 1263 pass / 7 fail |
The 7 failures are pre-existing on origin/main — all in test/connectors/redshift.test.ts (mapSiteRow), which CI's test:unit excludes. Confirmed by stashing this branch and re-running: identical 7 failures, 1212 passing.
## Deploy notes
- Migration required. New pipeline_api_keys table — pnpm db:push, or the equivalent applied through ECS Exec in prod (this repo has no auto-migrate). Until it exists the endpoints 401 every key; nothing else is affected.
- No new secrets, env vars or IAM. No key material is logged.
- Routes register only when the Postgres db is present — same posture as the gateway, absent rather than open.
## Deliberately left out
- No write surface, and no placeholder for one. pipelines:read is the only scope that exists. A key whose scopes don't cover a route still gets a 403 — that branch is tested with a scope-less key.
- No third resource, no push/webhooks/subscriptions, and no change to existing alerting.
- Cursor pagination — limit/offset like the gateway; the registry read is a full-set read paginated in memory.
- Cron edge syntax — next_run_at handles every expression the deployed pipelines actually use (wildcards, lists, ranges, steps, day/month names). EventBridge L / W / # return null rather than a guess; no deployed pipeline uses them.
- Cost note. ?verdict= is the only parameter that needs observer records for pipelines outside the page (one DynamoDB query each, the same fan-out the dashboard already does every load). Every other request reads the observer for the page only.
🤖 Generated with [Claude Code](https://claude.com/claude-code)