Introductions, drafted then confirmed, behind a proof of work of the site's own

A visitor's introduction is drafted, shown back and confirmed, after a honeypot, content rules and an ALTCHA proof of work solved in the site's own worker; kept 180 days, read by the owner alone.

Status
Accepted 3 October 2026, revised 7 November 2026 · a conversation with the guide may go with one, on the visitor's word
The record
0015-introductions-drafted-then-confirmed.md

Context

Email works, but a form lets a recruiter say who they are in the site's own terms: name, company, role, a message, in one place the owner reads (S21). A public form invites programs that fill in forms. The usual guards come from third parties, a captcha or a bot service, and would load another company's script and styles into pages whose Content-Security-Policy allows none (ADR 0007). What a visitor writes is personal data, given on purpose.

Decision

  • Drafted, then confirmed. POST /api/introductions keeps a draft and returns it as it was kept, with a token; POST /api/introductions/{id}/confirmation with that token sends it on. The visitor sees exactly what will be kept, and a draft not confirmed in 30 minutes is never sent. Only the token's SHA-256 is stored, and a wrong token is answered like a wrong id. The states, DRAFT, CONFIRMED, HANDLED (by the owner) and EXPIRED (a draft past its time, never stored: it is deleted), are a State pattern, each allowing its own moves; a confirmation is one conditional update, so two at once cannot both win.
  • A chain of checks. After the rate limit (ten drafts an hour per address), a draft goes through a Chain of Responsibility: the honeypot, a field people never see, whose filler is told it succeeded and nothing is kept; content rules (three links at most, an address with a domain); and the proof of work, last, so that a visitor told to change the message keeps the work already done.
  • Proof of work by ALTCHA's protocol, version 1, without its widget. The server hands out a challenge, the SHA-256 of a salt and a number it keeps to itself, with an HMAC signature and an expiry inside the signed salt. The page solves it in a Web Worker of the site's own, and the server checks the signature, the hash and the expiry before it touches the database, then remembers the challenge until it expires, so no solution is spent twice. The largest number, 100,000, is configurable.
  • Kept briefly, for one reader. Introductions are deleted 180 days after they are sent, and an unsent draft once it expires, by a sweep that runs at every start of the service, after each quiet spell on Cloud Run, and with the first draft of each day. Only the owner reads them, through the admin of S21. The privacy page says what an introduction keeps, why and for how long; the HMAC key is a Secret Manager secret in production.
  • Errors named like everywhere else. A body's validation answers as a parameter's does (MESSAGE_INVALID, UNREADABLE_BODY), and the form turns each documented code into a sentence at the field it is about.
  • A conversation with the guide, on the visitor's word (revised 2026-11-07). The guide keeps no conversation (ADR 0019); a visitor who wants the owner to read theirs sends it with an introduction. Send this conversation, under the transcript, opens the form with it attached; reached otherwise, the form offers it unattached. What goes is the transcript as the visitor saw it, as text, the oldest turns left out past 12,000 characters, shown whole before it is sent. It passes the same checks as the message (a few links at most, CONVERSATION_TOO_MANY_LINKS), is kept beside the introduction in a column the database checks as the API does, for as long as the introduction, and the admin shows it folded under the message. Between the guide's code and the page it is handed over in memory alone, never stored by the browser.

Consequences

  • Measured on the built site: the page solves a challenge in 0.2 s here and in 0.4 to 0.7 s with the CPU slowed six times. A program pays that for every draft: a speed bump, not a wall, with the rate limit and the honeypot beside it.
  • Without the secret, each instance signs with a key of its own, and a challenge handed out just before a new revision takes over fails once; the page then asks for another.
  • Until S21 the owner reads introductions in the database.
  • An attached conversation is what the visitor's browser sent, not the server's record of it, which does not exist: the visitor could have changed it on the way. The admin says it is the conversation as they sent it.
  • The first introduction migration found a fault from S19: PostgreSQL's search path starts with "$user", so for a role named site, as in compose.yaml, the site schema came first once V5 had made it, and Flyway looked for its history there and refused to start. Flyway's schema is named outright now, and a test reproduces the search path.