How this site is built
- dbPass
- backupPass
- mediaPass
- ogPass
- kvPass
- cache-purgePass
- media-revisionsPass
- imagesPass
- browserPass
- webmention-verifyPass
- ask-budgetPass
- cronPass
- ask-index-driftPass
- media-index-driftPass
- content-driftPass
- procedures-driftPass
- dictionary-driftPass
- roster-driftPass
- phage-driftPass
- registry-driftPass
- pages-driftPass
- cv-driftPass
- cv-pdf-driftPass
- publications-driftPass
- llms-driftPass
- fts-equalityPass
This is the third version of this site. The first launched in 2006, the second, on WordPress, in 2016, and this one in 2026.
Most of this page is generated from the repository's configuration and checked on every build. The reasons behind each choice are written by hand.
- Callers
- Cloudflare edge
- Edge page cache
- Worker
- React Router
- Prelum, site-runtime, site-api
- Capsomer
- Bindings
- DataDB, ASK_BUDGET, APP_KV, BACKUPS, MEDIA, MEDIA_REVISIONS, OG
- Queuesdustinedwards-media-events, dustinedwards-webmention-verify, WEBMENTION_VERIFY
- AIAI_SEARCH
- Media and filesASSETS, IMAGES
- OtherBROWSER, CSP_REPORT_RATE_LIMIT, EMAIL, CF_VERSION_METADATA
The network behind this site
Other systems run behind or beside this site, and some packages are shared with my other sites. None is in this repository.
Capsid is the site's memory and job queue. It stores decisions and current state, and hands work from a conversation to a working session as a signed job that ends in a pull request.
A small admin server lets me run the site from a chat, covering the registry, citations and lab procedures. It cannot write or publish posts. Carrel does that.
Carrel is my private writing hub, behind a login, where articles are written before publication. A finished article reaches this site through a small keyed API.
Capsid's source is public; read about it on its page.
Capsomer is the shared design system: color tokens, header, footer and components, in plain CSS.
Prelum turns markdown into pages, feeds, sitemaps, search records, llms.txt and each page's structured data.
site-runtime supplies security headers, the rate limiter, the health report, responsive images, sign-in and the edge page cache.
site-api is the contract Carrel uses to list, edit and publish this site's content.
d1-dump backs up each table of the database to the site's own storage and restores it. The database's own export cannot handle tables with full-text search.
Enarratio renders charts on the server, each with a text alternative and a data table.
devkit holds the shared test workflows, the dependency update rules and the check that fails on a copy of code a shared package already provides.
What the site does
Each entry names an anchor, and a gate checks that the anchor still exists. The gate does not check that the sentence is true.
44 entries. What the whole network does is on the features page.
Content pipeline
D1 holds the source, and every read comes from it
D1's source_files table holds every file a save writes, keyed by repository path, and source_revisions holds each version. The repository copies are the seed and CI's test corpus. A deploy pulls D1's files over the checkout, builds from them and restores the checkout. Each rendered row records the blob sha of its source and a hash of its own render, so a ship compares both against a fresh build before writing and names every row that has drifted. A row whose source is unchanged but whose render differs fails the run after the deploy stands, because that is a pipeline defect, not staleness.
- route
/writing - gate
check:content
- route
Content pipeline
One renderer, two writers
The build scripts and the Worker import the same markdown pipeline module. There is deliberately no second renderer, because both writers render the same source independently, the Worker on every save and the build on every ship, and a difference between the two is invisible until something compares them.
- route
/writing/:slug - gate
check:content
- route
Content pipeline
Every post has a markdown twin
The same URL with .md appended returns the source the page was rendered from. Requesting the post with an Accept: text/markdown header returns the same bytes.
- route
/writing/:slug.md - gate
check:machine-readable
- route
Writing
Posts are written in Carrel, through one save
The admin has no post editor. Carrel writes posts through /api/carrel/v1, and the operator API calls the same save. A save validates the frontmatter, writes the file and its revision in one D1 batch, renders the rows and purges the cache by tag. Ask is updated last and cannot fail the save. The save names the blob sha it loaded, and a file changed since is refused, with the same check inside the batch.
- assertion in
check:worker:answers the key on its prefix
- gate
check:policy
- assertion in
Writing
Each caller has a fixed set of capabilities
One table in the publish policy lists what each caller may do. The admin may do everything. An operator may write but not publish a post for the first time or delete one. Carrel may write and publish for the first time but not delete. A refusal is a 403 that names the rule.
- route
/api/operator - gate
check:policy
- route
Writing
First publication is a fact in the file
A post's first_published date lives in its frontmatter, not in a database column, because rows are rebuilt from the source and a column would be dropped silently and fail open. The value is read only from the stored file and overwritten on the way out, so a caller cannot publish a draft by asserting the fact the gate checks.
- route
/api/operator - assertion in
check:policy:readState reads a previously published post
- route
Writing
Scheduled posts go live with no write
A per-minute cron puts schedules live. A post scheduled before it was ever live goes live by its publish_at with no write, and the cron announces it once, so IndexNow, the page purge and the outgoing webmentions all hear it.
- route
/webmention - gate
check:worker
- route
Charts
Charts are content, not a widget
A chart is authored as a container directive over a fenced block of CSV and rendered to SVG as the post is rendered, so it is part of the stored HTML and a reader with JavaScript disabled sees the same chart as everyone else. Colors are CSS custom properties that resolve per theme, which is why one stored SVG serves light and dark with nothing to flash.
- gate
check:content
- gate
Diagrams
Diagrams render ahead of time, one drawing per theme
A diagram needs real font metrics, so it cannot render in a Worker. One SVG per theme is produced ahead of time and the stylesheet shows one of them. The rendered post carries the key and the source rather than the drawing, so a font-rendering difference between two machines can never turn into a failed build. Alt text is mandatory.
- gate
check:diagrams - assertion in
check:diagrams:a diagram with no alt fails
- gate
Search
Two FTS5 indexes over one derived table, fused by rank
An FTS5 tokenizer is set per table, not per column, so one index cannot serve both exact-token lookup and prose relevance. There are two: unstemmed for titles and tags, Porter-stemmed for bodies. The two ranked lists are merged by reciprocal rank fusion, never by raw score, because scores from two tokenizers over two average document lengths are not comparable on value.
- route
/search
- route
Search
Results are section-grained
One post yields a document record plus one per heading, so a hit deep-links to the heading that answers it rather than to the top of the page.
- route
/search - gate
check:content
- route
Search
A filter with no text takes a different query path
A bare year or a tag chip has nothing to give FTS5, so those queries run the filter SQL straight over the derived table, date ordered, document records only. Returning every section of one post for a bare year would present the corpus at several times its real size. The snippet carries no highlight, because nothing was matched and marking anything would claim a match that never happened.
- route
/search
- route
Search
The same URL returns JSON under content negotiation
Requesting the search page with Accept: application/json returns the same query against the same index, with Vary: Accept. It is not a second API. Negotiation runs in middleware, because a document route's loader cannot return a raw Response.
- route
/search
- route
Ask
A streamed AI answer with citations that deep-link
Retrieval and generation sit strictly above classic search and never block it. Citations map back to heading anchors, and the answer is written with textContent rather than as HTML, because it is generated text and the one thing known about it is that we did not write it.
- route
/search/ask
- route
Ask
The two search layers fail on opposite inputs
Measured on the same corpus: classic keyword search found two things AI retrieval missed, both single tokens naming a heading almost verbatim, and AI retrieval found three that classic missed, all long natural-language questions. Neither layer subsumes the other, which is the case for keeping classic first and Ask on top rather than replacing one with the other.
- route
/search/ask - route
/search
- route
Ask
Three cost gates in front of the only metered endpoint
A per-IP burst limit, then an answer cache keyed by a hash of the normalized question, then a site-wide daily ceiling. The ordering is load-bearing: the ceiling sits after the cache, so a cache hit costs nothing and consumes no budget. A refusal is a 429 with Retry-After and reaches no model.
- route
/search/ask
- route
Ask
Drafts can never reach the AI index
The classic index filters drafts at query time. The AI index cannot, because it has no per-item status a query can filter on, so exclusion happens at upload time. A draft uploads nothing and actively removes anything the post already had, because skipping the upload alone would leak on the unpublish path: a post published, indexed, then withdrawn would stay answerable forever.
- gate
check:ask-guards - assertion in
check:ask-guards:a withdrawn post is not Ask-publishable
- gate
Rate limiting
Durable Objects, because the platform limiter does not count
Both cheaper mechanisms were built first and both leaked. Cloudflare's GA ratelimit binding, configured at five requests per sixty seconds and attacked with twelve concurrent requests, refused one, then two, then nine, then zero across four runs. It is documented as permissive and eventually consistent: it sheds sustained load rather than counting. A Durable Object using the asynchronous storage API allowed eight through a ceiling of three, because a read and a write spanning an await is not atomic even inside a single-threaded object. The synchronous SQLite API landed on its ceiling exactly, which is the whole reason the class is registered as a SQLite-backed one.
- route
/search/ask
- route
Media
R2 is the store, D1 is a rebuildable index
R2 holds the bytes and wins every conflict: a row with no object is deleted, an object with no row is backfilled, never the reverse. The index exists in D1 because R2 paginates in key order and promises nothing else, so sorting by date, filtering unused, searching by name and counting by type are each a query, and building those over a key-ordered iterator means listing the whole bucket per request. A capability decision, not a scale one.
- route
/media/*
- route
Media
Keys are content-addressed and carry their dimensions
A key is a truncated hash of the bytes plus the extension, which was decided on security rather than tidiness: the old scheme was derivable from a slug the sitemap publishes plus eight hex characters, so a draft's images were guessable. The key also carries the pixel dimensions, so the build and the Worker resolve image sizes from the string rather than from two different stores that could disagree.
- route
/media/* - assertion in
check:tests:the fixture produced both resolutions and refusals
- route
Media
Transforms are computed on request and never written back
One object per image, every size derived from it, which keeps the bucket free of variant sprawl and is why it needs no lifecycle rules. It uses the Images binding and not the URL transform syntax, and that is forced rather than preferred: the URL interface answers 404 on both of the site's addresses, with error 1042 on the workers.dev address (which has no zone) and a plain 404 on dustinedwards.info, so the binding is the only route to resizing. Thumbnails serve one format unconditionally, after a negotiated version turned out to poison a cache whose key carried no headers.
- route
/media/*
- route
Theming
The theme is resolved server-side, so nothing flashes
The choice lives in a cookie, is read in the root loader, and the correct attribute is written into the first byte of HTML. There is no inline script and nothing is corrected after paint. System preference is the absence of the attribute rather than a third value.
- route
/theme - gate
check:contrast
- route
Theming
The toggle works with scripting off
It is a real form posting to a route that sets the cookie and sends the reader back. The client enhancement only removes the round trip. It is deliberately not a radio group, because a real radiogroup owes arrow-key roving focus that cannot be delivered without script.
- route
/theme
- route
Theming
Every color pair is recomputed from the shipped stylesheet
The gate reads the token values back out of the stylesheet and recomputes the whole contrast matrix, so a tuned hex moves one side of the comparison and fails. The pairs and thresholds are transcribed as token names rather than values, so nothing in the gate restates a color.
- gate
check:contrast - assertion in
check:contrast:dark (prefers-color-scheme)
- gate
Brand mark
The mark is inline SVG
One component collapses the four SVG files into a path list and a viewBox, because the files differ in exactly two ways. The purple paths carry no fill and take a brand custom property instead, which is the entire dark-mode story for the mark.
- route
/
- route
Machine surfaces
Feeds, a sitemap, and a map of the site for agents
RSS and JSON feeds, a sitemap, robots, and a plain-text map of the site with a full-text companion carrying every published post in one document. ONE FEED POLICY: both feeds read the same query with the same visibility predicate and the same cap, and carry the whole post rather than a summary, differing only in representation because an RSS reader renders markup and a JSON consumer is usually a program. The map is served from a database row rather than from the file in the repository, and the inlined fallback is imported from that same file so the two cannot drift.
- route
/writing/rss.xml - route
/writing/feed.json - route
/sitemap.xml - route
/llms.txt - route
/llms-full.txt - assertion in
check:machine-readable:llms.txt is LF-only
- gate
check:tests
- route
Machine surfaces
Search is exposed over the Model Context Protocol
The same corpus is reachable as an MCP tool, so an agent can search the site without scraping it. It needs no authentication and returns the same section-grained keys the rest of the site uses. It shows the same retrieval characteristic as the AI answer layer: it finds passages that answer a question and misses short exact tokens the keyword index finds immediately, which is stated rather than hidden.
- route
/llms.txt - gate
check:machine-readable
- route
Backups
Per-table exports, because the platform export is broken here
The database export command fails outright on a schema containing FTS5 virtual tables and writes nothing, so backups are taken per table with the schema excluded, never touching the virtual tables or their shadow tables. The table list is derived from the migrations directory, and the check fails in both directions. It found a real omission on its first run.
- gate
check:backup - assertion in
check:backup:sqlite_master returned no tables
- gate
Backups
Counting an FTS5 index cannot detect that it is empty
A count on an external-content FTS5 table reads through to the content table, so it reports rows that the index itself no longer holds. Measured twice: with the index emptied, the count still read seven while the size shadow table read zero and matching returned nothing. An integrity check also passed on the broken index. The shadow table is what gets counted, and the repair for a corrupt index is a rebuild, never a delete.
- gate
check:backup
- gate
Safety rails
URL protocols are allowlisted in the schema as well as the renderer
The render-layer guard only walks nodes the body renderer produced, so frontmatter bypassed it entirely: a curated link field took a generic URL validator, which accepts javascript:, and rendered as a live public anchor. Both that field and the cover path now call the same predicate the renderer uses rather than reimplementing it. That is asserted against the source, not only through behavior, because the two come apart: the cover path spent a while refusing bad protocols as a side effect of a stricter rule about paths, which every behavioral case passed and no gate could see.
- gate
check:urls - assertion in
check:urls:frontmatter blocked cases were exercised
- assertion in
check:urls:schema: ${label} calls isAllowedUrl, not a reimplementation
- gate
Safety rails
Rules stated twice are checked against each other
Some rules cannot be collapsed into one function because they are expressed in two languages: the public visibility predicate exists once as a query-builder condition and once as a hand-written SQL string over a different table. Those are run against a fixture of post states and must admit the same rows. The same suite proves the column schema agrees between the schema module, the migrations and the deployed database.
- gate
check:tests - assertion in
check:tests:both predicates admit the same posts
- gate
Pages
Prose pages are rows, not deploys
The Research, Teaching and Software pages and About are markdown files saved into D1's pages table. The page, its markdown twin, its sitemap entry and its search records are drawn from that row at request time, so an edit goes live without a build or a deploy. A save runs the same validator CI runs.
- route
/about - gate
check:links
- route
Publications
Citations export in BibTeX, RIS and JSON
Each paper is a file named by its DOI and saved into D1, and its page is drawn from the row. The list exports as BibTeX, RIS and JSON, and each paper as BibTeX, RIS and markdown. The CV gives each paper copy buttons for its plain citation, BibTeX and RIS.
- route
/research/publications - route
/research/publications.bib
- route
Procedures
One format for protocols, recipes and computational procedures
A procedure is a file with a profile (protocol, recipe or computational), saved into D1 and drawn from the row. A procedure page can run as a checklist with a Done check and a note for each step, and can be kept on the device for offline use.
- route
/research/protocols - gate
check:protocols
- route
Systems map
The network is a map, not a list
/systems draws the network of systems. Pointing at a system lights the ones it uses and the ones that use it, and mutes the rest. Each system has its own page with its parts and flows, and every flow is an ordered list without script.
- route
/systems - gate
check:systems-map
- route
Error pages
Each error page carries a vintage comic ad
The not found, gone, forbidden and server error pages show a small comic-book ad at the foot, below the page's plain message and buttons. /ads is the gallery, and adding a folder under app/utility-ads adds an ad.
- route
/ads/:id? - gate
check:tests
- route
Reading aids
Posts add reading aids without script
A reading-progress bar (CSS only, where the browser has scroll-driven animations), a table of contents that follows the section on screen, copy buttons on code blocks, footnote previews, an image lightbox and click-to-copy heading links are each added to a page that is complete without them.
- route
/writing - gate
check:features
- route
Search
A command palette opens on / or Cmd-K
The header's search link becomes a modal palette with live results. Without script it is the search page.
- route
/search - gate
check:features
- route
Lab registry
A fact is stored once, in a record, and every page draws it from there
The lab's primers, host strains, reagents and equipment are records in the repository, one file each, and a protocol names them by id instead of repeating them. A primer's sequence is written in one file, and the protocol that uses it, the primer's own page, the printable bench sheet, the markdown copy and the search records all print that one sequence. A save recompiles every protocol that names the changed record, so no page is left reading an old value. The files are the source and the database holds a derived copy that the health checks compare against the files.
- route
/research/lab - route
/research/protocols - gate
check:content - gate
check:protocols
- route
Lab registry
Sizes, positions and melting temperatures are computed, never copied
A primer's length, GC content, reverse complement and melting temperature come from its sequence, with the conditions stated beside the figure and the figure called an estimate for comparing primers rather than an annealing temperature. Where a primer binds a named GenBank reference, and the size of the product a pair makes, are computed by the site's own tested placement code, and a mismatch is shown rather than absorbed. A reference is genomic unless a protocol amplifies cDNA. The phages isolated on a host strain are counted from the phage table by host, so a phage added there is counted on the strain's page with no edit to the strain.
- route
/research/lab - gate
check:content - gate
check:tests
- route
Lab registry
What no record states is said, with the reason, and listed
A fact that could not be found in a source the lab can read is written as missing with the reason (paywalled, not stated, waiting on a person), and the page shows it as not found with that reason instead of leaving a blank or filling it from memory. A sequence that differs from the one a paper prints is shown as different and reported, never corrected quietly. The reagents and the equipment are each one table, because the record behind a row has little to say beyond its supplier or maker, and each protocol keeps its own amounts, stocks and settings in its own table.
- route
/research/lab - gate
check:content
- route
This page
The stack half is generated
Every binding, migration, gate and dependency above is emitted from the repository's own configuration when the site is built. Nothing in the generator or on this page restates a name.
- route
/colophon
- route
This page
The feature half is anchored, because it cannot be generated
Sentences like this one are in no configuration file, so each carries an anchor: a route, a gate, or a specific assertion inside a gate. The check verifies the anchored thing still exists. It cannot verify that the sentence is true, which is stated in its header rather than implied, and a claim whose only anchor would be an external decision record is refused outright, because that would be an unverifiable claim wearing the costume of a verified one.
- route
/colophon - gate
check:features
- route
A tradeoff in the security headers
The content security policy is enforced, not report-only. One part of it is a tradeoff.
Each response carries a one-time nonce, and scripts must quote it to run. The nonce is generated per response.
- Seven pages are cached at Cloudflare's edge and served from one stored copy for up to ten minutes. The nonce is part of that copy, so visitors served from one cache entry share it.
- I chose this over uncached pages, which would make every reader wait for the origin on every visit.
- It is safe only because those pages hold no one else's writing. There are no comments and no submissions, so no stranger's script can reach the shared nonce.
Runtime
Compatibility date, Worker flags and the pinned Node version.
- Compatibility date
2026-09-01- Compatibility flags
- none
- Node
26.12.1
Bindings
Every resource the Worker binds to, what it is for, and the measurement that put it there.
17 resources.
| Resource | What it is | Why it is load-bearing |
|---|---|---|
AI_SEARCH (ai_search) | Retrieval and answer generation for Ask, on built-in storage with uploaded markdown rather than the crawler. | It sits above classic search rather than replacing it, because the two fail on OPPOSITE inputs. Measured on the same corpus: classic found 2 things AI retrieval missed, all exact tokens, and AI retrieval found 3 that classic missed, all natural-language questions. Neither layer subsumes the other. |
ASSETS (assets) | The static files under public/, served without a Worker invocation. | The binding has exactly one method, fetch(), so a Worker can serve any path it is given and discover none of them. That is why the media rebuild reads a committed manifest: the one thing it cannot do for itself is find out which static files exist. |
BROWSER (browser) | A headless browser that prints the CV to PDF. The Worker calls it after each CV save and keeps one PDF in the derived bucket. | The CV is edited through Carrel and goes live with no deploy, so a PDF committed to the repository went stale on every save and failed CI until someone re-rendered it by hand. The PDF is derived from the CV rows, so it is rendered where the rows live, stamped with a fingerprint of the data it was drawn from, and a health check compares that fingerprint with the CV the site shows. A failed render never undoes a save: the check goes red and the watcher re-renders. |
DB (d1) | SQLite at the edge. Holds every post, the tag graph, the derived search corpus, the media index and the llms.txt row. | The media index lives here because R2 paginates in key order and promises nothing else. Sort by date, filter unused, search by name, count by type: each is a query, and building one over a key-ordered iterator means listing the whole bucket per request. A capability decision, not a scale one. |
ASK_BUDGET (durable_object) | The per-IP burst limit and the site-wide daily ceiling for Ask, on the synchronous SQLite storage API. | Both cheaper mechanisms were built first and both leaked. The GA ratelimit binding, configured at 5 per 60 seconds and attacked with 12 concurrent requests, refused 1, then 2, then 9, then 0 across four runs. A Durable Object using async storage.get then storage.put allowed 8 through a ceiling of 3, because a read and a write spanning an await is not atomic. The synchronous API allowed exactly 3, which is why the class is registered as new_sqlite_classes. |
IMAGES (images) | Thumbnail and content-width transforms, computed on request from the original. Written back only when an editor turns or mirrors a picture, or uploads one larger than the library keeps, and then the earlier bytes are kept. | The BINDING and not the /cdn-cgi/image/ URL syntax, and that is forced rather than preferred: the URL interface answers 404 on both of this site's addresses, with Cloudflare error 1042 on the workers.dev address (which has no zone) and a plain 404 on dustinedwards.info, so the binding is the only route to resizing. |
APP_KV (kv) | The Ask answer cache keyed by a SHA-256 of the normalized question, plus short-lived state: the health snapshot, draft preview tokens, the editor divergence flags and the drift counts. | The cache sits in front of the daily ceiling rather than behind it, so a repeated question costs nothing and consumes no budget. The ordering is the design: a hit reaches no model. |
dustinedwards-media-events (queue_consumer) | R2 event notifications from both buckets, consumed to derive the media index rows. | The write path is a notification into a queue, never a dual write. The Worker writes only to R2 and the consumer re-derives the row from the object as it is now, never branching on what the message claimed, which is what makes replay and out-of-order delivery converge. |
dustinedwards-webmention-verify (queue_consumer) | Webmention verification: the row id the endpoint sends after it writes the unverified row, consumed to fetch the source and record the verdict. | A verification cut short in waitUntil was lost; here it is retried and then kept in the dead-letter queue. The consumer re-reads the row and fetches nothing unless it is still unverified, so a redelivery is a no-op. |
WEBMENTION_VERIFY (queue_producer) | The webmention endpoint's producer: one message per accepted mention, carrying the row id, source and target. | The endpoint answers 202 only after the send lands, and 503 if it fails, so the sender retries rather than a row waiting on a verification nobody will run. Absent on a Worker Preview, which cannot consume a queue, so the endpoint refuses there. |
BACKUPS (r2) | Nightly dumps of every D1 table, one folder per run, each finished by a completion marker, kept 14 daily, 8 weekly and 6 monthly, and a nightly copy of MEDIA and MEDIA_REVISIONS. Every night a job outside Cloudflare copies all of it to Amazon S3. | D1 is derived from the repository except for a handful of tables nothing can rebuild: webmentions, the sealed procedure versions, the Knowledge Base inventory and the lab roster with their audit trails. A dump is the only copy of those rows, and the file copies are the only copies of the uploads outside their buckets. wrangler d1 export cannot do the job because D1 refuses to export a database holding an FTS5 table. |
MEDIA (r2) | Uploaded originals, content-addressed as sha256(bytes) truncated plus the extension. | Content addressing was decided on security, not tidiness. The old key was derivable from public information, so a draft's images were reachable by guessing a slug the sitemap publishes plus eight hex characters. |
MEDIA_REVISIONS (r2) | Every earlier version of an uploaded file, kept when a replace, turn or resize overwrites it, so the editor can put any of them back. | MEDIA holds only the current bytes of each file, so an overwrite would lose the old ones. They are kept here before the write, and no code path deletes from this bucket; check:destructive sweeps for one. It is separate from MEDIA so the media library lists only current files. |
OG (r2) | Social cards, one per post, rebuilt by command, and the CV's PDF, one object replaced each time the CV is saved. | A second bucket exists because the two split on LIFECYCLE rather than on what the UI calls them. MEDIA is irreplaceable; OG is regenerable, so it is safe to empty and is deliberately outside the backup path. |
CSP_REPORT_RATE_LIMIT (ratelimit) | The limit on Content-Security-Policy reports: 60 a minute per client address, counted by the ratelimit binding the network's shared Worker reads for /csp-report. | A report is an unauthenticated POST any page can send, so without a limit one visitor could fill the logs. An approximate count is enough here, unlike Ask's guards: a report let through costs one log line, and with no binding every report is dropped. |
EMAIL (send_email) | Email Sending from the Worker, used once a day at most: when ORCID or Crossref lists a paper of Dustin's that the publications list does not have yet, the DOI is mailed to him once. | A new paper is added through Carrel by hand, so nothing on the site notices one by itself. The mail is what turns a paper published elsewhere into a line on this site, and each DOI is mailed once so a paper waiting to be added is not mailed every day. |
CF_VERSION_METADATA (version_metadata) | The time the running version was uploaded. /health's cron check reads it and nothing else does. | A scheduled job added after its hour has not been due yet, and without the deploy time /health warned that it never ran. With it, a job is expected only once its first window since the deploy has passed, so the warning means a trigger really is missing. |
Schema
Hand-written migrations, applied in order. drizzle-kit is not a dependency. The database export command fails on this schema, so this directory is the only copy of the table definitions.
44 migrations.
0001_init.sql0002_blog_content.sql0003_post_toc.sql0004_blog_discovery.sql0005_og_image.sql0006_search_index.sql0007_media.sql0008_llms_seed.sql0009_media_index.sql0010_media_role.sql0011_media_trash_tags.sql0012_drop_posts_category.sql0013_content_hashes.sql0014_webmentions.sql0015_zero_result_queries.sql0016_post_head_blocks.sql0017_post_changelog.sql0018_post_backlinks.sql0019_procedures.sql0020_publications.sql0021_pages.sql0022_cv.sql0023_dictionary.sql0024_roster.sql0025_phages.sql0026_retire_better_auth.sql0027_procedure_versions.sql0028_registry.sql0029_kb_inventory.sql0030_lab_members.sql0031_kb_runs.sql0032_source_files.sql0033_scheduled_updates.sql0034_media_rev.sql0035_media_library.sql0036_cron_runs.sql0037_site_api_store.sql0038_carrel_posts.sql0039_post_search_fields.sql0040_site_api_media_uploads.sql0041_not_found.sql0042_site_api_store_v0_22.sql0043_ask_uploads.sql0044_shared_media_store.sql
Gates
Checks that run before anything ships. The list is derived from the scripts, so a gate cannot be added and forgotten.
25 checks.
check:alt-textcheck:ask-guardscheck:backupcheck:browsercheck:consolecheck:contentcheck:contrastcheck:destructivecheck:diagramscheck:enhance-a11ycheck:featurescheck:fontscheck:linkscheck:machine-readablecheck:migrationscheck:policycheck:postinstallcheck:protocolscheck:secretscheck:systems-mapcheck:test-assertionscheck:testscheck:typescheck:urlscheck:worker
Dependencies
Runtime dependencies only. Build tooling is excluded.
47 runtime dependencies.
@base-ui/react1.8.0@codemirror/commands6.10.4@codemirror/lang-markdown6.5.1@codemirror/language6.12.4@codemirror/state6.7.1@codemirror/view6.43.7@drdustinedwards/site-helpersgithub:DrDustinEdwards/prelum#v0.50.0@dustinedwards/d1-dumpgithub:DrDustinEdwards/d1-dump#v0.16.0@dustinedwards/site-apigithub:DrDustinEdwards/site-api#v0.39.0@dustinedwards/site-runtimegithub:DrDustinEdwards/site-runtime#v0.148.1@fontsource-variable/source-serif-45.3.0@lezer/highlight1.2.3@observablehq/plot0.6.17@shikijs/rehype4.5.0@shikijs/transformers4.5.0capsomergithub:DrDustinEdwards/capsomer#v0.111.0drizzle-orm0.45.2enarratio0.13.1github-slugger2.0.0gray-matter4.0.3hast-util-from-html2.0.3hast-util-to-mdast10.1.2hast-util-to-string3.0.1image-size2.1.0katex0.16.47linkedom0.18.14mdast-util-gfm3.1.0mdast-util-to-markdown2.2.0parse58.0.1react19.2.7react-dom19.2.7react-router8.5.1rehype-autolink-headings7.1.0rehype-katex7.0.1rehype-slug6.0.0rehype-stringify10.0.1remark-directive4.0.0remark-gfm4.0.1remark-math6.0.0remark-parse11.0.0remark-rehype11.1.2remark-smartypants3.0.3shiki4.5.0unified11.0.5unist-util-visit5.2.0uqr0.1.3zod4.6.7
Credits
The packages shared with my other sites adapt code from three open-source projects, each under the MIT license.
Astro is the source of devkit's island-loading rules and dev-toolbar audit rules. Site-runtime adapts its speculation-rules prefetching and image layouts and widths. Capsomer adapts its size-matched fallback fonts.
EmDash is the source of the scheduled updates to live posts that site-api and Carrel apply, and of the ideas for focal points and captions on media.
Cloudflare Nimbus is the model for how Prelum serves Markdown copies of pages to AI agents. Prelum uses Nimbus's own code to decide when a request prefers Markdown.
What was not adopted
Choices ruled out on purpose, each with its reason.
5 entries.
| Choice | Why not |
|---|---|
| Vectorize (Refused) | Rank fusion over two FTS5 indexes answers this corpus in about fifteen lines and no vectors. Zero-result queries are the cheapest content-gap signal the site has, and the only evidence that would justify reopening the question. |
| Images storage (Refused) | Transforms are used; storage is not. It bills in $5 increments per 100,000 stored images, for a library of dozens. |
| Soft delete and a trash prefix (Refused) | A team concern. The audience is one person, and R2 wins every conflict already, so a deleted row that should not have gone is restored by the next rebuild. |
| Media versioning (Refused) | Keys are content-addressed, so a changed image is a different key by construction. Versioning would be a second mechanism for something the addressing already does. |
| Single-flight on Ask (Refused) | Declined with the bound written into the route. Deduplicating identical questions does not lower the worst case: N addresses spend the same N units asking N DIFFERENT questions. The 200 per day ceiling is the real bound and it is exact. |
AI disclosure
Claude, Anthropic's model, helps write the code and the prose of this site.
The model runs through Claude Code and publishes through the same API, and passes the same gates, as I do.
- An agent on the operator API cannot publish a post for the first time. Code refuses it by name. Carrel publishes only when I tell it to.
- I read and edit every post before it goes live, and I am answerable for it.
- Provider marks on model output do not survive editing, so you cannot check for one here. My review is the guarantee.
For what the site records about a visit, and where each of those facts lives in the code, see privacy.