This is deployed. ADR 0003 records that exposing the ask service to families is a separate decision with its own consequences; the owner made it on 2026-08-22, and this directory is what was applied.
| Stack | homeroom-ask |
| Region | us-west-2 |
| Stack ARN | arn:aws:cloudformation:us-west-2:014248889144:stack/homeroom-ask/4c826de0-9e29-11f1-9e5f-0642c3e5a3a7 |
| Function URL | https://vlbgna342rtuvkikbuwuwgosuu0jgvdp.lambda-url.us-west-2.on.aws/ |
| Model | Bedrock global.anthropic.claude-sonnet-4-6 |
| Site origin | https://homeroom.chelseakr.com |
| Alarm topic | arn:aws:sns:us-west-2:014248889144:homeroom-ask-alarms (no subscriber yet) |
Parameters as applied: DeployFunction=true, SiteOrigin=https://homeroom.chelseakr.com,
Provider=bedrock, Model=global.anthropic.claude-sonnet-4-6,
ReservedConcurrency=2, DailyCap=400, PerMinute=6,
DailyInvocationAlarm=400, and BedrockModelArns set to exactly the
inference-profile ARN and the two foundation-model ARNs it routes to.
Two steps, in this order, so no page is ever left pointing at a service that is gone:
make publish ASK_ENDPOINT= # fails: the target requires an endpointis not the rollback. The rollback is to rebuild the site without one and to delete the stack:
uv run python -m homeroom.site --directory data/raw/pubschls.txt \
--enrollment data/raw/cdenroll2526.txt \
--absenteeism data/raw/chronicabsenteeism25.txt \
--cds 57726786056246 --out site --landing
echo homeroom.chelseakr.com > site/CNAME
# commit site/ -- the school pages return to byte-identical-to-pre-ADR-0003,
# the ask pages and the one link to them stop existing, and the pages are
# complete without them. Then, once that is published:
aws cloudformation delete-stack --stack-name homeroom-ask --region us-west-2Deleting the stack first would leave a live ask page whose every submission fails. The site is the thing families see, so the site changes first.
The stack's own code bucket is versioned and is deleted with the stack only if it is empty; emptying it is a deliberate step, because those objects are the exact packages that were served.
One AWS Lambda function (Python 3.12, arm64, 512 MB, 40 s) behind a Lambda
Function URL, AuthType: NONE, with CORS allowing exactly one origin (the
site's) and one method (POST). The function runs homeroom.ask.http.lambda_handler,
which is the same code the local server runs. The evidence bundle and the
corpus ship inside the package; no database, no storage, no account.
Cost is bounded three ways, because one is not enough:
- Reserved concurrency (default 2): at most two questions in flight across the whole function. This is the bound that actually holds under abuse; everything below is per container.
- The service's own limits: six requests per client per minute and a daily cap of 400 model calls (two per answered question) per warm container, both from the environment. A refused request costs nothing and returns a fixed string; the page is complete without it.
- A CloudWatch alarm on daily invocations, to an SNS topic if one is given. Pair it with an AWS Budgets alert on the account; budgets are account-level and not in this template.
The in-memory daily cap is per container, which is honest about what it is: a bound on what one running copy spends, not a ledger. With reserved concurrency 2 the worst case is two containers each spending their cap. A shared counter (DynamoDB, one item, conditional increment) is the upgrade if that matters.
Provider=bedrock is the keyless path and is what is deployed: the function's
IAM role is allowed bedrock:InvokeModel on BedrockModelArns, which has no
default on purpose and is set to exactly three ARNs -- the inference profile and
the two foundation models it routes to. A wildcard there would be a wider grant
than the one model this service runs.
The model id is whatever the account can invoke. This account could invoke
global.anthropic.claude-sonnet-4-6 and was refused Sonnet 5 and Opus 5, which
is settled and does not need re-probing: the availability API reports Sonnet 5
as AUTHORIZED for this account and InvokeModel still returns
AccessDeniedException. Believe the invoke, not the catalogue.
Two traps worth writing down, both of which cost a deploy cycle:
- Pass
BedrockModelArnsfrom a parameters file, not theKey=Valueshorthand. Escaping the commas as\,puts the backslashes into the ARNs. The stack deploys green, the role holds two malformed ARNs, and the only symptom is that every question comes backunavailablewithmodel_calls: 0, because the service maps a provider error to a fixed refusal and never logs the cause. Use--parameter-overrides file://params.jsonwith[{"ParameterKey": ..., "ParameterValue": ...}]. - The Function URL needs two grants on this account, not the one the
documentation implies. See the note in
template.yaml.
Provider=anthropic uses the public API with the code default
claude-sonnet-5. The key is a NoEcho parameter that lands in the function's
environment, encrypted at rest by Lambda; it is never written to this
repository, to a results file, or to a log. No ANTHROPIC_API_KEY existed in
the build environment on 2026-08-21.
The handler never logs a request body. The default access log is silenced in
the local server; in Lambda only the runtime's START/END/REPORT lines reach
CloudWatch, retained 14 days. This is why a failing provider call is diagnosed
by reading the IAM policy rather than a stack trace: the same silence that keeps
a family's question out of the logs keeps the operator's error out of them too,
and that trade is made deliberately in the reader's favour. The rate-limit key is a salted hash of the source
address with a per-process salt, never stored. The question and one school's
published records are sent to the model provider for the duration of the
request; that subprocessor relationship is recorded in
docs/RESPONSIBLE-TECH-AUDITS.md under Privacy (Amazon Bedrock, us-west-2,
owner-approved 2026-08-22). RR-07 to RR-09 in
docs/audits/residual-risk-register.md carry the rest.
The stack is two-phase so that everything, including the code bucket, lives inside it. Phase one already ran; a redeploy is phase two only.
make data # acquired files -> data/out
make ask-bundle # -> data/out/ask
./deploy/ask/build.sh # -> dist/ask-lambda.zip
aws s3 cp dist/ask-lambda.zip \
s3://homeroom-ask-code-014248889144/ask/$(git rev-parse --short HEAD).zip \
--region us-west-2
aws cloudformation deploy --template-file deploy/ask/template.yaml \
--stack-name homeroom-ask --region us-west-2 --capabilities CAPABILITY_IAM \
--parameter-overrides file://params.json # see the trap aboveThen re-publish the site so the pages and the service agree:
make publish ASK_ENDPOINT=https://vlbgna342rtuvkikbuwuwgosuu0jgvdp.lambda-url.us-west-2.on.awsand commit site/. Pushing to main publishes it after ci passes.
The stack's FunctionUrl output is what the site build takes as
--ask-endpoint; a build without it renders no ask page and no link, and is
byte-identical to a build before ADR 0003.
build.sh refuses to package a fixture bundle, installs the SDK's bedrock
extra (AnthropicBedrock signs through botocore; without it the function runs
on whatever boto3 the runtime happens to carry), and measures the unzipped
package against Lambda's limit: 231.7 MB, 92.7% of 250 MB. It fails rather
than shipping something over. That margin is what is left, not what was chosen:
a fourth data source does not fit, and the answer then is to move the bundle to
S3 and read one school per request -- a small change to
homeroom.ask.evidence.load_school -- rather than to drop schools or trim data.
- Nobody is subscribed to the alarm topic. The CloudWatch alarm at 400
daily invocations fires into
homeroom-ask-alarmsand reaches no one. An address is the owner's to give; subscribing needs no stack change. - No account budget. Budgets are account-level and touch other projects, so this stack does not create one. At Bedrock Sonnet 4.6 list prices and the measured token counts (roughly 1.5k uncached input, 7.8k cached, 500 output per answered question), 400 calls a day is on the order of a dollar a day; reserved concurrency 2 is the bound that actually holds under abuse.
- The daily cap is per warm container, not a shared ledger, so the worst case is two containers each spending it. A DynamoDB item with a conditional increment is the upgrade if that ever matters.
- A person reading a sample of real answers in each language (RR-07, RR-08). The evaluations are automated scorers; nobody has read the Spanish narration as a Spanish speaker.
- Issue #6: the keyboard and screen-reader walkthrough of the live ask page.
- Which model. The evaluation results in
evals/results/are for the model they name; deploying a different one means re-running them first. - Whether the change widens what leaves the reader's browser. The subprocessor
record in
docs/RESPONSIBLE-TECH-AUDITS.mdhas to move before the exposure does, not after.