deploy.sh is the whole deployment: it bundles the package, corpus snapshot,
prompts, and web handler into one Lambda behind a public HTTP API, with an IAM
role scoped to the pinned answer model. It publishes an immutable numbered
version, verifies that version directly, and only then moves the stable live
alias. Architecture and cost guards are in ADR 0004. Release control and
rollback are in ADR 0018
(docs/decisions/0018-immutable-lambda-release-control.md); the structured
logging and promotion gate are in ADR 0019
(docs/decisions/0019-privacy-safe-runtime-observability.md).
The API Gateway stage throttle is the true cross-container aggregate rate
limit (its rate and burst are derived from RESERVED_CONCURRENCY at the top
of the script, so they cannot drift independently);
tests/test_deploy_rate_limit.py guards that. It bounds what the service
spends in total but not who spends it, so a per-caller limiter and a spend
breaker sit alongside it (ADR 0025, docs/decisions/0025-per-caller-limiting-and-spend-cutoff.md).
See "Per-caller rate limiting" and "Spend cutoff" below.
The rider bundle's dependencies are hash-pinned (roadmap M-7 / audit P1-6):
deploy.sh installs only from infra/requirements-deploy.txt with
--require-hashes, and that file is a uv export of the locked runtime set,
so the deployed artifact carries exactly the versions the test suite ran
against. Regenerate it with make deploy-reqs after any dependency change;
tests/test_deploy_requirements.py fails if it drifts from uv.lock. The
operator console bundle (deploy-console.sh) still installs from loose
ranges and is not covered by this pin file.
scripts/build_lambda_zip.py writes the rider ZIP with sorted paths and fixed
timestamps, modes, and ZIP metadata. Rebuilding unchanged inputs therefore
reuses the same Lambda CodeSha256 instead of consuming another numbered
version because of installation-time mtimes. The builder rejects symlinks and
special filesystem entries and preserves the existing __pycache__ and wheel
RECORD exclusions. It also omits unused dependency console scripts under
top-level bin/, whose generated shebangs otherwise expose the builder's
checkout-specific virtual-environment path and change the artifact digest.
scripts/copy_tracked_bundle.py admits only the explicitly selected regular
files recorded in the Git index. It verifies a clean worktree, rejects
symlinks/submodules and unsafe destinations, compares each worktree file with
its reviewed Git blob, and writes the immutable blob bytes. Ignored bytecode,
editor state, credentials, and other checkout debris therefore cannot enter
the first-party portion of the ZIP.
The per-caller limiter and the spend cutoff need three operator actions, in this order. Run them from a clean checkout of the merged release.
1. Deploy the rider, allowing the IAM change. The execution role gains
dynamodb:UpdateItem and dynamodb:GetItem on one table. That is a
shared-IAM edit, which an alias rollback cannot undo, so deploy.sh refuses it
until you say you have reviewed it. A plain ./infra/deploy.sh will fail
with shared IAM policy drift detected until you pass the flag once:
make verify
FPA_ALLOW_SHARED_IAM_CHANGE=1 AWS_REGION=us-west-2 ./infra/deploy.shSubsequent releases need no flag. This run also creates the DynamoDB table
fare-policy-assistant-demo-limits (on-demand billing, TTL on expires_at)
and adds FPA_RATE_LIMIT_TABLE and a generated FPA_RATE_LIMIT_HMAC_KEY to
the function environment. The deploy credentials additionally need
dynamodb:CreateTable, dynamodb:DescribeTable, dynamodb:DescribeTimeToLive,
dynamodb:UpdateTimeToLive, and dynamodb:TagResource.
2. Deploy the spend breaker.
AWS_REGION=us-west-2 ./infra/deploy-cutoff.sh3. Scope the budget. See "AWS Budget, scoped to this project" below. The
existing fare-demo budget has no CostFilters, so it currently watches the
whole account.
Real figures for us-west-2, not "minimal". Sources are linked in ADR 0025.
| Item | List price | Source |
|---|---|---|
CloudWatch alarm on EstimatedModelCostUsd (1 standard-resolution alarm) |
$0.10 / month | $0.10 per standard-resolution alarm metric |
| DynamoDB writes, at 10,000 limited requests a month | $0.006 / month | $0.625 per million write units; one UpdateItem under 1 KB is 1 unit |
| DynamoDB reads for the breaker check, at 20,000 reads a month | $0.002 / month | $0.125 per million read units; an eventually-consistent GetItem under 4 KB is 0.5 units, and it is cached 30s per container |
| DynamoDB storage | $0.00 | items are a few dozen bytes and expire in about two minutes; the first 25 GB-month is free |
| Breaker Lambda | under $0.01 / month | $0.20 per million requests and $0.0000133334 per GB-second on arm64; it runs only when a cutoff signal fires |
| SNS delivery to Lambda | $0.00 | AWS does not charge for SNS deliveries to Lambda, and the first million requests a month are free |
Total: about $0.12 a month at list price, and in practice closer to $0.02, because CloudWatch's free tier covers 10 standard-resolution alarm metrics and this deployment would have seven. The figure is insensitive to traffic at this scale: ten times the request volume adds under a cent.
The alternative, one AWS WAF web ACL with one rate-based rule, is $6.00 a month before the first request ($5.00 per web ACL plus $1.00 per rule in us-west-2), which is about 30% of the project's $20 monthly budget. It also cannot be attached to an HTTP API at all without first adding CloudFront or migrating to a REST API. That is the reason for this design; ADR 0025 records the comparison and the sources for every figure above (verified against the AWS Price List API for us-west-2, August 2026).
Deleting the table or the breaker stack does not break the service. The limiter fails open and the rider returns to its previous behaviour.
Run the full verification gate, merge the reviewed release, switch to a clean default branch, and deploy:
make verify
AWS_REGION=us-west-2 ./infra/deploy.shThe script always refuses a dirty worktree. There is no emergency bypass: identity-bearing artifacts may name a source revision only when every tracked and untracked release input is clean. An emergency build must first be committed and reviewed so the deployed bytes remain attributable.
The one-time transition from a pre-identity production version requires the operator to name that exact observed numeric baseline:
FPA_LEGACY_IDENTITY_ROLLBACK_VERSION=<observed-version> \
AWS_REGION=us-west-2 ./infra/deploy.shKeep the same variable on an emergency rollback only while rollback still
targets that legacy version. After the next successful release advances
rollback to an identity-bearing version, omit it; the legacy exception is not
a standing compatibility mode.
The first run against the historical deployment performs a safe bootstrap
before touching $LATEST: it publishes the exact current production state,
checks that numbered version, creates live and rollback, grants API Gateway
permission on live, and updates the existing managed integration. The old
unqualified permission is removed only after the qualified route passes and
pre/post-cutover checks prove $LATEST did not change during the migration.
Later runs leave the API integration unchanged.
A routine release:
- inherits operator-owned environment values from the current
liveversion; - verifies that unmanaged
$LATESTconfiguration still matches immutablelive, then stages code and managed configuration; - checks the staged code against the locally built ZIP hash and the complete intended managed configuration, then publishes or reuses an exact numbered snapshot;
- freezes its runtime patch mode at
FunctionUpdate; - runs
infra/check-lambda-version.shagainst that number, including one paid cache-bypassing answer whose actual JSON log tail must satisfy the privacy, correlation, token, cost, and duration contract; - retains the old target under
rollback; - moves
livewith a revision guard; and - restores the old target and prior rollback pointer automatically if the
public assistant smoke fails or the process receives
EXIT,INT, orTERMbefore verification completes.
Layers, VPC/DLQ/tracing/KMS/EFS settings, ephemeral storage, SnapStart, and
unknown future Lambda configuration fields are release-reviewed state.
deploy.sh does not inherit changes to them from $LATEST; drift blocks the
release until it is reconciled with the immutable live version. Logging is
also release-reviewed, but is intentionally managed to exact JSON/INFO/WARN
settings under ADR 0019 rather than inherited.
The initial live alias revision is also held as a release-wide baseline, so
a concurrent promotion aborts this deployment instead of mixing settings from
two releases.
The direct health check includes a paid Bedrock answer, so run deployments
sequentially and away from a scheduled demo. It shares the function's reserved
concurrency of two with public traffic. Both the direct check and public smoke
require yolobus-fares by default. Their
--expected-disabled-docs option accepts a reviewed comma-separated list;
passing an explicit empty string requires no disabled document and omits the
Yolobus refusal probe.
For an operator-initiated rollback:
AWS_REGION=us-west-2 ./infra/rollback.shThe command rejects $LATEST, weighted aliases, and any API shape other than
one integration targeting the qualified live alias. It verifies the retained
target's corpus pin, disabled-document state, runtime mode, PII refusal, and
paid answer before moving live, then rechecks the integration and runs the
public assistant smoke. The full operation is measured against the 15-minute
recovery objective. A single deadline bounds AWS calls, direct invocation, curl
retries, and the overall command; the public verification window reserves up
to 60 seconds inside that deadline for the guarded restore. If smoke or final
verification fails, times out, or the process is interrupted, the
revision-guarded exit handler restores the displaced version without
overwriting a concurrent alias change.
By default the retained version must still contain yolobus-fares in its
disabled-document list. Set FPA_REQUIRED_DISABLED_DOC_IDS to a comma-separated
reviewed list; setting it explicitly to the empty string means no document ID
is required.
Both aliases must target numbered versions and must not use weighted routing. Never delete either target. A retained version is not safe forever: policy expiry or withdrawal can make an old artifact unsuitable even when it still returns HTTP 200.
Lambda execution-role policy changes are shared infrastructure and cannot be
rolled back by moving an alias. deploy.sh refuses drift from its expected
inline policy. After separate review, set
FPA_ALLOW_SHARED_IAM_CHANGE=1; the script applies the policy, smokes the
existing live release, and restores the old policy on failure.
FunctionUpdate keeps each published version on the runtime patch it was
tested with. This transfers responsibility for receiving Lambda runtime
security patches to the release process. Redeploy regularly and whenever AWS
publishes a relevant Python runtime update.
The Lambda uses advanced JSON logging with application INFO, system WARN,
and 14-day retention. Fixed-schema records expose anonymous invocation
correlation, answer/model duration, canonical provider/model/token fields, and
token-derived estimated cost. They never contain questions, answers, prompts,
history, citations, request headers, IPs, user agents, exception messages, or
stacks.
The deploy creates CloudWatch alarms for handler errors, Lambda errors and
throttles, p99 Lambda latency, unpriced model completions, and a call surge. It
wires them to fare-policy-assistant-demo-alerts. Ten filters cover the
structured metrics plus one-release legacy rollback compatibility. Deployment
re-reads every filter contract, then tests the relevant patterns against the
numeric candidate's actual captured model/answer events before promotion.
To actually be paged, subscribe an endpoint once:
aws sns subscribe --topic-arn <printed by deploy.sh> \
--protocol email --notification-endpoint you@example.comDeployment warns if the topic has no confirmed subscriber. It also creates (or
overwrites) the fare-policy-assistant-demo dashboard with application-
estimated model cost and call counts, 5-minute traffic, request/model/Lambda
duration, and alarm status. deploy.sh prints its console URL at the end.
EstimatedModelCostUsd comes from observed tokens and the pinned application
price table. It is not an AWS bill. Unknown prices produce
UnpricedModelCalls, never a misleading zero-cost sample.
Constraints inherited from the rest of the repo: no user query persistence (the handler logs counts and timings, never content; 14-day retention), pinned model versions, and the deployed corpus is the committed snapshot set.
The gateway throttle above is aggregate. One actor sustaining two requests per
second occupies the whole public allowance and every real rider gets 429, at no
cost to the actor. web/ratelimit.py adds the per-caller layer: a fixed-window
counter in DynamoDB, on separate quotas for /api/ask (10 per 60s) and
/api/feedback (20 per 60s). Quotas are release inputs in
src/assistant/config.py, so changing one is a reviewed release with a new
config version, not a console edit.
deploy.sh creates the table (<function-name>-limits, on-demand billing, TTL
on expires_at) and passes its name and a caller-digest secret to the function.
Both are created idempotently; nothing about the table is release state, and
deleting it degrades the service to its pre-ADR-0025 posture rather than
breaking it.
The counter key is not an IP address. It is
HMAC-SHA256(secret, schema || window || route || address) truncated to 128
bits. The address is hashed and dropped inside the request; it is never logged,
returned, or stored. The window index is inside the hash, so the key rotates
every 60 seconds and two windows of one rider cannot be linked. Rotating
FPA_RATE_LIMIT_HMAC_KEY makes every stored row permanently unlinkable to any
address, at the cost of one abandoned window. ADR 0025 states plainly what this
costs relative to the previous "nothing derived from a request is persisted"
posture, and docs/dpia.md carries it as processing.
The limiter fails open. If the table is unreachable, missing, or the secret is
unset, requests are admitted and rate_limit_unavailable is logged. A DynamoDB
blip must not become a rider-facing outage, and the pre-existing gateway
throttle, reserved concurrency, and in-process budget all still apply.
An alarm pages someone. This actually stops spend. infra/deploy-cutoff.sh
deploys a second, tiny Lambda that writes one well-known row into the limiter
table; the rider function reads that row (cached 30s per container) and stops
making new model calls while every route that needs no model keeps serving.
A tripped breaker degrades to /offline and /guide, not to an error
page. /, /offline, /guide, /embed, and /version keep working, answers
already in the container cache are still returned, and only a new model call is
refused, with a 503 naming the two offline routes. Those pages cover the same
published policies, so a rider still gets an answer.
Two signals reach the breaker's topic:
- a CloudWatch alarm on
EstimatedModelCostUsd, this deployment's own token-derived cost estimate. This is the fast path, landing within one alarm period (default 15 minutes); - the tag-scoped AWS Budget below, which is billing-authoritative but refreshes about three times a day and lags real usage by 8 to 12 hours.
Deploy it once, after deploy.sh has created the table:
AWS_REGION=us-west-2 ./infra/deploy-cutoff.shTune the trip point with FPA_CUTOFF_THRESHOLD_USD (default 0.50) and
FPA_CUTOFF_PERIOD_SECONDS (default 900). The default is about 104 answers in
a quarter hour at the measured $0.0048 per answer, which is far above real
portfolio traffic and would burn the $20 monthly budget in roughly ten hours if
sustained.
Nothing clears the breaker on its own. That is deliberate: an automatic reset the moment a window looked quiet is not a cutoff. Inspect and clear it by hand:
# why did it trip?
aws dynamodb get-item --region us-west-2 \
--table-name fare-policy-assistant-demo-limits \
--key '{"pk":{"S":"spend-breaker"}}'
# resume answering (riders recover within ~30s)
aws dynamodb delete-item --region us-west-2 \
--table-name fare-policy-assistant-demo-limits \
--key '{"pk":{"S":"spend-breaker"}}'
# trip it by hand, without waiting for the alarm
aws dynamodb put-item --region us-west-2 \
--table-name fare-policy-assistant-demo-limits \
--item '{"pk":{"S":"spend-breaker"},"open":{"BOOL":true},"reason":{"S":"manual"}}'The last resort, if the breaker itself cannot be reached, is still to take the function offline entirely. This stops the offline guide too, so prefer the breaker:
aws lambda put-function-concurrency --region us-west-2 \
--function-name fare-policy-assistant-demo --reserved-concurrent-executions 0AWS WAF cannot be attached to an API Gateway HTTP API at all; it supports REST APIs, CloudFront, ALB, and a few others, and this service is an HTTP API. Using it would require fronting the API with CloudFront or migrating to a REST API first. It also costs $5.00 per web ACL per month plus $1.00 per rule per month in us-west-2, so $6.00 a month before the first request: about 30% of this project's $20 monthly budget. ADR 0025 records the full comparison, including why REST API usage plans (which throttle per API key, and riders have none) and AWS Budgets actions (which cannot set Lambda concurrency, and lag 8 to 12 hours) were also rejected.
The billing-authoritative backstop. It needs billing permissions the deploy
role may lack, so it stays a one-time manual step. CostFilters scopes it to
the project cost-allocation tag the deploy scripts apply; without that it
watches every dollar in the account and will alarm on spend that has nothing to
do with this demo. Point its subscribers at the cutoff topic printed by
deploy-cutoff.sh so a breach trips the breaker as well as paging:
aws budgets create-budget --account-id <id> \
--budget '{
"BudgetName":"fare-demo",
"BudgetLimit":{"Amount":"20","Unit":"USD"},
"TimeUnit":"MONTHLY",
"BudgetType":"COST",
"CostFilters":{"TagKeyValue":["user:project$fare-assistant"]}
}' \
--notifications-with-subscribers '[
{"Notification":{"NotificationType":"ACTUAL","ComparisonOperator":"GREATER_THAN","Threshold":80,"ThresholdType":"PERCENTAGE"},
"Subscribers":[{"SubscriptionType":"SNS","Address":"arn:aws:sns:us-west-2:<id>:fare-policy-assistant-demo-spend-cutoff"}]},
{"Notification":{"NotificationType":"FORECASTED","ComparisonOperator":"GREATER_THAN","Threshold":100,"ThresholdType":"PERCENTAGE"},
"Subscribers":[{"SubscriptionType":"SNS","Address":"arn:aws:sns:us-west-2:<id>:fare-policy-assistant-demo-alerts"}]}
]'Actual spend over 80% trips the breaker. A forecast of 100% only pages, since
a forecast is not evidence that money has been spent. An existing fare-demo
budget is updated with aws budgets update-budget, not create-budget.
deploy-cutoff.sh already grants budgets.amazonaws.com permission to publish
to the cutoff topic. The -alerts topic used by the forecast notification above
is created by deploy.sh and does not carry that grant, so a forecast
notification sent there will be discarded unless you add it:
aws sns add-permission --region us-west-2 \
--topic-arn arn:aws:sns:us-west-2:<id>:fare-policy-assistant-demo-alerts \
--label AWSBudgets --aws-account-id budgets.amazonaws.com \
--action-name PublishOr point both notifications at the cutoff topic and subscribe your email there instead.
Every AWS resource the deploy scripts create carries project=fare-assistant.
project is the cost-allocation tag key activated in Cost Explorer; a resource
created without it lands in the account's untagged bucket, where no per-project
budget or report can see it.
The value is the portfolio project name, deliberately neither the repo name
(fare-policy-assistant) nor the function name (fare-policy-assistant-demo):
it is the key the budget and the cross-repo cost report group on, so it has to
survive a rename of either. tests/test_deploy_tagging.py guards that, and
guards that no resource silently drops back out of the tagged set.
Both scripts tag on create and re-apply the tag on every deploy. The re-apply is the part that matters in an account that has already been deployed to: create-time tags never reach a resource that already exists.
Tagged: the rider and console Lambda functions (tags cover every published
version), their IAM roles, their CloudWatch log groups, both HTTP APIs, the
-alerts SNS topic, and all six CloudWatch alarms. deploy.sh also tags the
per-caller limiter DynamoDB table; deploy-cutoff.sh tags the breaker
function, its role and log group, the -spend-cutoff SNS topic, and the
model-spend alarm.
Untaggable — AWS accepts no tags on these, and none of them bills
separately from a tagged parent: CloudWatch metric filters, the CloudWatch
dashboard, Lambda aliases and published versions, the API $default stage and
route, and inline IAM role policies.
Outside the scripts, so not tagged by them: the fare-demo budget and the
console's SSM token parameter, both of which are documented one-time manual
steps above. Tag those by hand if you want them attributed.
The deploy credentials need tag permissions for this to take effect —
lambda:TagResource, iam:TagRole, logs:TagResource, sns:TagResource,
cloudwatch:TagResource, dynamodb:TagResource, and apigateway:POST on
/tags/*. If any are
missing the deploy still succeeds (the service is live and verified before the
tagging runs) but prints a WARNING naming each resource left untagged.
deploy-console.sh deploys a second, separate Lambda + API Gateway route: the
agency operator console (web/console.py). It is currently a read-only view of
the immutable live alias, corpus history/diffs, and evaluation evidence. It
never shares code, a Lambda, or an IAM role with the rider-facing deploy above;
its role holds only lambda:GetAlias / lambda:GetFunctionConfiguration
scoped to the one rider function and its live alias.
The historical pin and embed-setting POST routes now return 409 Conflict.
Published Lambda versions cannot be edited, so reporting success after changing
unqualified $LATEST would be false. Re-enable those controls only after a
durable approval store and promotion workflow can turn an operator request into
a reviewed immutable release.
FPA_RIDER_FUNCTION_NAME=fare-policy-assistant-demo \
FPA_CONSOLE_TOKEN_PARAMETER_NAME=/fare-policy-assistant/demo-console-token \
AWS_REGION=us-west-2 ./infra/deploy-console.shAuthentication is the operator's job to finish, not this script's. Out of
the box the console is gated by a shared bearer token
stored as an encrypted SSM parameter — the handler (web/console.py) fails
closed if it cannot resolve that parameter, but a shared token is not identity.
Before handing the
console URL to a non-technical agency operator, put a real authorizer (JWT or
IAM, backed by the agency's own SSO/IdP) in front of the console's API Gateway
route; the exact aws apigatewayv2 create-authorizer invocation depends on
that agency's identity provider, so it is documented as a one-time manual step
in deploy-console.sh's header comment rather than automated here, the same
way the AWS Budget setup above is manual.
The console's corpus changelog is a static file
(corpus/version_history.json, make history), not a live git query: the
standard Lambda Python runtime has no git binary, and reading committed
history at request time would mean bundling a full .git directory for no
good reason. deploy-console.sh regenerates and bundles it fresh on every
deploy.