1 · The receipt
A signed payment webhook whose signature is verified against the raw bytes, recorded as an entitlement your own system owns. The whole class in one evening.
Nobody is awake. Every guarantee has to refuse on its own.
A stranger buys a $25 listing, types their shop into a form, and their page is readable in about a minute. No queue, no moderator, no approval. This class is the machinery underneath a directory that does that for real — the payment webhook and the signature that has to be checked against raw bytes, the entitlement that survives a failed write, the geocoding step that refuses rather than guesses — and the harder half: the checks in that same system that looked like protection and turned out to be incapable of failing. Seven projects, every prompt written out.
A self-service directory is a machine that converts money into published content while you sleep, and everything hard about it follows from that sentence. The payment is the easy half: a webhook, a signature verified against the exact raw bytes that were sent, and a record of a right to publish rather than the publication itself — which is what lets a failed submission be retried instead of refunded. The hard half is that no person is going to catch anything. Every guarantee you would normally get from someone reading a queue has to become a check that can refuse, and refusing is harder to write than warning. Then the real problem arrives: your checks quietly stop being checks. In the system measured for this class, a duplicate query ran against one collection while the page was assembled from two, a health endpoint reported a six-day-old reading as current, and a build step carried a flag that let it fail without failing the build. Each looked like protection the day it was written. A check that cannot fail is not a check.
The reference system for this class is the Bay State Treasure Gazette, a directory of 565 Massachusetts shops. A shop owner buys a one-year listing for $25, receives an order number by email, fills in a form, and their page is live. The copy on the form says “usually within a minute” and that is not marketing — there is nothing in the path that waits for a person.
Two values, both already on the buyer's receipt: the order number, normalised, and the email used on the order. Neither is secret. That is worth sitting with, because it is the whole authentication model of the paid path, and the code that implements it says so out loud:
The submission endpoint is public and gated only by an order name, which is
short and guessable in principle. Defences, in order:
- the order must exist as a PAID entitlement we recorded ourselves
- the submitter's email must match the order's email
- each unit redeems exactly once, enforced by a transaction
- rate limited per IP
Guessing a valid order name without also knowing the buyer's email is the
bar an attacker has to clear.
That comment is doing more work than it looks. A self-service endpoint accrues defences one incident at a time, and six months later nobody can say what the actual bar is. Stating it as a ranked list, in the file that implements it, means the next person to touch it — human or agent — can tell the difference between a defence and a decoration.
Six gates, in order. For each submission, decide which one stops it — or whether it goes all the way through.
Six real gates from the paid submission path, in the order they run. Pick the first one that refuses each candidate.
A shop owner with a valid order and a full street address
A guessed order number with no purchase behind it
The correct order number, submitted from a different email
The same buyer submitting a second shop on a one-unit order
A valid order whose address is given as Main Street with no number
A shop that is already one of the 565 listed, paying to be listed again
Your answers stay in this browser. Nothing is sent anywhere. Reset clears them.
The duplicate check is not missing. It is written, it runs on every submission, and it is correct about the collection it queries. It simply queries one of the two collections the page is assembled from. A shop already in the directory can pay and appear twice, with two cards, two map pins and two detail pages.
The client-side merge does not save it either. It is documented as resolving identifier collisions — and it operates on two id namespaces that cannot produce a collision. It is a true statement about a case that can never arise. Hold onto that sentence; module 03 is built on it.
The instinct when a payment lands is to create the thing that was bought. Resist it. At the moment the money clears, the buyer has not told you what to publish yet — they have only told you they intend to. So the webhook writes an entitlement: a small document saying this order may publish one listing, valid until a date, redeemed zero times so far.
The listing is written later, from the form, against that entitlement. Splitting the two is the single highest-leverage decision in the whole build, and it buys three things at once.
The payment provider signs the raw request body and sends the digest in a header. Verification must run against those exact bytes. The usual framework setup parses JSON for every route, and if you verify against a re-serialised object, key order and whitespace have already changed and every signature fails. The fix is to mount the raw body parser on that one path and nowhere else.
The provider signs the RAW request body with the webhook's shared secret and
sends the base64 HMAC-SHA256 in a header. This MUST run against the raw bytes
— if the framework has already parsed the JSON and you re-stringify it, key
order and whitespace change and every signature fails.
An order can contain anything. Filter the line items by SKU to count how many listing units were bought — titles get edited by whoever runs the shop, and an A/B test on a product name should not silently stop your webhook working. An order containing no listing items should return 200 with a cheerful "nothing for me here", so the provider stops retrying a t-shirt order.
The record the webhook writes. Six fields, and every one of them is load-bearing. Click a line to see what it is doing.
entitlement/GAZ-1042Your answers stay in this browser. Nothing is sent anywhere. Reset clears them.
A buyer who pays and never returns to the form has bought nothing, and nothing in the system goes looking for them. No sweep reads unredeemed entitlements, no email chases them, and the seller cannot tell the difference between that and a customer who simply has not got round to it.
This is the largest gap in the "nobody awake" claim, and it belongs in the class as a design question rather than a bug. Closing it needs either a credential for the payment provider so orders can be reconciled against entitlements — reintroducing exactly the long-lived secret the design avoided — or a scheduled nudge over unredeemed entitlements, which needs no new data at all, because the expiry date, the units bought, the units used and the email are already on the document. Project 4 builds the second one.
With nobody reading a queue, validation is the only editorial standard the system has. That changes what validation is for. It is not there to improve a submission; it is there to stop one, and the difference shows up in the code as whether the function returns or throws.
The clearest example in the reference system is geocoding, which most directories treat as enrichment — look up the coordinates, fill them in, carry on. Here it is a hard gate with four distinct refusals:
| Condition | What the submitter is told |
|---|---|
| No result at all | “We could not find that address.” |
| Resolves outside the state | “That address does not appear to be in Massachusetts.” |
| Only resolves to a town centre | “We could only locate that to the town centre. Please give a full street address with a number.” |
| Ambiguous between several places | A refusal naming what was ambiguous |
Every one of those is a sentence a shop owner can act on without emailing anybody. That is not politeness — on a self-service path, an unactionable error message is a support ticket you have chosen to receive.
It was not designed that way. It was promoted to a gate after eighteen records
were found carrying coordinates produced by Math.random() around a
town centroid — wrong by up to roughly 1.7 miles, and
different on every run.
Nothing was broken in the sense of throwing. A missing coordinate had a fallback, the fallback produced a number, the number was a plausible latitude, and the map drew a pin. The failure was not that the code stopped working; it was that the code kept working on invented data. That is the failure mode a self-service system produces by default, because there is nobody looking at the map going “that shop is not in the harbour.”
Each round below reports a passing result. In each, exactly one line is the reason the result cannot be trusted.
Three checks that reported success on real runs. For each, pick the line that makes the success meaningless.
every published row must carry a latitude and longitude
Look at where a missing value comes from, not whether one is missing.
publish only on evidence dated within the last twelve months
One of these three is evaluated against something the server chose, not something the author wrote.
evidence must come from the organisation's own website
Ask what the rule compares the host against.
Your answers stay in this browser. Nothing is sent anywhere. Reset clears them.
These are not two styles of the same rule. A deny-list fails open: anything nobody has thought of yet is permitted. An allow-list fails closed: anything nobody has thought of yet is refused. On a path where a person reviews the output, failing open is an inconvenience. On a path where nobody reviews anything, failing open is the whole risk.
This is the module the rest of the class exists to set up. Every defect in the reference system — and there are a lot of them, all found by measurement rather than by anything going visibly wrong — is the same shape. A test whose answer does not depend on the thing it claims to test.
They are hard to spot because they are not bugs. Each one is correct code, doing exactly what it says, returning exactly the value it should. They fail the only test that matters, which is: can I make this thing say no?
Six checks from the same codebase. For each: is it capable of refusing, or does it only look like it is?
Geocoding refuses an address it can only place at a town centre. — Is this capable of refusing?
verified by submitting 'Main Street, Boston'
The merge function resolves identifier collisions between curated and community rows. — Is this capable of refusing?
0 collisions possible by construction
The nightly expiry sweep returns 200 to confirm it ran. — Is this capable of refusing?
9 consecutive days of 200, meaning nothing
The deploy fails unless exactly three scheduled jobs exist. — Is this capable of refusing?
fails the build at any count but 3
The continuous-integration step that verifies the documented examples still work. — Is this capable of refusing?
allowFailure still set today
The prerender step refuses to start without an empty root element in the built HTML. — Is this capable of refusing?
refuses on a malformed dist
Your answers stay in this browser. Nothing is sent anywhere. Reset clears them.
There is one question, and it is mechanical rather than clever:
Take the check. Feed it something that should be refused. If you cannot construct an input that makes it say no — not did not, but cannot — then it is not protecting you, and the passing result it has been emitting for months is not evidence of anything.
A metric that returns the same answer for every input cannot see the defect. So the first thing to do with any new check is break it on purpose and watch it complain. If it will not complain, delete it or fix it, because leaving it there is worse than having nothing: nothing does not give you confidence.
While preparing this class, a build-time gate was added to a page to guarantee a product name appeared often enough in the visible copy. It counted with a case-sensitive match. The copy it was written to protect was in capitals. The gate counted ten, needed twelve, and failed a page that was already correct — it could not see its own fix.
The rule written down after that one was: a text-matching check must scan only the region where the matched thing can actually do harm. The very next check written after it — on this page — searched the whole document for a style declaration that breaks the contents rail, and fired on the comment explaining that the declaration is deliberately absent. It matched its own documentation.
Nobody is immune to this, including immediately after writing down the rule. The remedy is not care; it is the mechanical test above, applied to your own instruments with the same suspicion you would apply to somebody else's.
The figures on this page are generated by a script whose gate computes every text block's edge span and refuses to write the file if two overlap or one leaves the canvas. On the first run it refused: one label was landing at x = −11.
It was not an overlap. One call had been written with eight arguments instead of nine, so a text anchor bound to the font-weight parameter and a label name bound to the anchor. A gate built to catch collisions caught an argument-order mistake — because the nonsense anchor fell through to the centred branch and produced an impossible coordinate.
That is luck, and it is worth being honest about: the same mistake forty pixels
further right would have passed the gate cleanly and shipped
text-anchor="l09-07" into the file. Invalid, silently ignored by
every browser, visibly wrong on screen, and complained about by nothing. So the
fix was not only the missing argument. It was a check at the layer where the
mistake actually happens: the drawing function now refuses an anchor that is not
one of the three legal values, and says it is probably an argument-order slip.
Put the check where the error is, not only where the symptom
surfaces.
Three scheduled jobs keep the reference directory honest. One expires listings whose year is up, one sends renewal notices, one publishes and retires advertisements. All three have run every day without a miss, which is measurable and true and almost beside the point.
All three return HTTP 200 for “I did nothing” and for “I did the right thing.” The scheduler records the status code. Nothing reads the response body. A sweep whose query silently matched zero documents — a renamed field, a changed status string, a collection pointed at the wrong database — would log a contented 200 every morning for a year.
There is a monitor. Somebody spotted that the pipeline asserted the jobs existed rather than that they had run, and built a proper fix: a step that reads each job's real last-attempt time and posts it to a health endpoint. That fix works. It also runs inside the deploy pipeline, so it only refreshes when somebody pushes code.
ok: true and an
age of 3.5 hours on the strength of a reading taken on the 7th. Both statements
were true when they were written. Neither is evidence today.
The jobs happened to be healthy — that was checked directly, against the
scheduler. But /health could not have known that, and would have said
exactly the same thing if all three had been dead since the 8th.
A monitor refreshed by the thing it monitors is not a monitor. If a deploy is what updates your view of production, then your view of production is a photograph of the last deploy. The runbook for this system half-knows it — it says “if the scheduler block is null, push a deploy to refresh it.” It handles missing. It does not handle stale, and stale is the dangerous one, because missing looks broken and stale looks fine.
The fix is not a better monitor. It is that the reading must carry its own timestamp, and the consumer must refuse a reading that is too old instead of rendering it. A number without an age is not a measurement.
Each row pairs something a careful person would assume about this system with what was actually measured on 2026-09-13.
The directory a reader sees is one dataset
measured delta +1 sources +100.0%
A scheduled job that returns 200 did its job
measured delta +1 outcomes +100.0%
The health endpoint reports the current state
measured delta +140 hours +3500.0%
Every step in the deploy can stop a bad deploy
measured delta -1 of 13 -7.7%
A paid order ends up as a published listing
measured delta +3 outcomes +300.0%
A written protocol survives a change of operator
measured delta +3 rounds +300.0%
Your answers stay in this browser. Nothing is sent anywhere. Reset clears them.
No export job, no scheduled snapshot, no lifecycle rule — measured, not assumed. The database holds every paid entitlement and every published listing in the system. Nothing tells you a backup did not happen when nothing is trying to take one. It is the quietest failure on the list because it produces no signal at all, right up until it produces the only signal that matters.
A directory that lets strangers publish has to answer one question before it writes a line of code: what stands between a submission and the public page? The reference system has three different answers depending on who is paying, and the differences are the most teachable thing in the build.
| Path | Gate | Human before publication? |
|---|---|---|
| Paid listing | Money | No. Validate, geocode, dedupe, publish — one transaction, readable in about a minute |
| Paid advertisement | Money | Soft. Copy lands pending; a person approves it, or forty-eight hours pass and it publishes itself |
| Letter to the editor | Nothing | Yes, hard. A person must approve. There is no auto-publish timer |
| Event listing | Nothing | Yes, hard. Same |
Read down that table and the policy states itself: the thing a stranger pays for publishes itself with no human in the loop; the things a stranger can post for free do not publish without one; and the paid thing in between publishes itself after forty-eight unattended hours.
The money is the moderation. Not as a slogan — as the actual security model, stated in the code and defensible in a sentence. A card that clears carries a name, an address and a chargeback risk, which is a meaningfully higher bar than a form anyone can submit. It is not a quality check and nobody should describe it as one.
The free paths have one rule written at the top of the module that handles them: nothing validated here is ever published by validating. Passing validation moves a submission to pending, never to published. That single sentence removes an entire class of accident — the refactor six months later where a well-meaning change makes a validator's success path also the publish path.
When a letter is rejected it is marked rejected. It is not removed. That keeps a moderation decision reviewable, and it keeps a spam pattern visible in aggregate — which is the only way you ever learn what is actually being attempted against you. A deleted submission teaches you nothing and cannot be appealed.
A paid directory publishing real, named businesses takes on obligations that a personal project does not, and they need answering before launch rather than after the first complaint:
Each one is a working piece of the system above, reduced to what a person can build alone, for their own niche and their own region. They ladder: project 1 is an evening and project 7 assumes the rest. None of them needs the Places API.
A signed payment webhook whose signature is verified against the raw bytes, recorded as an entitlement your own system owns. The whole class in one evening.
A form that redeems an entitlement exactly once, under a transaction, so two simultaneous submissions cannot both win.
Geocoding as a hard gate with four named refusals, each returning a sentence the submitter can act on without emailing you.
Expiry, renewal notices, and a sweep over unredeemed entitlements — the one the reference system still does not have.
A free submission path that cannot publish itself. Validation moves it to pending, never to live, and rejection is a write rather than a delete.
Scheduled jobs that report what they actually did, a health reading that carries its own age, and a consumer that refuses a stale one.
The whole loop, proven on every deploy: a smoke test that buys, submits, publishes, verifies and purges against a candidate before any traffic reaches it.
The first four steps exactly as you would type them. The pattern holds for all seven: a prompt, a thing to look for, and a gate before you move on.
1. Create the project folder and connect it as a workspace.
2. Create AGENTS.md at the root: what you are building, your payment
provider, your datastore, and an empty "Known traps" heading you
will fill in as you go.
3. Prompt, verbatim:
"Create a webhook endpoint that receives order-paid events from
[provider]. Mount a raw body parser on THIS ROUTE ONLY. Verify the
HMAC over the raw bytes against the shared secret. Return 401 on
mismatch. Do not parse the body before verifying."
- you should see: a route that reads Buffer, not a parsed object
- if it verifies against JSON.stringify(req.body), it is wrong and
will fail every real signature - say so and ask again
4. GATE: send a request with a deliberately corrupted signature and
watch it return 401. Then send a valid one and watch it return 200.
Do not continue until you have seen BOTH. A verifier you have only
ever seen succeed is not a verifier.
Fill this in for your own stack and download the result. It is the file to hand an agent at the start of project 1.
Five answers produce a starter specification for your own directory. Nothing is sent anywhere — the file is built in this page.
Your answers stay in this browser. Nothing is sent anywhere. Reset clears them.
A self-service directory is not a form with a payment attached. It is a machine that converts money into published content while nobody is looking, and every design decision follows from the second half of that sentence. Verify the payment yourself. Record a right rather than a result, so a failure is recoverable instead of refundable. Make validation refuse, in a sentence the submitter can act on. Decide out loud what the money buys, because on the paid path the money is the moderation.
And then do the thing almost nobody does: go back through every check you wrote and try to make each one say no. The reference system for this class is real, it works, it takes real money from real shop owners and publishes their pages in about a minute — and half its safeguards turned out to be structurally incapable of refusing anything. Not broken. Not sloppy. Written carefully, by someone trying, and incapable all the same. A check that cannot fail is not a check, and the only way to find out which kind you have is to break it on purpose.
It means the thing a stranger pays for publishes itself with no human in the loop. A paid listing goes validate, geocode, dedupe and published in a single transaction, readable within about sixty seconds. The things a stranger can post for free — letters and events — do not publish without a human approving them. And the paid advertisement sits in between: a person can approve the copy, or forty-eight unattended hours pass and it publishes itself. The money is the moderation. That is the entire security model of the paid path, and it is worth stating plainly rather than pretending a machine is reading submissions for quality.
A webhook, not polling and not an export. The store fires an order-paid event at one endpoint, which verifies an HMAC computed over the exact raw bytes that were sent. Two values already on the buyer's receipt carry identity across the payment boundary: the normalised order number and the order email. The webhook does not write a listing. It writes an entitlement — a recorded right to publish one listing — and the listing itself is written later from the form. That separation is what makes the failure modes survivable, because a form that fails can be retried against an entitlement that was never consumed.
Less than you would hope, and being honest about that is the point. Payment is the first barrier: an attacker must clear a valid paid order number plus the buyer's email on that order, with each unit redeemable exactly once under a database transaction and rate limiting per address. Then geocoding is a hard gate — it refuses an address it cannot find, an address outside the state, and an address it can only resolve to a town centre. What none of that establishes is that a real shop stands at a real address. A directory that takes money from strangers needs a takedown path it can actually execute, and the class treats that as a design requirement rather than an afterthought.
Because the buyer has not told you what to publish yet. Splitting the purchase from the publication gives you an idempotent webhook — a retry merges into the same document keyed by the order number — and it gives the form a thing to validate against. It also turns three of the four failure modes into recoverable states: if the publish transaction throws, the entitlement is not consumed and a retry works. The cost of the split is the fourth mode, which is genuinely open: a buyer who pays and never returns to the form has bought nothing, and nothing in the system currently goes looking for them.
Not the form and not the payment. It is that nobody is awake. Every guarantee you would normally get from a person reading a queue has to be expressed as something that can refuse on its own, and refusing is harder to write than warning. The second hardest part is that your checks quietly stop being checks. In the system this class measures, a deduplication query ran against one collection while the directory was assembled from two, a health endpoint reported a six-day-old reading as current, and a continuous-integration step carried a flag that let it fail without failing the build. Every one of those looked like protection on the day it was written.
A test whose answer does not depend on the thing it claims to test. Three measured examples from one codebase. A merge function documented as resolving identifier collisions, operating on two namespaces that cannot produce a collision — a true statement about a case that can never arise. A recency rule that accepts a page modified within twelve months, evaluated against a header that a dynamic server sets to the current second, so every dynamic page passes regardless of its content. And a scheduled sweep that returns success for did nothing and for did the right thing, so a query silently matching zero rows logs a healthy result forever. The remedy is the same in all three: change the threshold until the check can fail, and if it never can, you have a report rather than a gate.
No, and the class is deliberately built so you do not. Projects one through five use only a payment provider, a database and a geocoder, and the geocoding step has a free tier generous enough that a directory of a few hundred rows never leaves it. Places matters for a different job — confirming that a business you did not create still exists — which is a maintenance problem rather than a build problem. If you do reach for it, understand that the field mask you request selects the billing tier, and the billing tier selects both the rate and the size of the monthly free allowance.
At the scale of a few hundred listings and ordinary traffic, close to nothing — the scheduled jobs, the build pipeline and the database all sit inside free allowances, and the front end is static files. The costs that actually bite are not infrastructure. They are the paid API calls you make while researching or verifying records, which is why the reference system runs a hard monthly call cap in code rather than trusting a console quota, and why that cap is the first thing the class teaches you to build.
Project one, even if you are experienced, because it is one evening and it teaches the habit everything else depends on: verify a payment yourself before you trust anything the browser told you. It builds the receipt — a signed webhook whose signature is checked against the raw bytes, recorded as an entitlement your own system owns. Projects two through five add the claim, the address gate, the expiry clock and the free-but-moderated door. Project six makes it publish unattended. Project seven is the capstone that proves the whole loop on every deploy.
Claude Fable 5.1, driven from two surfaces — a Cowork session and Claude Code — with a human setting the standing rules, auditing the reports and making every decision about money and publication. That division is not incidental to the result. The most instructive artifact in the whole archive is a scheduled agent that ran a confirmation script, read its own four passing verdicts, found that the rule behind them could not actually fail, and declined to publish anything on the strength of them. It wrote down why instead. An agent that can refuse its own instructions is worth more than one that completes them.