Skip to content
Ryan Lindsey

Governance

Acceptable use and data handling

Updated

What the AI features on this site do, what they store, for how long, and what they never touch. Every sentence here is checkable against the repository this site is built from.

This site is a production system, not a demo. It answers questions from a model, retrieves from a corpus, and writes some of what happens to a database. This page says what that means for you, in the order you would want to ask.

It is written to be checked. The site’s source is public, and where a paragraph below describes a mechanism, the mechanism is in that repository under the name this page gives it. The companion risk register says where the controls are weaker than they sound.

What these features are for

Three things on this site use a model:

  • Chat answers questions about published writing, the résumé and the case studies, grounded in retrieved passages and cited by number.
  • Fit analysis compares a description you supply against the published corpus and returns a structured report. It is reached only with a scoped token.
  • A judge scores answers against written criteria when the evaluation suite runs. It never faces a visitor.

They are all narrow on purpose. Chat is not a general-purpose assistant and declines to be used as one. Nothing on this site will answer questions about Ryan’s working arrangements, compensation, references or plans — those are covered in a private tier, and the way to get at them is to ask him.

Acceptable use

You are welcome to use everything here, including with an agent, including at volume within the published limits. Two things are not welcome, and both are enforced rather than requested:

  • Driving spend. Every inference path is rate limited per caller. Chat also has a global daily cap of 500 messages, and it is the only path that has one. A budget breaker can stop chat and fit analysis; it is not read by the evaluation judge or by the embedding call retrieval makes, so it does not stop everything. Both browser forms carry a bot check.
  • Probing the private tier. Routes that are not listed answer exactly what a path with no route answers. There is nothing to learn from the difference, because there is no difference.

Automated access is not abuse. See Agents and crawlers.

What is stored, and for how long

Chat transcripts — 30 days. Your question, the answer, the model, the numbered sources the answer was grounded on, how many of them it cited, how many citation numbers named a source that does not exist, whether the turn succeeded, how long it took, and whether it came from this site or from the endpoint directly. Also a session id, which your own browser generates per visit to group one conversation; it is not a credential and is not stable across visits.

The tool-call audit trail — 1 year. One row per call to the MCP server: the tool, a hash of the arguments, whether the call was public or made under a grant, the audience and token id of that grant, the client’s name, version and user agent, the protocol version, the outcome, and the duration. Arguments are stored as a hash, never as text.

Fit reports — 1 year. The description that was submitted, the validated report, the audience of the grant that produced it, the model, and how many citations were checked and dropped. A report has an unguessable permalink, and that link is the only key to it — anyone holding it can read it until the window closes.

Two things have no window, deliberately. Rows in the token registry — a token’s id, audience, scopes and timestamps, never its value — are never deleted, because they are the only key the audit trail has for the credential that made a call, and deleting one would erase the record that the token ever existed. Evaluation runs store a suite name, pass and fail counts and a truncated note naming failing cases; they are summaries of our own test runs and contain nothing a visitor typed.

Per-request analytics. One row per request reaching a Worker, carrying six bounded labels: a class for the client (agent, browser or unknown), a stable name for it, a class for the route, a class for the referrer, which surface served it, and whether the status was 2xx, 3xx, 4xx or 5xx. These are aggregate counters and nobody can be picked out of them.

What is never stored

  • No cookies. This site sets none, and the MCP server refuses to read one: a token is presented explicitly on every call, never carried ambiently.
  • No IP address in any table. There is no address column in the transcripts, the audit trail, the fit reports or the analytics rows. Your address is used once, in memory, to name the rate-limit counter that applies to you, and is discarded with the request. Cloudflare’s own edge logging is a separate system and is outside what this page can speak for.
  • No fingerprinting. Classification reads the user agent, the path, the Accept header, Sec-Fetch-Mode and the referrer — the referrer’s origin specifically: it is parsed and its host matched against lists of campaign, social and search domains to produce a class. That class is stored; the referring URL never is. It does not read TLS fingerprints, header order, screen dimensions or anything else that identifies a person rather than a client, and it stores classes rather than raw values — never a full path, a query string or a referrer URL. The MCP audit trail is the one exception and says so above: an MCP client identifies itself by name and version, and that is kept verbatim for a year.
  • No accounts, and no tracking across visits. There is nothing to log in to.

How the private tier is partitioned

Some documents on this site are held for a named audience and reached with a scoped, expiring token. The separation is structural rather than a filter applied at read time:

  • Gated documents live in a separate storage bucket, and the code path that serves public documents does not declare a binding to it. A public tool cannot leak a private document by forgetting a check, because it holds no reference to the bucket.
  • Gated tools are registered per grant. An unauthenticated listing does not contain them, so it cannot name one.
  • Unlisted routes answer a byte-identical copy of this site’s own 404 — status, body and every header — so a refusal is indistinguishable from a path that does not exist.
  • Tokens are scoped and revocable. Revocation is one row update in a registry the server consults on every call, and every gated tool call is recorded with the token’s id, so revoking one can be followed by an exact answer to what it read.

How retention is enforced

The windows above are a single constant in the code. A scheduled job runs once a day, deletes everything past its window table by table, and logs one line naming each table and the number of rows it removed — so a day on which nothing was old enough and a day on which the job did not run look different from outside. A test that runs on every build checks, for each table above, that this page names that table beside the window the constant sets — so changing a window in the code without changing this page turns the build red, and so does adding a table the page does not mention.

If a table’s delete fails, the others still run and the failure is logged rather than swallowed. One table falling behind must not hold another table’s data past its stated window.

Agents and crawlers

Agents are welcome here and are not treated as a problem to be managed.

  • robots.txt contains no Disallow anywhere, for any agent. Major operators are named in their own groups and allowed explicitly.
  • A Content-Signal line states the intent behind that permission: search=yes, ai-input=yes, ai-train=no, use=reference. Indexing, citing and retrieval-time grounding are all welcome; using this content to train or fine-tune a model is a reserved right rather than a technical control, and nothing here can stop a crawler that ignores it.
  • /llms.txt is a curated index and /llms-full.txt is the whole corpus as plain text.
  • The résumé, every post and every case study have a markdown variant — append .md to /resume, /writing/<slug> or /work/<slug>, or send Accept: text/markdown to the page itself and get the same bytes. Those three are the whole list, and this page is not on it.
  • An MCP server at https://ryanlindsey.me/mcp exposes the same corpus over a protocol, unauthenticated, with per-caller limits.

Deliberately unindexed pages carry their own noindex tag and are kept out of the sitemap. They are not listed in robots.txt, because naming a path in a file built to be crawled is the opposite of confining it.

Models, and the gateway they run through

  • Chat runs on anthropic/claude-sonnet-5.
  • Fit analysis runs on anthropic/claude-opus-5.
  • The evaluation judge runs on anthropic/claude-sonnet-5.
  • Retrieval embeddings run on @cf/qwen/qwen3-embedding-0.6b, a Workers AI model, for both the corpus and your query.

Every frontier-model call goes through Cloudflare AI Gateway, tagged with which surface made it. This site holds no model-provider API key of its own: billing and the credential both live inside Cloudflare, and a binding cannot leave it. Two other managed services are used and neither is a model — Cloudflare Turnstile for the bot check, and Cloudflare Browser Rendering to print the résumé PDF.

Prompts are versioned in git and changed by pull request, like any other code. They are not secret, and asking chat to show you its instructions will get you a polite refusal rather than the text — but the file is in the repository, and you are welcome to read it there.

Raising a concern

If something here is wrong, if an answer misrepresented a source, or if you want something you typed deleted before its window closes, email hello@ryanlindsey.me and say so. A deletion request needs enough to find the row — roughly when, and roughly what was asked.

If the claim you want to check is about a control rather than about your own data, the source is public and the risk register below is where the weaker controls are written down on purpose.

AI Risk Register

Every row is a risk this system actually carries, with the control that is actually in place rather than the one that would sound best. A row is marked when it has gone more than 90 days without a review.

7 rows · updated

One row per identified risk, with its likelihood, impact, the control in place, the owner, and how long ago it was last reviewed.
RiskLikelihoodImpactMitigationOwnerReviewed
prompt-injectionText a visitor pastes or a page the corpus contains carries instructions the model follows instead of treating as data.likelymoderateUntrusted spans are fenced, and the prompts say that fenced content is data. The fence is COMPUTED to be longer than any backtick run inside the text, which is what makes it a boundary rather than a suggestion, and it is computed for every span without exception: the chat corpus, the chat question, the fit corpus, the fit target description and both of the judge's spans. One helper, six call sites. Until 2026-09-11 the fit corpus was the exception, wrapped in a fixed three backticks — and a published post on this site carries 14 fenced blocks of its own, so a fit run over the corpus was sending much of it outside the fence that the "treat fenced content as data" rule depends on. It is recorded here rather than quietly dropped because the cause is worth knowing: the helper had been copied into four modules, and the fifth place that needed one got a literal instead. It now lives in one module, and a test pins the property by failing against the literal. Chat cites by number, and the guarantee is not that the model has no URLs to work with — it is shown each source's URL beside that source's number — but that nothing it writes is ever turned into a link. Its answer is inserted into the page as a text node, with no markdown pass over it, and the only links rendered are built by looking a cited number up in the list the server sent; a number with no entry in that list stays plain text. So a steered answer can type a URL into its prose and still cannot produce a link to anywhere. A fit report does carry URLs in its structured output, and any that do not resolve to a document the model was shown are dropped before the report is stored. An adversarial case in the chat eval suite exercises the boundary, but that suite runs by hand before a merge rather than in CI, because it needs a deployed endpoint and a scoped credential and this project's CI holds neither by design. The case is also not deterministic: it passed twice and failed twice across four runs of the same prompt, so a green run is evidence about that run and not a guarantee about the boundary.Ryan Lindsey
citation-fabricationA cited claim points at a source that does not say it, or does not exist.possiblemajorFit reports drop any citation whose URL is not in the set of documents the model was shown, and store how many were checked and how many were dropped beside the report itself. Chat answers can only cite numbers in the list they were given; a number outside it renders as plain text rather than a link, and is counted on the transcript. Both counts are asserted at zero on the golden sets by the eval suite, which is run by hand before a merge rather than in CI, so that assertion is only as current as the last run somebody performed.Ryan Lindsey
private-tier-disclosureInformation held for a specific audience becomes visible on a public surface — through a tool listing, an error that differs from a dead route's, a metrics page, or an answer the model volunteers.unlikelymajorGated tools are registered per grant, so an unauthenticated listing cannot name one. Gated documents live in a separate bucket whose binding the public code path does not declare, which makes the separation a partition rather than a filter. The unlisted routes answer a byte-identical copy of the site's own 404, compared as a whole response — status, body and every header — against two dead paths by a test that runs in CI, alongside a static scan of every tracked file under the scanned roots for the vocabulary this rule exists to keep off the public surface — those roots cover what ships and deliberately exclude the test directory, which enumerates that vocabulary in order to forbid it. What does not run in CI is the eval suite that probes the deployed public chat with eight questions and refuses to pass on a confirmation, a denial or an inference; that is run by hand before a merge. The published metrics page filters to the public tier in SQL rather than in its template, which is the difference between a page that cannot leak a row and one that leaks the first time somebody adds a section and forgets a condition; it imports no list of gated tool names, because a denylist by name would be correct only until the next tool is added; and a test plants a private-tier row before rendering and asserts that neither the gated tool name nor the audience label reaches the page.Ryan Lindsey
token-leakageA scoped access token is forwarded, logged or otherwise reaches someone it was not issued to.possiblemoderateTokens are scoped, expiring and revocable by one row update in a registry the server consults on every call; the token's value is never stored, only its claims. Pages that carry one in the URL send Referrer-Policy no-referrer as both a response header and a meta tag, which is what keeps the token out of the Referer on the bot-check script's own request to a third-party origin. Every gated tool call is audited with the token's id, so revocation can be followed by an exact answer to what that token read. One read is deliberately not covered by that trail and is named rather than glossed: a fit report permalink is unguessable and is itself the capability, so it takes no token, produces no audit row, and stays readable by whoever holds the link until its retention window closes.Ryan Lindsey
cost-blowoutInference spend runs away, through enthusiasm or through abuse.possiblemoderatePer-caller limits are enforced by a strongly consistent Durable Object rather than by the platform's own rate-limiting binding, which was measured serving 150 strictly sequential calls against a 60-per-minute bucket without refusing once. A global daily cap of 500 chat messages sits on the same mechanism, and it is the only daily cap there is — fit analysis, the judge and retrieval have per-caller limits and no global one. Both browser forms carry a bot check, and only one of them is verified where the spend happens: the chat form's token is checked on the Worker that makes the model call, while the fit form's is checked on the Worker that served the form, after which the analysis call is gated by a scope check, the limiter and the breaker. A budget breaker is read before a chat turn or a fit run spends anything and refuses when it cannot be read, which is the safe answer to not knowing whether the budget is exhausted — but it is not read by the judge, and not by the embedding call retrieval makes before a chat turn reaches it, so it does not stop everything. The breaker is a flag an operator sets by hand: nothing in this repository measures spend and trips it, so it is a kill switch rather than an automatic cut-off, and the caps above are what bound an unattended day. The gateway's own thirty-day spend is published on that same operations page, so an unexpected figure is visible on a page rather than discovered on an invoice — though that read covers only the calls that name the gateway, which is chat, fit and the judge, and not the embedding calls retrieval makes.Ryan Lindsey
tone-and-refusalThe chat persona embarrasses its author — by flattering, by lecturing, or by declining in a way that reads as evasive.possiblemoderatePrompts are versioned in git and changed by pull request, so a change to how this thing speaks is reviewed the way a change to its code is. The eval suite scores grounding, honesty and tone with an LLM judge against written criteria. Nothing mechanical blocks a merge on that score: the suite runs by hand against a deployed endpoint, because CI holds no credential that could reach one, and at the last full run it stood at 13 of 16 cases rather than green. The control is a person reading a run before merging, which is weaker than a gate and is what is actually in place.Ryan Lindsey
transcript-retentionText visitors typed is kept longer than the policy says, or kept after it should have gone.unlikelymoderateRetention windows are one table-driven constant, read by the cron that enforces them. The policy page does not read that constant; instead a test that runs in CI asserts, per table, that the page names that table beside the window the constant sets — so a window changed in the code without changing the page turns the build red, and adding a table to the constant without publishing its window does the same. What the test does not cover is a second, contradictory sentence added elsewhere on the page. The sweep has a daily cron slot of its own and logs one line naming every table and its row count, so "deleted nothing" and "did not run" are distinguishable from outside. No test proves the trigger fires; that is confirmed against the deployed Worker.Ryan Lindsey