Manifest and discovery files
1. manifest.json (stable, schema-versioned)
Location: repository root (committed) and https://loremfile.dev/manifest.json (deployed copy, identical bytes).
1.1 Top-level object
{
"$schema": "https://loremfile.dev/schema/manifest-v1.json",
"schema_version": 1,
"catalog_version": "1.0.0",
"generated_at": "2026-10-01T12:00:00Z",
"base_url": "https://loremfile.dev/",
"license": "CC0-1.0",
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
"source": "https://github.com/kumarprabhashanand/loremfile",
"toolchain_image": "ghcr.io/kumarprabhashanand/loremfile-toolchain@sha256:<digest>",
"count": 416,
"total_bytes": 1060000000,
"formats": ["avif", "bin", "csv", "..."],
"fixtures": [ { "...": "see 1.2" } ]
}
1.2 Fixture entry
{
"path": "pdf/a4-3pages.pdf",
"url": "https://loremfile.dev/pdf/a4-3pages.pdf",
"format": "pdf",
"ext": "pdf",
"mime": "application/pdf",
"bytes": 12345,
"sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"size_class": "free",
"phase": 1,
"description": "A4 portrait, 3 pages of Lorem Ipsum body text with page numbers. Helvetica core font, no embedded fonts.",
"tags": ["document", "multi-page", "text"],
"edge_case": false,
"props": { "pages": 3, "page_width_pt": 595.28, "page_height_pt": 841.89, "encrypted": false, "pdf_version": "1.7" },
"generator": "pdf.basic",
"generator_params": { "pages": 3, "page_size": "A4", "orientation": "portrait", "page_numbers": true },
"added_in": "1.0.0",
"status": "active",
"deprecated": false,
"supersededBy": null,
"notes": null
}
Field rules:
| Field | Type | Rule |
|---|---|---|
path |
string | Unique. Matches URL grammar in 03-http-contract.md §1. Does not change. |
url |
string | base_url + path. |
format |
string | First path segment. |
ext |
string | Everything after the first dot of the last path segment (tar.gz, not gz; m3u8 for hls/720p-10s/index.m3u8). |
mime |
string | Exact Content-Type served. From the format's default in the catalog unless overridden per fixture (e.g. charset). |
bytes |
integer | Exact size. Frozen. |
sha256 |
hex string (64) | Frozen. |
size_class |
enum | exact (bytes equal nominal), boundary (nominal ± 1 exactly), approx (within ±5 % of nominal), free (no size claim in name). |
phase |
integer | 1–4. |
description |
string | One or two sentences a human or agent can use to decide fit. ≤ 300 chars. |
tags |
string[] | Lowercase; controlled vocabulary in catalog/_tags.yaml. |
edge_case |
boolean | true for anything under edge/ and for format-dir fixtures flagged as unusual (e.g. txt/control-characters.txt). |
props |
object | Measured by validators, format-specific (see §1.3). The measured values cannot change, because the bytes they were measured on cannot: sha256 is frozen, so pages or rows is settled for good. What a prop is called, and the wording of one that is a sentence, may be corrected — a correction is a changelog entry and a new catalog version, never a silent rewrite. |
generator, generator_params |
string, object | Enough to regenerate. Informational; may change if a generator is refactored (bytes must not). |
added_in |
semver | Catalog version that introduced it. |
status |
enum | active, removed (legal takedown tombstone, see §1.5). |
deprecated, supersededBy |
boolean, string/null | See immutability policy. |
1.3 props by format (what validators measure)
| Formats | Keys |
|---|---|
pages, page_width_pt, page_height_pt, encrypted, pdf_version, has_outline, has_images, and for a PDF with an AcroForm only, form_fields and filled_fields (a filled field must carry its appearance stream) |
|
| png, jpg, gif, webp, avif, bmp, tiff, ico | width, height, mode (RGB, RGBA, L, P, CMYK, I;16), frames, animated, progressive, exif_orientation (jpg), sizes (ico) |
| heic | width and height (the primary item's ispe, which heif-convert's decode must match), mode (decoded), major_brand, compatible_brands, primary_item_type (hvc1), items, format |
| svg | width, height, viewbox |
| mp4, webm, mkv, mov, avi, ogv, ts, hls | duration_ms, width, height, fps, vcodec, acodec, audio_streams, subtitle_streams, rotation, bitrate_kbps (hls: segments, target_duration_s) |
| mp3, wav, flac, ogg, opus, m4a, aac, aiff | duration_ms, sample_rate, channels, bit_depth (pcm), bitrate_kbps, codec, tags (bool) |
| docx | paragraphs, tables, images, sections, has_headers_footers |
| xlsx | sheets, sheet_names, rows and columns (first sheet, rows incl. header), formulas (every sheet; each must carry a cached value), images, and cross_sheet_formulas only where a formula reads another sheet |
| pptx | slides, has_notes, aspect (16:9/4:3) |
| txt, md, html, css, js, rtf, srt, vtt, log, sql, ini | encoding, bom, line_ending (lf/crlf/cr/mixed/none), lines, max_line_bytes |
| csv, tsv | plus rows (data rows), columns, delimiter, has_header, quoted_fields, and the two causes of quoting reported apart: quoted_delimiter (a field holds the delimiter) and quoted_newline (a field holds a line break) |
| json, ndjson, geojson, har, ipynb, jsonld | top_type (object/array/string…), items (array length or ndjson lines), max_depth |
| xml, rss, atom, kml, gpx | root_element, encoding, has_dtd, namespaces |
| yaml, toml | documents (yaml), top_keys |
| parquet, avro, arrow | rows, columns, compression (parquet), row_groups |
| sqlite | tables, rows_total, page_size, journal_mode, has_fts |
| zip, 7z | entries, directories, method (stored/deflate/lzma2), encrypted, zip64, comment |
| tar (+gz/bz2/xz/zst) | entries, compression, has_symlinks, pax |
| gz, bz2, xz, zst | members (gz), original_bytes |
| ttf, otf, woff, woff2 | glyphs, flavor, family_name, units_per_em |
| bin | pattern (shake256, zeros, ones, incrementing) |
| ics | events, has_vtimezone, has_rrule |
| vcf | cards, version |
| eml, mbox | messages, parts, attachments, has_html |
| epub | version, chapters, images |
| pem, der | kind (certificate/csr), algorithm, not_before, not_after |
| wasm | sections, exports |
| edge/* | defect, intended_format, bytes, damage, outcome, compare_with, and derived_from for the six that were cut or copied from a published fixture; plus fraction (truncated) and hostile_entries (hostile-name). §1.3.1 describes the four that are about the breakage. defect is a closed enum, and it tells the validator what to assert: zero-byte (bytes == 0), truncated (bytes == floor(fraction × source bytes) with fraction from the catalog's edge block, default 0.5 — pdf-truncated-60pct.pdf uses 0.6 — and prefix-equal to the source), mismatched-extension (bytes equal the source fixture; magic bytes match the source format, not the extension), magic-prefix (bytes == the magic value from the edge block followed by the source fixture's bytes), invalid-syntax (the format's parser raises), invalid-encoding (strict decode with the declared charset raises), nonstandard (strict parser raises, lenient parser succeeds), bom (decodes after stripping the BOM; parser succeeds on the stripped text), stress (parses; the recorded props document the depth/size), hostile-name (parses; at least one entry name contains ../), polyglot (both the announced format's reader and the one named by edge.also_valid_as accept the bytes; the prop also_valid_as records the second format). Only fixtures under edge/ carry these; unusual-but-valid fixtures elsewhere (txt/control-characters.txt, txt/nul-bytes.txt) have edge_case: true and ordinary props |
1.3.1 The four edge props (what is wrong, and what to expect)
A broken file is only useful if you know how it is broken. Four props say so, and none of
them is written by hand — loremfile validate measures or derives each one from the bytes
and refuses the fixture when the answer disagrees with the catalog.
| Prop | What it is |
|---|---|
damage |
One sentence about the bytes: where the file stops, which structure is missing, where a parser gives up. Composed from what the validator measured, so every number in it was counted on the file it describes. |
outcome |
must-fail, may-recover or varies — see below. |
compare_with |
The valid fixture of the same announced type. Feed your reader both: a reader that refuses everything is not the same as one that refuses this. |
derived_from |
The published fixture this one was cut or copied from, present on the six that have one and absent on the other thirteen. The validator reads that file and checks the relationship: a truncation's length must equal the recorded fraction of it and be a prefix of it, and a mislabelled file must equal it byte for byte. |
outcome describes the file, not your reader. It is derived by offering the bytes to the
reader for the type the file announces and, when that refuses them, to a tolerant reading
the format itself permits:
must-fail— nothing conforming gets anything out of it. An empty JSON file has no value to return, and a trailing comma has no reading RFC 8259 allows. A parser that succeeds here is wrong.may-recover— part of the content survives and a conforming reader may return it: a PDF reader rebuilding a lost cross-reference table, a JPEG decoder painting the scan lines that arrived, U+FFFD substitution for ill-formed UTF-8.varies— readers legitimately disagree. Either one takes the file whole while another refuses it (a mislabelled file read by content rather than by name, a BOM that RFC 8259 lets a parser ignore), or two take it whole and disagree about what it is — a polyglot, where nothing in the bytes decides the type.
may-recover and varies mean a parser that recovers is not wrong. Only must-fail
says failure is the required behaviour, so only must-fail belongs in a test that asserts
your code refuses the file. For the other two, assert that your code does not crash, lose
data silently or hang — not that it fails.
1.4 JSON Schema
src/loremfile/schema/manifest-v1.json (deployed at /schema/manifest-v1.json). Draft 2020-12. For status: active the required fields are exactly those in §1.2 except notes; additionalProperties: false so drift is caught. For status: removed the entry is a tombstone (§1.5) validated by an if/then branch. CI validates the manifest on every run (tests/unit/test_manifest.py). The schema document is itself immutable once published; a new schema is manifest-v2.json and bumps schema_version.
1.5 Tombstone entry (status: removed)
{ "path": "html/example.html", "format": "html", "ext": "html", "mime": "text/html; charset=utf-8",
"bytes": 1234, "sha256": "…", "status": "removed", "removed_at": "2027-03-01T10:00:00Z",
"reason": "Legal request #12 (summary)", "added_in": "1.2.0", "description": "…", "phase": 1 }
Exactly these fields, nothing else; url is omitted so clients that iterate url skip it. sha256/bytes stay so archives can still be verified. sha256sums.txt, formats.json, {format}/index.json and llms-full.txt list active fixtures only; the site shows a tombstone line on the format page.
2. sha256sums.txt
GNU coreutils format, one line per active fixture, sorted by path:
9f86d0818…a08 pdf/a4-3pages.pdf
Two spaces between hash and path, LF line endings, UTF-8, no BOM, trailing newline. Generated from the manifest; never hand-edited.
3. formats.json
{ "catalog_version": "1.0.0", "formats": [ { "format": "pdf", "count": 14, "total_bytes": 23456789, "url": "https://loremfile.dev/pdf", "index": "https://loremfile.dev/pdf/index.json", "mime_default": "application/pdf" } ] }
4. {format}/index.json
Same shape as manifest.json but fixtures filtered to one format (active only), count and total_bytes are the per-format values, and there is no top-level formats array. Stored as _formats/{format}.json and served at this URL by a rewrite, because {format}/ is a locked prefix (ADR-032).
5. llms.txt and llms-full.txt
Follow the llmstxt.org layout (H1 title, blockquote summary, H2 sections with link lists).
llms.txt (short, ≈ 2 KB):
# loremfile.dev
> Free, CC0, hotlink-friendly sample files and test fixtures for every common file type. Stable URLs, open CORS, byte-range support, SHA-256 manifest. No ads, no signup, no rate-limit keys.
## How to use
- Every fixture: https://loremfile.dev/{format}/{name} — see the manifest for all paths.
- Machine-readable list of everything: https://loremfile.dev/manifest.json (JSON, includes sha256, bytes, mime, measured properties)
- Per-format lists: https://loremfile.dev/{format}/index.json
- Verify: https://loremfile.dev/sha256sums.txt
## Formats
- [PDF](https://loremfile.dev/pdf): A4/Letter, 1–100 pages, images, tables, links, outline, encrypted, exact-ish sizes 1 MB / 10 MB
- [Images](https://loremfile.dev/png): png, jpg, gif, webp, avif, svg, bmp, tiff, ico …
...
## Rules
- Query strings are ignored. Stay under 30 requests/second per IP.
- License: CC0 1.0 for all fixtures. Source: https://github.com/kumarprabhashanand/loremfile
llms-full.txt (≈ 60–100 KB): the same header, then for each format a table of every fixture: path, bytes, one-line description, key props. Regenerated from the manifest on every deploy.
Neither file ever lists /legal/imprint or /legal/privacy (ADR-016 amendment, ADR-028). Neither lists legal pages today; this is a rule the builder must keep, not a removal.
6. robots.txt
User-agent: *
Content-Signal: search=yes, ai-input=yes, ai-train=yes
Allow: /
User-agent: GPTBot
User-agent: OAI-SearchBot
User-agent: ChatGPT-User
User-agent: OAI-AdsBot
User-agent: ClaudeBot
User-agent: Claude-User
User-agent: Claude-SearchBot
User-agent: CCBot
User-agent: PerplexityBot
User-agent: Perplexity-User
User-agent: Bytespider
User-agent: meta-externalagent
User-agent: meta-externalfetcher
User-agent: Amazonbot
User-agent: Diffbot
User-agent: MistralAI-User
User-agent: DuckAssistBot
User-agent: AgentDataBot
User-agent: BixelBot
User-agent: CloudflareBrowserRenderingCrawler
User-agent: Kimi-Agent
User-agent: qodercli
User-agent: KeenableBot
User-agent: Keenable-User
User-agent: Google-Extended
User-agent: Applebot-Extended
Content-Signal: search=yes, ai-input=yes, ai-train=yes
Disallow: /legal/imprint
Disallow: /legal/privacy
Sitemap: https://loremfile.dev/sitemap.xml
AI crawlers are deliberately allowed (the audience includes agents). Raw fixtures carry X-Robots-Tag: noindex; pages are indexable — except the two legal pages that name the operator (ADR-016 amendment, ADR-028).
User-agent: *keepsAllow: /. Google honoursnoindexonly on a page it is "not … blocked by a robots.txt file"; disallowing the legal pages for everyone would stop search engines from ever seeing thenoindexthat keeps them out.- The named group replaces the
*group for those crawlers, and disallows only the two paths — everything else stays allowed for them. Each token is checked against its vendor's own documentation, and no token is added without that check: OpenAI documentsGPTBot,OAI-SearchBot,ChatGPT-UserandOAI-AdsBot("OAI-AdsBot only visits pages submitted as ads, and the data collected by OAI-AdsBot is not used to train generative AI foundation models"), and says ofChatGPT-Userthat "robots.txt rules may not apply" to user-initiated actions; Anthropic documentsClaudeBot,Claude-UserandClaude-SearchBotas robots.txt user agents; Google documentsGoogle-Extendedas a robots.txt token that "does not impact a site's inclusion in Google Search". Google-ExtendedandApplebot-Extendedsend no requests of their own. Google saysGoogle-Extended"doesn't have a separate HTTP request user agent string"; Apple saysApplebot-Extended"does not crawl webpages" and only controls how Applebot's data is used. Both belong here and never in a user-agent-matching rule, where they could not fire.- Checked against each operator's own documentation (2026-09-22), with the token in the casing that operator publishes:
CCBot(Common Crawl,CCBot/2.0),PerplexityBotandPerplexity-User(Perplexity; the second "generally ignores robots.txt", which is why the WAF rule matters),meta-externalagentandmeta-externalfetcher(Meta publishes them lower-case in the user agent),Amazonbot(compatible; Amazonbot/0.1),Diffbot,MistralAI-User(Mistral also runsMistralAI-IndexandMistralAI-Training),DuckAssistBot(DuckAssistBot/1.2) andApplebot-Extended.Bytespideris the one token vouched for by someone other than its operator: ByteDance publishes no crawler documentation, and Cloudflare's AI Crawl Control bot reference lists it asBytespider | ByteDance | AI Crawler(read 2026-09-22). - Four proposed tokens are deliberately absent, because nobody documents them.
anthropic-aiandclaude-web: Anthropic's crawler page names onlyClaudeBot,Claude-UserandClaude-SearchBot.cohere-ai: Cohere's own page says it runs no crawling bots "at this time" and names none.archive.org_bot: the Internet Archive's help pages do not name it, though a verified archiver is refused by category at the edge (08§5.7).ia_archiver: that token belongs to Alexa Internet, retired in 2022, so a clause for it could not fire. Adding any of them is a decision to drop this section's rule, not a detail. - Five more, weighed 2026-09-28 (
crawler-watch, old issue #27), all five refused. The evidence is not equal across them and the record says so.CloudflareBrowserRenderingCrawleris the only one its operator documents: Cloudflare publishes the user agent asCloudflareBrowserRenderingCrawler/1.0, "not customizable", for Browser Rendering's/crawlendpoint, and calls it a verified bot that honours robots.txt (read 2026-09-28). It is verified under intermediary agent, which is not one of the four categories this rule matches, so the token clause is what refuses it.qodercliis Qoder's terminal coding agent; their documentation names the command but publishes no user agent, so the bare token is what the rule matches.Kimi-Agentis Moonshot AI's browsing agent and has no crawler page at all.AgentDataBotandBixelBotare listed with the operator "unclear at this time", so there is no operator to check them against — the list is the only source, as it is forBytespider. qodercliandKimi-Agentare refused as user-directed agents, not as crawlers. Both fetch because a person asked them to, which is the same category asChatGPT-User,Claude-UserandPerplexity-User— already refused here since 2026-09-22. The two pages name the operator; the rule is about who ends up holding that name, not about why they came. Being user-directed is a reason robots.txt may not be obeyed, which is exactly why the WAF rule exists.- Two more, weighed 2026-10-07 (#51), both refused, both documented by their operator. Keenable publishes its agents on its own crawler page, with full user-agent strings:
KeenableBot(Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; KeenableBot/1.0; +https://keenable.ai/bot)) "collects publicly available pages to build and maintain the Keenable search index", which answers queries from AI agents and applications; andKeenable-User(the same string withKeenable-User/1.0) "identifies requests we make on behalf of a person, or an agent acting for one … It does not crawl the web automatically."KeenableBotcame fromcrawler-watch.Keenable-Useris refused under the rule above for user-directed agents (qodercli,Kimi-Agent), and it is not on the crawler list the watch reads, so the weekly watch would never have raised it. - What the watch covers, and what it cannot.
crawler-watchreads a list of crawlers. User-directed agents are mostly not on it, so they have to be found in the operator's own documentation, asKeenable-Userwas, next to the crawler that brought the operator to our attention. When a new operator is weighed, read its crawler page for every token it publishes, not only the one the watch named. - robots.txt asks; a WAF custom rule refuses. Every token in this group except
Google-Extendedis also matched by the custom ruleloremfile_legal_pages_ai_agents(08§5.7, ADR-030), which answers403on the two paths.tests/unit/test_legal_pages.pykeeps the two lists equal. - The limit. RFC 9309: robots.txt is "not a form of access authorization". These groups ask; they cannot enforce (
13§3b). Content-Signalstates what the content may be used for: search, AI input and AI training are all allowed, which is what CC0 already grants. Syntax as contentsignals.org publishes it (read 2026-09-21): a group member line before the group's rules,key=yes|nopairs separated by commas. It is in both groups because a crawler obeys only its most specific group (RFC 9309 §2.2.1); in the*group alone it would never reach the AI crawlers the named group matches. It grants nothing on the two disallowed paths: a signal is about use, and those paths are not to be fetched at all.
7. sitemap.xml
Every discovery file carries X-Robots-Tag: noindex, and that is not a bug to fix.
The files_headers rule matches any path containing a dot, so sitemap.xml,
robots.txt, llms.txt, manifest.json, search-index.json, sha256sums.txt,
formats.json and security.txt all receive it alongside the fixtures. It looks alarming
next to a Search Console error and it is the wrong suspect: Google's own sitemap
troubleshooting lists robots.txt blocking, manual actions, 404s, server errors and low
crawl demand as the causes of "Couldn't fetch" — noindex is not among them, and a
noindex sitemap is still fetched and read.
The observation that prompted this (2026-09-17). A Search Console submission reported
"Couldn't fetch" on a property verified minutes earlier, while the file served 200 to
Googlebot with 92 URLs. Decision: no header change; re-check after a few days, because
a just-verified property commonly reports that and resolves itself. This is written down
so the rule is not "fixed" later on a theory the documentation does not support — changing
files_headers to carve out the discovery files would alter the headers on every fixture
path, which is a real risk taken against an imagined cause.
The decision stands after 2026-09-21. Four display assets — favicon.ico, apple-touch-icon.png, /assets/og.png, /assets/mark.svg — were taken out of the noindex rule that day (08 §5.3), because they exist to be shown by search engines and social platforms. That is not precedent for the discovery files: a sitemap does not need to be indexable to be fetched, and nothing in Google's documentation says otherwise.
Lists the home page, every format page, docs pages, legal pages except /legal/imprint and /legal/privacy, and the changelog. Does not list raw fixtures. lastmod = the committer date of the built commit (07 §4), never the clock. Under 50 MB / 50,000 URLs, so a single file suffices.
8. .well-known/security.txt (RFC 9116)
Contact: https://github.com/kumarprabhashanand/loremfile/security/advisories/new
Contact: mailto:security@loremfile.dev
Expires: <one year from deploy, RFC 3339>
Preferred-Languages: en
Canonical: https://loremfile.dev/.well-known/security.txt
Policy: https://loremfile.dev/docs/security-policy
Expires is regenerated on every site build (committer date of the built commit + 365 days); the daily health check warns 30 days before expiry, which can only happen if no commit was deployed for 11 months.
9. schema/manifest-v1.json
Deployed copy of the JSON Schema (§1.4). application/schema+json, immutable cache headers.
10. Structured data on pages
JSON-LD per page type (single source of truth; 07 §3 refers here): home → WebSite + SoftwareSourceCode (pointing at the repository); format page → Dataset (name, description, license: CC0, distribution with contentUrl and encodingFormat per active fixture, isAccessibleForFree: true) + BreadcrumbList; docs and legal pages → TechArticle + BreadcrumbList, except /legal/imprint and /legal/privacy, which carry no JSON-LD at all (ADR-028). No Organization node.
11. Agent discovery
Four static standards, checked by Cloudflare's agent-readiness scanner. None of them lists or links either legal page, and site build refuses one that does (site/checks.py).
Content-Signalin robots.txt (§6).Linkon every page (RFC 8288), set by thesite_pages_headersrule:</llms.txt>; rel="describedby", </.well-known/api-catalog>; rel="api-catalog". Registered relation types only —sitemapis not in the IANA registry, RFC 8288 requires an unregistered type to be a URI, and robots.txt already names the sitemap. The same rule matches/.well-known/api-catalog, because RFC 9727 §2 requires itsHEADto carry the relation./.well-known/api-catalog(RFC 9727), served asapplication/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"— the key has no extension, soroutes.content_typenames it rather than defaulting to HTML. One linkset entry per data file an agent can use:manifest.jsonwith its schema asservice-desc,sha256sums.txt, and every{format}/index.json, each withllms.txtasservice-doc. A per-format index has noservice-desc: it lacks the manifest'sformatsarray, so the manifest schema does not describe it./.well-known/agent-skills/index.json(Agent Skills Discovery v0.2.0) listing one skill,find-and-verify-fixtures(site/agent-skills/): find a fixture by format, fetch it, verify it against the manifest hash. The index carries$schema, and the skill entry'sdigestis the SHA-256 of theSKILL.mdbytes, computed at build.
12. IndexNow
Each deploy tells IndexNow which site pages it changed: one POST https://api.indexnow.org/indexnow with host, key, keyLocation and urlList, after verify-live has seen the pages served (09 §3.2). src/loremfile/infra/indexnow.py; the command is loremfile indexnow.
- Who reads it. The participants indexnow.org lists in
searchengines.json(read 2026-10-02): Bing, Yandex, Seznam, Naver, Yep, the Internet Archive and Amazon. One submission to the shared endpoint reaches all of them. Google does not take part. It learns about pages fromsitemap.xml(§7) and its own crawl, and nothing here changes that. - Why, when Crawler Hints is on. Cloudflare's Crawler Hints, enabled 2026-10-01, already sends IndexNow signals, inferred from cache misses. This names the pages a deploy actually wrote, read from the site upload's own report, so a page is announced once, when it changed, rather than whenever a cache entry happens to expire.
- What is sent. Changed pages only: the keys
upload --sitewrote that are pages (routes.is_page), as absolute URLs onloremfile.dev. Never a fixture or any other file: they are files, not pages, and the site upload never writes a fixture anyway. Never/legal/imprintor/legal/privacy. Both are kept out of every index (ADR-028), and announcing them would contradict theirnoindexheader and the WAF rule that refuses AI agents on them; two independent filters drop them, each tested on its own. At most 100 URLs: a deploy that changed more (a template change, aforce_siterun) sends the home page and the sitemap instead. - The key is public, by design. It is
config.INDEXNOW_KEY(6f5cebccab35f5982434418d5d001e12), served athttps://loremfile.dev/6f5cebccab35f5982434418d5d001e12.txtholding exactly the key and nothing else, which is how an engine checks that a submission came from the host. IndexNow's documentation says only you and the engines should know the key, but its own root-file method serves the key to anyone, and all a key allows is telling the engines that URLs on this host changed. It must never become a secret: the build could not emit the file, and every submission would be refused with403. The file is a site key like any other. The upload publishes it,verify-livecompares it byte for byte on every deploy, and nothing deletes it, because site keys are never removed (only tombstoned fixtures are,09§5). It is not insitemap.xml, which lists pages, and a build check refuses it there. - It never fails a deploy.
200("URL submitted successfully") and202("URL received. IndexNow key validation pending.") mean accepted. Any other answer, or none, is logged with its status, and the command still exits 0; the step iscontinue-on-errorbesides. An engine refusing a notification changes nothing about what was published. A dry run, or an upload that wrote fewer keys than it planned, announces nothing, because which pages changed is then not known.