site #
The did.bot website: marketing pages, the prose documentation, and the Rust API reference, built into one static tree by Astro.
What lives where #
src/pages/index.astro— the landing page: a scroll-driven, deterministic 2-D Ising-model simulation that crystallises into the "did.bot" wordmark. This is the one page on the site where the full, live simulation runs — see "The landing page's simulation" below, and "How the Ising direction was chosen" for why this direction and not one of the five others tried.src/pages/features/,src/pages/architecture/,src/pages/about/— the three sections that have subpages. Each is exactly two routes,index.astroand[slug].astro, both reading one data file; the pages underneath are whatever that file's list holds, so adding one is a data edit and never a routing edit. See "One file per page" below.src/pages/*.astro(the rest) — get started, privacy, terms, 404.src/config/nav.ts— the header and footer nav, as data. This is the one place to add, remove or reorder a header or footer link —Header.astro,Footer.astroand the landing page's own header/footer all render fromheaderNav/footerNavhere, so a link list never needs editing in more than one place.src/config/anim.ts— every landing-page animation parameter that is not part of the physics schedule itself (lattice size, camera/zoom pacing, the reveal band's position in scroll space, mesh/caption timing, the free-run threshold). This is the file to open to tune the simulation without touching Rust or the?tune=1panel. The schedule itself (temperature, field strength/ramp, sweep count) is not here — see the next section for why, and where it actually lives.src/pages/docs/— renders the repository's../docs/*.mdat/docs/. The markdown itself is not here:scripts/prepare-docs.mjscopies it from../docs/into the gitignoredsrc/content/docs/, rewriting its relative links so they resolve on the built site (see that script's own comments for the rewriting rules). Never hand-edit anything undersrc/content/docs/— it is regenerated on every build and dev run and the edit will vanish./api/— not built by this project at all.../scripts/build-site.shcopiescargo doc's owntarget/doc/output here after the Astro build. There is no Astro page for it;site/tests/check-links.mjsonly checks that the mount point itself is reachable, not rustdoc's own internal links.src/data/*.ts— the marketing copy, one file per page or section:features.ts,architecture.ts,about.ts,get-started.ts,privacy.ts,terms.ts,not-found.ts. Each is the only place its page's words live.section.tsholds the shape the three sectioned ones share andfiller.tsthePH()marker for copy nobody has written yet — see "One file per page" below.meta.tsholds the two strings no page has of its own: the fallback description and what the social card depicts.src/data/landing-beats.ts— the landing page's copy: its beats (hero, the long-dwell captions, the post-reveal footer line) as an ordered list. This is the one place to add, remove, reorder or reword a landing-page beat: the page renders the list, and the scroller's height and each beat's position down it are derived from the list's length.src/data/testimonials.ts— provisional real copy: genuine, attributable quotations (three testimonials, one each for Opus, Fable and Haiku) sitting in slots expected to be refilled later. See "One file per page" below for how this differs from an invented placeholder and why that difference matters.crates/didbot-site-anim(at the repo root, not undersite/) — the landing page's simulation engine (the Ising lattice, the deterministic checkpoint-and-replay anneal, free-run), in Rust compiled to wasm by../scripts/build-wasm.sh. Ordinarycargo test -p didbot-site-animcovers the simulation itself, no browser needed.crates/didbot-brand(also at the repo root) — the brand images, built by annealing that same engine and writing the result intopublic/. See "The brand images" below.
The landing page's simulation #
src/pages/index.astro is not built like the other pages: it is a complete,
self-contained HTML document with its own <style> and <script type="module">, not a component tree under BaseLayout, because the
simulation owns the whole viewport (a fixed <canvas> behind everything
else) for the length of the scroller (whose height follows from how many
beats src/data/landing-beats.ts holds). Two things are deliberately split
across languages:
- The anneal schedule (
t_high,t_low,field_start,field_ramp,field_strength,anneal_temperature, and the total sweep count) is Rust's alone:crates/didbot-site-anim/src/anneal.rs'sAnnealSchedule::default()andDEFAULT_TOTAL_SWEEPS. The page reads these seven numbers from the wasm module at startup (default_anneal_schedule(), called throughanim.ts'sloadDefaultSchedule) rather than hand-copying them into TypeScript — that hand-copy used to be a real bug (the two copies drifted), and a nativecargo test(default_anneal_schedule_values_are_the_schedule_and_the_sweep_count) now asserts the wasm binding is nothing but a forward of the same Rust function the crystallisation property test itself calls, so it is structurally impossible to change one without the other. - Everything else — lattice size, camera pacing, the reveal band's
scroll-space position, mesh/caption timing, the free-run threshold — is
page composition, not physics, and lives in
src/config/anim.ts.
To try a new value: open the page with ?tune=1 on the dev server (e.g.
http://localhost:4321/?tune=1) for a panel with a slider per
schedule parameter (plus reveal timing, easing, and the free-run threshold),
each with a one-sentence description of what it visibly changes, its own
reset-to-default button, and a live "crystallisation agreement" readout (the
same measure cargo test's property test checks). The panel's copyable
settings-line at the bottom is how a value found there gets reported back —
turn it into an edit to anim.ts (the non-schedule numbers) or
anneal.rs's AnnealSchedule::default() (if it should become the actual
shipped default; re-run cargo test -p didbot-site-anim afterward — see
"the crystallisation threshold" below).
The panel lives in src/components/TunePanel.astro, and index.astro
renders it and wires it up only under import.meta.env.DEV — so ?tune=1
is a dev-server address, and neither the panel nor its readouts are in the
tree scripts/publish-site.sh uploads. site/tests/check-dev-surface.mjs
is the check that keeps it that way.
The crystallisation threshold. the_lattice_reliably_crystallises_into_did_bot
(in anneal.rs) requires the anneal's final state to agree with the
did.bot mask on at least 93% of lattice sites, for every one of twelve
seeds. That 93% is not a physical constant — it is calibrated to the
current shipped schedule plus a small margin, purely to catch a future
regression, not to certify "93% is legible." If you change the schedule and
this test fails, the options are: tune further until it passes at the
current threshold, or — if the new schedule is one the owner actually wants
and it's only just below the line — recalibrate the threshold the same way
this round did, documenting the new number's real per-seed measurements in
the constant's own doc comment, not just lowering it quietly.
Drift and free-run. Deterministic replay is a pure function of scroll
position, which means a page nobody is scrolling does not move. So when
scrolling stops, the frame is handed to a separate scratch lattice
(crates/didbot-site-anim/src/anneal.rs's FreeRunLattice, spawned by
spawn_free_run_at) seeded from the sweep the reader stopped on and held at
that sweep's own temperature and field strength, and it keeps sweeping
there. That happens animConfig.idleDriftDelayMs after the last scroll
event anywhere on the page, and immediately past
animConfig.freeRunThreshold of scroll (fraction of the scroller's own
height, not the whole page), where no further scroll is coming — the end
state churns while the field holds the letters, exactly as it always has.
The sweep rate eases up from a standstill over animConfig.driftRampMs
rather than switching on, which is what makes this read as the lattice
still being alive instead of a glitch; that ramp is also why the delay
before it is only a couple of frames, since a drift that starts and is
immediately cancelled by the next flick of a scroll wheel never moved
anything.
The scratch lattice is a copy, so the next scroll event discards it and
resumes deterministic replay exactly where it left off, however long it
drifted; drifting_from_a_mid_scroll_sweep_moves_without_disturbing_the_replay
is the test for that at an arbitrary sweep, alongside the end-state one.
Drift never engages under prefers-reduced-motion, and pauses while the tab
is hidden. Under that setting the page holds one still frame at
animConfig.reducedMotionFrac and scrolling does not move the lattice; the
query is read again whenever it changes, so turning the setting on stops a
page that is already open.
free_running_holds_the_wordmark_legible_over_ten_thousand_sweeps
is the test that matters here: it samples agreement repeatedly across ten
thousand free sweeps rather than only checking one endpoint, because a
schedule whose field is just strong enough to reach the mask once but too
weak to hold it would pass every other test and still dissolve into noise
after a reader leaves the tab open.
How the Ising direction was chosen #
The landing page went through six built, working visual directions before
this one shipped. All six shared one engine crate
(crates/didbot-site-anim) behind the same rlib/cdylib split; a
direction being cut never meant deleting its engine, since each is real,
tested code and one (wave.rs) already has a second consumer outside this
site.
| direction | palette | idea | outcome |
|---|---|---|---|
Ising lattice (ising.rs, anneal.rs) |
black/green | 2-D Metropolis lattice; scroll drives temperature down through a magnetic field biased toward the wordmark, so the field fills the screen at every scale before crystallising into "did.bot" | shipped, this page |
| Multi-scale automaton | black/green | an elementary CA (ca.rs) resolving into a force-directed graph |
superseded by the Ising rebuild — the CA "grows a triangle, never fills the screen," and its graph was a second, unrelated simulation drawn beside the field rather than derived from it |
| Moire instrument | greyscale | summed travelling plane waves (wave.rs) |
kept as a live direction through several rounds, ultimately not the one promoted; the engine has a second consumer today |
| Field/instrument | white/black | a readout-box layout | retired as a direction; its layout ideas live on in the shipped page |
| Bulletin | greyscale | a reusable interior-page shell | not carried forward — no shipped page uses it currently |
| Terminal takeover | black/green | terminal-emulator chrome (life.rs, Game of Life gliders) |
cut — "the terminal-isms are just not good enough" |
| Specimen plate | white/black | reaction-diffusion texture (rd.rs), including a real ~7.8x perf fix (removing two full-grid allocations per step) |
cut on visual grounds, not performance; the fix and the engine are kept |
The Ising direction won because it fills the screen by construction (every
lattice site holds a spin, unlike the CA), because the wordmark reveal is
one field derived from the simulation rather than a second unrelated one
drawn beside it, and because temperature-as-scroll gives it a legible
throughline: uncorrelated noise at high temperature, multi-scale domain
structure near the critical point (~2.269 in this model's units), large
ordered domains once cooled. value noise (noise.rs) was a candidate
engine for the retired Field/Instrument direction and is kept the same way:
tested, unused today, cheap to keep.
Tuning history, in brief. The shipped AnnealSchedule::default() went
through several owner-driven rounds of hand-tuning via the ?tune=1 panel
(see below); the crystallisation property test's pass threshold was
recalibrated once, from 95% to 93%, when a schedule the owner explicitly
chose measured a real (not noisy) worst case of 94.79% — documented in both
the schedule's and the test's own doc comments as a regression catch, not a
legibility certification. A separate, permanent product decision came out
of the same period: motion safety is requestAnimationFrame at native rate
plus the standard prefers-reduced-motion query, full stop — no bespoke
per-frame luminance-budget instrumentation anywhere in this codebase.
Without the simulation. The document places every beat, one per
viewport down the scroller, and the simulation only paints behind them. So a
blocked or failed wasm fetch, or prefers-reduced-motion, leaves the page
readable end to end, and the footer beat's two calls to action are simply on
it rather than waiting on a reveal that is not coming.
site/tests/check-wasm.mjs checks that the served markup carries those
links and that no beat ships styled transparent.
Where the copy lives. The landing page prints no hand-written string
of its own: every beat comes from
src/data/landing-beats.ts, which is the
owner's copy to edit freely. Nothing validates that text against a second
list, so there is never a second place to update.
The brand images #
Everything under public/brand/, plus public/favicon.ico,
public/favicon.svg and public/apple-touch-icon.png, is generated —
crates/didbot-brand, at the repository root — and committed. Do not edit
one by hand; the next rebuild overwrites it and CI fails in between.
Each file is a snap of the same simulation this site's landing page runs:
the Ising lattice annealed by AnnealSchedule::default() for
DEFAULT_TOTAL_SWEEPS, stopped at the end of the schedule, painted one
block a lattice site in --ground and --accent. So the logo is the frame
the page ends on rather than a picture of it, and changing the schedule
changes the images.
scripts/build-brand.sh # rewrite them
scripts/build-brand.sh --check # what scripts/ci.sh runs; fails if stale
Both run cargo run -p didbot-brand --example render: the generator is an
example in that crate, run from a checkout, and nothing installs it.
The icons are their own marks, not the wordmark shrunk, and they are a size ladder rather than one mark scaled: a lattice does not survive being made small, so each tier is the largest thing its tile can actually hold. The wordmark's "did.bot" appears only on the wordmark and the social card — an icon is a "d".
| tile | mark | why not the tier above |
|---|---|---|
64 sites, painted at 1 to 8 pixels a site (icon-64/128/256/512, apple-touch-icon, the round icons) |
a "d" cut out of a disc, three specks in the ring around it | — |
48 sites (icon-48) |
the same badge, same proportions | 64 does not divide 48 |
16 sites at 1 and 2 pixels a site (icon-16, icon-32) |
the dot | a hole needs sites too: a "d" cut small enough to leave the specks their ring has a two-site stroke here, and surface tension closes its counter |
The big tiles are the 64-site badge magnified, not a finer lattice: the
stray lit sites an anneal leaves scale with the site count, so a 256-site
version of this is not a crisper icon, it is a dusty one. Every raster size
is a whole multiple of its own lattice, so no brand image is ever a
resampled picture of a lattice, and favicon.ico holds the three small
tiles, one PNG an entry, each from the mark that size is drawn in.
Everything an icon draws stays inside the tile's inscribed circle
(mark::SAFE_RADIUS), because an uploader that shows a square icon in a
round frame crops to exactly that. It is why a round icon here is the same
mark as the square one with a different cut rather than a mark of its own,
and every_icon_keeps_its_ink_inside_the_safe_circle is the test that
keeps it true — measured from each lit site's far corner, since a crop cuts
pixels rather than sites.
The three specks in the ring around every icon are the one deliberate
mark in the set: pinned sites (IsingModel::pin_spin) with a small island
of field around them, so the lattice grows its own shape on each. At 16
sites that is a single pixel — where they came from, having fallen out of
the first anneal that shipped — and on a bigger tile the island is bigger
and the anneal makes it irregular. Their ring is narrow and both edges are
hard: outside it the round crop cuts them, inside it they merge with the
badge, which is also what fixes how large the badge can be.
every_speck_survives_the_anneal is the test that they are still there
afterwards; without the pin the field alone cannot hold anything that small
and they vanish.
To change the set — a size, a shape, a mark — edit ASSETS in
crates/didbot-brand/src/lib.rs and the marks in its mark.rs, then
rebuild. That list is the inventory; nothing here holds a second copy of
it. The <head> tags that point at these files are
src/components/BrandHead.astro, which both layouts and the landing page
include.
The atmosphere pages #
Features, architecture, about — index pages and subpages alike — and 404
use AtmosphereLayout rather than the plain BaseLayout the docs,
/api/, get-started and the launch-required pages use. That layout adds the site's ambient texture —
QuietAtmosphere.astro (a static, CSS-only scanline+vignette echo of the
landing page's own register, cheap and non-live on purpose: the landing
page is the only place the full simulation runs), a context meter that
fills with scroll depth and quietly folds sections that have scrolled by
(ContextMeter.astro), and the build's own commit in margin space
(BuildStamp.astro, which reads one short id from git at build time and
renders nothing if git is unavailable rather than inventing a value — the
id and nothing else, since a commit subject is prose written for a reviewer
rather than for this origin). All of it degrades to nothing with JS off or
prefers-reduced-motion set — see each component's own comments. The docs
pages carry one small piece of this too: a hover-visible content hash and
git commit for the actual file each page renders (DocsLayout.astro), real
values computed by scripts/prepare-docs.mjs, never fabricated.
Every page — atmosphere or refuge — shares one palette and type scale
(src/styles/global.css's :root tokens), the same ones the landing page's
own <style> block uses. Adding a new subpage that inherits the theme
means picking one of the two layouts: AtmosphereLayout for a page that is
part of the marketing surface (gets the quiet scanline/context-meter
texture), or BaseLayout directly for a reference/legal/utility page that
should stay plain and fast (the refuge rule) — either way, import the
layout, wrap the page's content in it, and the tokens, header and footer
come for free.
Setup: one command to run and iterate #
From the repository root:
scripts/dev-site.sh # builds the landing page's wasm if missing, then astro dev
This is the command to reach for when changing copy, nav, or an animation
parameter and wanting to see it live: it builds
site/public/wasm/site-anim_bg.wasm if it does not already exist (the one
input astro dev's own watcher does not know how to produce), runs npm ci
if site/node_modules is missing, then starts astro dev at
http://localhost:4321/ (pass flags through, e.g. scripts/dev-site.sh --port 4322). Editing a page, src/config/*.ts, or src/data/*.ts shows
up on save through Astro's own hot reload; editing
crates/didbot-site-anim does not — the wasm build is not part of Astro's
watch graph, so re-run scripts/build-wasm.sh (or delete
site/public/wasm/ and re-run scripts/dev-site.sh) and reload the page.
Local dev has no /api/ — that only exists once something has copied
cargo doc's output into dist/api/, which is ../scripts/build-site.sh's
job, not this project's. Running cd site && npm ci && npm run dev directly
also works and is equivalent minus the wasm-build check.
Building and testing everything #
From the repository root, not from here:
scripts/build-site.sh # cargo doc + astro build + mount /api/ + site/tests
scripts/build-site.sh --skip-tests
scripts/publish-site.sh # build, upload a release, point did.bot at it
scripts/publish-site.sh --dry-run # build, then a plan of what that would do
See those scripts' own comments for what each step does and assumes. The
tests under site/tests/ (npm test, from inside site/) need the full
pipeline to have run first — several of them check the /api/ mount point,
which only exists once cargo doc's output has been copied in.
Two of those tests check the built tree against something outside it, because
neither half is checkable alone: check-serving.mjs runs
infra/site/viewer-request.js over the addresses the pages link to, and
check-csp.mjs reads the Content-Security-Policy from infra/site/main.tf
and holds the shipped scripts to it. The policy's script-src grants
'wasm-unsafe-eval' and not 'unsafe-eval', so nothing in the tree may build
JavaScript from a string — which is why the landing page loads its simulation
with a plain dynamic import() and not an eval'd one. A build that breaks
either pairing passes every other test here and then fails only in a reader's
browser.
One file per page #
Every page's copy lives in its own file under src/data/, and that file is
the only place its words appear. Rewording is an edit there and nowhere
else: the pages read from these files, nothing validates them against a
second list, and no test holds a copy of any string, so nothing goes stale
when a line changes.
| page | its copy |
|---|---|
/ |
landing-beats.ts (the owner's own copy already) |
/features/ and its subpages |
features.ts |
/architecture/ and its subpages |
architecture.ts |
/about/ and its subpages (including /about/contact/) |
about.ts |
/get-started/ |
get-started.ts |
/privacy/, /terms/ |
privacy.ts, terms.ts |
404 |
not-found.ts |
| the testimonials block | testimonials.ts |
Features, architecture and about are lists, not fixed layouts. Each of
those three data files exports one Section (src/data/section.ts): a
title, an intro, and an ordered list of subpages, each with a slug, title,
lede and body paragraphs. Adding a fourth feature means adding a fourth
entry to features.ts — the index page renders the list and [slug].astro
derives one route per entry from the same list, so nothing else learns
about it. Removing or reordering works the same way. SectionIndex.astro
and SubpageBody.astro are the shared markup, so a fourth section of this
kind is a data file plus two three-line routes.
architecture.ts is where properties of the system go; features.ts is
what a user gets. Same shape, different question.
Images. A subpage can carry one, next to its body:
import shot from "../assets/features/accounts.png";
...
body: ["..."],
image: { src: shot, alt: "what the picture shows" },
Drop the file under src/assets/<section>/, import it at the top of the
data file, and hand the import — not a path string — to image.src.
Importing is what lets astro:assets hash the file, emit a responsive
srcset in webp, and stamp the real width/height on the tag so the
page does not reflow when the picture lands; it also turns a misspelled
filename into a build error rather than a broken image on the live site.
Any format astro:assets reads works (png, jpg, webp, avif, svg). Omit the
image key entirely and the subpage renders without one — nothing else
changes. about.ts uses the same Subpage type, so its entries can take
an image too.
The images seeded under src/assets/ today are generated placeholder
plates, one per subpage, marked ⟦slug⟧ on their face for the same reason the text filler
is: they are obviously not the real thing. Replace the file in place and
the import needs no edit.
CLAUDE.md's Copywriting section is why nearly every slot still holds filler
rather than copy: brand voice is the owner's call, not something an agent
decides while shipping infrastructure. Filler is invented text, obviously
so, wrapped in src/data/filler.ts's PH() helper, which renders it
inside a ⟦…⟧ marker (guillemet-and-bracket, chosen because nothing else
on the site uses it) so it is visible on the built page too, not only in
the source. To find what is still unwritten:
grep -rn 'PH(' site/src/data/ # in the source
grep -rn '⟦' site/dist # in a built page, to see it in context
Writing a real line means replacing the filler and dropping the PH()
wrapper with it. When a file's last PH() is gone, its import goes too;
when nothing imports filler.ts, delete it. Slugs are URL structure rather
than brand voice, so they are written plainly — but the filler ones
(apricity, nightjar, …) are placeholders like everything else and are
meant to be renamed with the copy.
The three testimonials in
src/data/testimonials.ts are a different
thing: real, attributable quotations sitting in slots the owner expects to
refill later, marked provisional: true (which
Testimonials.astro renders as data-provisional, so a copy pass can spot
them in a built page too). "Provisional" describes how long the slot
lasts, not what may go in it. That marker is never license to invent a
plausible-sounding line — any text that lands in these slots must be an
actual quotation from an actual, correctly-named entity. Each entry carries
a full field (the complete, verbatim quotation) and a quote field (a
short trim of it, elided with … for the page's layout, reachable in full
through the page's "read the full quote" disclosure); replacing one means
supplying a new full quote and choosing a fresh trim from it, never
hand-editing the trim in isolation, since on its own it is not a real thing
anyone said.
Pages under /docs/ and /api/ carry no filler at all: they render real,
already-reviewed content from elsewhere in the repository. Neither does the
landing page's ?tune=1 panel, whose per-slider descriptions are developer
documentation for whoever is tuning the simulation and which a production
build leaves out entirely.
What a link card shows #
Pasting an address from this site into a chat window renders a card, built
from the tags src/components/PageHead.astro writes into every page's
<head>: title, description, canonical address, the social card, and
twitter:card, which is the one thing the og: tags have no equivalent
for. Both layouts use that component, and so does the landing page, which
is its own document.
A page's description is the line it already shows. Nothing here is a
second sentence written about a page — a section hands its intro to the
layout, a subpage its lede, a /docs/ page the opening paragraph
scripts/prepare-docs.mjs flattens out of its own markdown. So a card is
never stale. A page with no line of its own, or whose line is still filler,
falls back to src/data/meta.ts's siteDescription,
which is the landing page's hero beat on one line — the owner's copy, and
the same text a scraper that ignores tags would take off the front page
anyway. describe in that file is where the fallback happens, and the
filler case is the one place a card and its page are allowed to differ: the
page shows ⟦…⟧ so the unwritten line is visible to whoever will write it,
while a card is read somewhere else by somebody who may never open the
page.
tests/check-meta.mjs holds it together: every authored page carries the
whole set, its og:title and og:description match its own title and
description rather than a second copy of them, its og:url and canonical
are the address the built tree actually serves it at, and its og:image is
an absolute address of a file this build ships, and no page's description
carries the filler marker. The 404 is the one exception it encodes — it
answers from every missing path, so it is noindex and names no canonical
address.
Building the landing page's wasm #
../scripts/build-wasm.sh (called by ../scripts/build-site.sh) compiles
crates/didbot-site-anim for the browser. It needs, on top of the Rust
toolchain the rest of the workspace already needs:
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version <version in Cargo.lock> --locked
The script checks both and fails with the exact command to run if either is
missing or the wasm-bindgen-cli version does not match Cargo.lock's
wasm-bindgen exactly (the crate and the CLI speak a private ABI to each
other). Those two are the whole toolchain, and neither is needed for anything
else in this workspace: cargo build --workspace and cargo test --workspace
never touch wasm32-unknown-unknown.
Which Cargo profile each module is built under is the script's own decision,
per crate — crates/didbot-site-anim under release and
crates/didbot-policy-check under the root Cargo.toml's wasm-release. The
script's case and that profile's comment carry the measurements behind each.
Where the built site is served from #
infra/site/ is the OpenTofu stack behind https://did.bot: a private S3
bucket, a CloudFront distribution in front of it, the ACM certificate that
distribution serves, and the records in the did.bot hosted zone — the apex
alias pair pointing at the distribution, and the _atproto TXT record that
lets did.bot resolve as an ATProto handle. That stack's own comments carry
the detail; three things about it shape this project:
- A publish is a pointer move.
scripts/publish-site.shuploads the build as an immutable tree unders3://did-bot-site/releases/<git sha>/and then appliesrelease_sha, which flips the distribution'sorigin_pathto it, waits for that config to reach every edge, and invalidates. A reader gets the old tree or the new one. Rolling back istofu -chdir=infra/site apply -var release_sha=<an earlier sha>, andtofu -chdir=infra/site output release_shasays what is live. - Pretty URLs are a CloudFront function, not the bucket. The bucket is
private behind an origin access control and answers by key alone, so
infra/site/viewer-request.jsis what resolves/features/tofeatures/index.htmland redirects/featuresto/features/. It pairs withastro.config.mjs'strailingSlash: "always"andbuild.format: "directory"— change either and read that function alongside it. 404.htmlis load-bearing. A private bucket answers a key outside the release tree with403, which the distribution maps to this page; the publish script checks the built tree carries one before uploading it.
aws and tofu are on PATH with working credentials for the account this
site deploys to (AWS_PROFILE=<your profile>); the script checks both before
it writes anything. infra/site/ reads its state bucket from backend.hcl, so
its tofu init takes -backend-config=backend.hcl. Astro's /_astro/ and
rustdoc's api/static.files/ carry a content hash in the filename and are
uploaded with a one-year immutable cache, and every other object takes the
distribution's caching, which each publish invalidates.