$LATEST pointer, and update-function-code overwrites it in place.
The old artifact is gone the instant the new one lands. "Roll back" meant "re-run the whole CI pipeline
from a good commit" — minutes, with the rail down the entire time.
Every rail deploy now publishes an immutable Lambda version before it walks away, and fires the rail canary afterwards to say out loud whether the rail still works — without pretending that saying so protects anyone yet.
Hold one image for the whole walkthrough. A trapeze artist swings between two platforms.
$LATEST is the bar the artist is holding right now. There is exactly one
bar. update-function-code does not hand over a second bar — it
replaces the one in mid-air.Before this Story: no net, and no spotter. RCA-006 was a fall onto concrete, watched by a red CI job.
After this Story: a net, and a spotter who shouts. The spotter still has no hand on the
lever. That lever is pramaan-docs ADR-161, and it is deliberately not in this diff.
.github/workflows/deploy-agent-rail.yml has existed for weeks. It runs a full test job
(Postgres + lint + pytest), validates the artifact by importing the handler module out of the zip, runs
Chore-2226's assert-ssm-vpc-endpoint.sh as a pre-deploy gate, merges environment variables
read-modify-write rather than overwriting them, and smoke-tests the clerk worker. It fires on
workflow_run off Migrate Staging RDS. Steps 1–3 of the ticket were already built.
deploy-ws-rail.yml beside it would have put two workflows on the same
workflow_run trigger, racing each other to mutate the two functions already in the pipeline.
The real gaps were narrower: no published versions, no alias, no canary, and no WS Pusher
deploy. This Story closes every useful workflow-local gap; the alias remains Terraform-owned.
Three changes to deploy-agent-rail.yml: deploy all three rail Lambdas, publish each version,
and record one end-to-end canary verdict.
pramaan-ws-pusher-staging
The existing matrix now includes src.ws_pusher.handler.lambda_handler. Its smoke invokes an
empty frame list, proving the packaged runtime imports without sending anything to a browser.
Publish immutable version
Slotted into the matrix, after Verify deployed identity and before the clerk smoke test —
so the version snapshot captures the code and the merged environment together, already proven to
carry the right PRAMAAN_COMMIT_SHA.
VERSION=$(aws lambda publish-version \
--function-name "$FUNCTION_NAME" \
--description "$GITHUB_SHA @ $PRAMAAN_DEPLOYED_AT" \
--region "$AWS_REGION" \
--query 'Version' --output text)
aws lambda wait published-version-active \
--function-name "$FUNCTION_NAME" \
--qualifier "$VERSION" \
--region "$AWS_REGION"
A published version freezes code and env together and can never be mutated again. Nothing invokes it. That is not a defect — it is the entire point of shipping this slice on its own. The version costs nothing, changes no traffic, and cannot break the rail. It simply means that from this merge onward, every rail deploy leaves behind a thing you can point back at.
rail-canary job
A separate job (not a matrix leg — the rail is probed once, end to end, not once per Lambda). It
runs scripts/rail-canary-check.sh from Chore-2225, records the exit code, writes the verdict
to the job summary, and raises a ::warning:: annotation when the rail is not green.
| exit | meaning | this job |
|---|---|---|
0 | ok — a terminal frame was recovered after a forced reconnect | silent |
1 | hang — the rail did not deliver (Bug-1372 class, P1) | warns, does not fail |
2 | error — transport/auth/config failure | warns, does not fail |
3 | unprovisioned, or operator error | warns, does not fail |
Two properties of that job are load-bearing, and security review caught both being one careless edit
from lost. They are now pinned by tripwires in
tests/unit/test_deploy_agent_rail_workflow.py.
f"{ws_url}?token={token}" (rail_canary/handler.py), where
token is a freshly minted Clerk session JWT. And it folds raw exceptions
into the verdict's detail:
f"connect A failed: {type(exc).__name__}: {exc}" (rail_canary/probe.py).
detail carries a
live JWT. The first cut of this job printed the raw verdict JSON straight into the job
summary. Actions logs are readable by anyone with repo read, and GitHub masks only values it was
told were secrets; a Lambda-minted token is not one of them. The spotter would have shouted the
key to the building.
The fix is an allowlist, not a blocklist — you cannot enumerate the shapes a leaked token might
take, so nothing is published unless it is on the list. The script's stdout and stderr go to
files that are never printed (stdout is the verdict JSON; stderr is where the script cats a raw
FunctionError), and the summary republishes exactly three validated fields.
verdict in summary out
────────── ───────────
result: "error" ──► result: error
elapsed_ms: 842 ──► elapsed_ms: 842
run_id: "3977aba1-…" ──► run_id: 3977aba1-…
detail: "connect A failed: … ✗ withheld
?token=eyJhbGciOiJSUzI1…" (read CloudWatch, where it is access-controlled)
So the job carries continue-on-error: true at the job level — one
line, covering every step including the infrastructure steps a per-step flag would miss. That flag comes
off in the same change that arms the blocking gate, and not before: the day the canary can genuinely
stop a bad deploy is the day it is allowed to fail one.
The obvious next step — "so gate the deploy on the canary" — does not work, for two reasons
that are independent of each other. Both are recorded as TODO(gate) in the workflow itself,
where the next engineer will actually read them.
Verified live on 2026-07-10, account 879602786117, ap-south-1:
| Lambda | invoked by | target |
|---|---|---|
pramaan-ws-handler-staging | API Gateway WebSocket | bare function ARN in the resource policy |
pramaan-clerk-worker-staging | SQS event source mapping | bare FunctionArn |
pramaan-ws-pusher-staging | direct invoke by the other two | bare name in WS_PUSHER_FUNCTION_NAME |
aws lambda list-aliases returns [] for all three. An unqualified invoke resolves
to $LATEST. So by the time the canary job starts, update-function-code has
already put the new code in front of counsel. Refusing to shift an alias that nobody consults
would turn the job red and leave the bad code running. That is RCA-006 wearing a gate's uniform.
deploy job canary job
────────── ──────────
update-function-code ──► $LATEST ──► API Gateway ──► counsel
▲ │
│ │
already live canary shouts here
(before any canary) ...ten minutes too late
the alias that would sit between $LATEST and counsel does not exist yet
This one survives fixing reason one, and it is the subtle half. The canary
(src/rail_canary/probe.py) opens a real wss:// socket against the staging
WebSocket stage, launches atlas_intake_review, drops the connection mid-run, reconnects, and
asserts a terminal frame comes back. It is an end-to-end rail probe. It reaches whatever
the stage routes to.
Put an alias in front, and before the shift the stage routes to the old version. A canary run before the alias moves would faithfully certify the code it is meant to be replacing. This is a property of end-to-end probing, not of the wiring — you cannot fix it by rearranging the workflow.
Which is why the gate is an architecture decision, not a YAML edit. pramaan-docs ADR-161
(Proposed) names the only two shapes that exist:
clerk-runs
queue, candidate aliases. The only shape that honours canary → then shift literally. A
permanent duplicate subsystem that must stay identical to the real rail or it certifies a lie.The canary Lambda genuinely does not exist yet, so the new job's real behaviour was exercised against live AWS:
$ GITHUB_OUTPUT=/tmp/gho bash -eo pipefail -c '
set +e
./scripts/rail-canary-check.sh --json-out /tmp/canary.json
echo "rc=$?" >> "$GITHUB_OUTPUT"'
rail canary is NOT PROVISIONED: no Lambda named 'pramaan-rail-canary-staging' in ap-south-1.
what was needed: a live canary function to prove the rail before this deploy is called done.
what to do next: gh workflow run provision-rail-canary.yml -f environment=staging
--- step exit: 0 ; captured:
rc=3
The script exits 3. The step exits 0. rc=3 is captured, the summary
records "UNPROVISIONED", a warning annotation fires, and the deploy is not blocked —
which is the whole design, because arming the gate today would block the very deploy that provisions the
canary. The verdict branch was exercised for every exit code (0/1/2/3 and an unknown
99); all five return 0, and only the non-zero ones annotate.
| not done | why | tracked by |
|---|---|---|
| Gate the deploy on the canary | The gate cannot protect anything until the invokers move onto an alias, and the canary does not exist yet | pramaan-docs ADR-161 (Proposed) |
Create live aliases; repoint API Gateway, the ESM, WS_PUSHER_FUNCTION_NAME |
Changes the rail's invocation topology. Lives in pramaan-infra ws_rail.tf and
vpc_endpoints_ws_auth.tf — Terraform-owned, not this repo's |
pramaan-docs ADR-161 D1 |
| Provision the canary Lambda | Blocked on staging DB reachability for the identity script (bastion ConnectionLost) |
pramaan-spec Chore-2225 |
live alias stands between $LATEST and
counsel, the shout cannot become a catch, so this Story ships the net and the shout, names the missing
lever in pramaan-docs ADR-161, and refuses to dress detection up as protection.