DEV Community

Cover image for Give your book a machine-verifiable identity: JSON-LD, llms.txt, SHA-256 ledgers and Ed25519 signatures
TK Lin
TK Lin

Posted on Originally published at km.idaeo.ai

Give your book a machine-verifiable identity: JSON-LD, llms.txt, SHA-256 ledgers and Ed25519 signatures

I published a book for free in four languages (Traditional Chinese, Simplified Chinese, English, Japanese) and four formats each: a web edition, a standard PDF, an A5 PDF and an EPUB. That is 12 downloadable files plus four web editions.

Right after release I noticed a gap. There was no single place where a person or a machine could answer three questions:

  1. Which book is this? Are the Japanese EPUB and the English PDF the same work, or two different books?
  2. Which revision do I have? The book gets revised, and page numbers differ between formats and between revisions.
  3. Is this file the one the author released? Byte for byte.

So next to the pages people read, I built one page whose only job is to let you check. We call it the book's IDA (identity data). This post walks through the implementation so you can reproduce it for your own book, documentation set or dataset.

The live pieces:

  • https://km.idaeo.ai/idaeo/book — the identity page (?lang=en | ja | zh-Hans)
  • https://km.idaeo.ai/idaeo/book/llms.txt and brief.txt — plain-text indexes for LLMs
  • https://km.idaeo.ai/idaeo/book/releases/2026-10-25-r11.json — the ledger for this revision, plus a .sig
  • https://km.idaeo.ai/idaeo/book/releases.json — the index of all revisions

Why AI needs a verifiable bibliography

When an AI system summarizes or cites a book, it is working from whatever it crawled. A human can look at the colophon and decide. A machine needs explicit handles:

  • a stable ID for the work that does not change between revisions,
  • a list of editions and formats that point back to that work,
  • a list of revisions with dates and persistent identifiers,
  • hashes and a signature so a given file can be tied to a given revision.

We split this into layers, from shortest to most detailed:

Layer What it holds
L0 brief.txt About a dozen lines: titles in four languages, author, revision, license, citation, data limits
L1 llms.txt Every entry point with correct file names, ledger URLs, how to verify
L2 identity page Embedded JSON-LD: one work, four language editions × four formats
L4 ledger One never-overwritten JSON per revision: size and SHA-256 of all 12 files, Ed25519-signed

(L3 reuses the book's existing machine-readable data such as facts.json.)

Step 1: JSON-LD with one #work and four editions

The core design rule: one work, one edition per language. We use schema.org Book at two levels and link them both ways with workExample and exampleOfWork.

  • Work node: @id = https://km.idaeo.ai/idaeo/book#work
  • Edition nodes: @id = https://km.idaeo.ai/idaeo/book#ed-{zh-Hant|zh-Hans|en|ja}
  • Each edition's encoding lists four MediaObjects: HTML, PDF, A5 PDF, EPUB

Here is the JSON-LD the page emits, trimmed to the English edition:

{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "WebPage",
      "@id": "https://km.idaeo.ai/idaeo/book#page",
      "url": "https://km.idaeo.ai/idaeo/book",
      "mainEntity": { "@id": "https://km.idaeo.ai/idaeo/book#work" }
    },
    {
      "@type": "Book",
      "@id": "https://km.idaeo.ai/idaeo/book#work",
      "name": "IDAEO:AI 時代的行銷戰略",
      "alternateName": ["IDAEO: Market Strategy for the AI Era", "IDAEO:AI時代の市場戦略", "IDAEO:AI 时代的营销战略"],
      "author": { "@id": "https://tklin.washinmura.jp#person" },
      "publisher": { "@id": "https://idaeo.ai/#org" },
      "copyrightHolder": { "@id": "https://tklin.washinmura.jp#person" },
      "datePublished": "2026-10-11",
      "dateModified": "2026-10-25",
      "version": "第1版第11次改訂",
      "license": "https://creativecommons.org/licenses/by/4.0/",
      "isAccessibleForFree": true,
      "identifier": [
        {
          "@type": "PropertyValue",
          "propertyID": "DOI",
          "value": "10.5281/zenodo.23691711",
          "url": "https://doi.org/10.5281/zenodo.23691711"
        }
      ],
      "sameAs": ["https://doi.org/10.5281/zenodo.23691711"],
      "subjectOf": { "@id": "https://km.idaeo.ai/idaeo/book/releases/2026-10-25-r11.json" },
      "workExample": [
        { "@id": "https://km.idaeo.ai/idaeo/book#ed-zh-Hant" },
        { "@id": "https://km.idaeo.ai/idaeo/book#ed-zh-Hans" },
        { "@id": "https://km.idaeo.ai/idaeo/book#ed-en" },
        { "@id": "https://km.idaeo.ai/idaeo/book#ed-ja" }
      ]
    },
    {
      "@type": "Book",
      "@id": "https://km.idaeo.ai/idaeo/book#ed-en",
      "name": "IDAEO: Market Strategy for the AI Era",
      "inLanguage": "en",
      "bookEdition": "1st edition, 11th revision",
      "exampleOfWork": { "@id": "https://km.idaeo.ai/idaeo/book#work" },
      "url": "https://km.idaeo.ai/idaeo/textbook?lang=en",
      "encoding": [
        { "@type": "MediaObject", "encodingFormat": "text/html", "contentUrl": "https://km.idaeo.ai/idaeo/textbook?lang=en" },
        { "@type": "MediaObject", "encodingFormat": "application/pdf", "contentUrl": "https://km.idaeo.ai/idaeo/book/IDAEO-en.pdf" },
        { "@type": "MediaObject", "name": "A5 PDF", "encodingFormat": "application/pdf", "contentUrl": "https://km.idaeo.ai/idaeo/book/IDAEO-en-A5.pdf" },
        { "@type": "MediaObject", "encodingFormat": "application/epub+zip", "contentUrl": "https://km.idaeo.ai/idaeo/book/IDAEO-en.epub" }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Design notes:

  • author and publisher are @id references only. The author's details live in the author's own identity record at https://tklin.washinmura.jp#person. The book does not duplicate names and bios that could drift apart.
  • identifier carries only the concept DOI. The book has no ISBN, so there is no ISBN field. "No invented identifiers" is an explicit acceptance criterion.
  • subjectOf points at the ledger, so a crawler can walk from the work to the record that describes this revision's files.
  • Two PDFs, one MIME type. The standard PDF and the A5 PDF are both application/pdf; MediaObject.name: "A5 PDF" tells them apart.

In Next.js (App Router) the edition nodes are generated from the language list:

const editions = CONTENT_LANGS.map((l) => ({
  "@type": "Book",
  "@id": `${SITE}${BOOK_PATH}#ed-${FILE_LANG[l]}`,
  name: TITLE[l],
  inLanguage: IN_LANGUAGE[l],
  bookEdition: EDITION_LABEL[l],
  exampleOfWork: { "@id": BOOK_WORK_ID },
  // encoding: HTML / PDF / A5 PDF / EPUB
}));
Enter fullscreen mode Exit fullscreen mode

A gotcha worth copying: the site's internal code for Traditional Chinese is zh, but the files are named IDAEO-zh-Hant.*. One mapping table, FILE_LANG, feeds file names, edition @ids and cover images, so they cannot disagree.

Revisions touch one place. A CURRENT constant holds the release key, date, version DOI and ledger path. Updating that constant plus adding a new ledger file is enough for the page and the JSON-LD to follow.

Step 2: llms.txt and brief.txt

JSON-LD is precise but verbose for a model reading plain text. So there are two text files.

/idaeo/book/brief.txt (L0) fits in about ten lines: the work @id, concept DOI, author, first-edition date, current revision with its version DOI, license, the book's core claim, its framework, the headline number with its limits, where to read, where to verify, and how to cite.

/idaeo/book/llms.txt (L1) is a Markdown-sectioned index:

# IDAEO: Market Strategy for the AI Era — Book IDA index (L1)
> The identity record of this book: which book it is, which revision you have, and how to verify it.

Work @id: https://km.idaeo.ai/idaeo/book#work
...
## Revisions
## Read and download (four languages × four formats)
## What the book says (chapters)
## Records
## Verify a file
## Cite
Enter fullscreen mode Exit fullscreen mode

The ## Records section is split into three buckets: first-party (our own pages), third-party confirmed (the DOIs and the GitHub release), and pending. The pending item is a receipt number from a national-library deposit request, and the file says outright that it is "not a bibliographic ID". Telling a model what something is not matters as much as telling it what it is.

Step 3: a ledger of sizes and SHA-256 hashes

Each revision gets one file, releases/<release_key>.json, and it is never overwritten. A short Python script generates it from the actual files: size, SHA-256, MD5, PDF page count, and the EPUB identifier and modified date.

for code in ["zh-Hant", "zh-Hans", "en", "ja"]:
    for fmt, name in [("pdf", f"IDAEO-{code}.pdf"),
                      ("pdf-a5", f"IDAEO-{code}-A5.pdf"),
                      ("epub", f"IDAEO-{code}.epub")]:
        b = (R / name).read_bytes()
        rec = {"lang": code, "format": fmt,
               "path": f"/idaeo/book/{name}",
               "url": f"https://km.idaeo.ai/idaeo/book/{name}",
               "bytes": len(b),
               "sha256": hashlib.sha256(b).hexdigest(),
               "md5": hashlib.md5(b).hexdigest()}
        # PDF: page count and Author presence via pdfinfo
        # EPUB: dc:identifier and dcterms:modified from the OPF
Enter fullscreen mode Exit fullscreen mode

One entry from the generated ledger:

{
  "lang": "en",
  "format": "pdf",
  "path": "/idaeo/book/IDAEO-en.pdf",
  "url": "https://km.idaeo.ai/idaeo/book/IDAEO-en.pdf",
  "bytes": 18735077,
  "sha256": "a25fa8adebb242592d647a5e9328da205d633cb513c94932096d9e010af776e1",
  "md5": "f1259262e8d658debaa6bf25b41a74c6",
  "pages": 173,
  "pdf_author_present": false
}
Enter fullscreen mode Exit fullscreen mode

Beyond the file list, the top level holds:

  • release_key, edition_label (four languages), release_date, first_edition_date
  • doi.concept and doi.version, plus archives (Zenodo record and GitHub release)
  • previous_ledger_sha256 — the hash of the previous revision's ledger, so ledgers form a chain. It is null here because this is the first ledger.
  • history_note — ledgers start at r11; earlier revisions were not recorded at release time.
  • known_issues — e.g. the PDFs in this revision have no Author metadata.
  • deployed_at_note — the server's Last-Modified header is recorded, with the caveat that it is "not proof of first publication".
  • verify — the commands, the public key URL and the key's fingerprint (public_key_fingerprint_sha256_der).

known_issues and deployed_at_note look minor, but they set the tone: the ledger separates what we can claim from what we cannot.

The index releases.json lists current and, per revision, key, ledger, signature, doi and release_date, plus the concept_doi.

Step 4: sign the ledger, not the binaries

Only the ledger JSON is signed, with Ed25519. The 12 binaries are not signed individually. The ledger already contains their SHA-256 hashes, so one valid signature over the ledger covers every hash in it. This also meant we did not have to touch the already-published r11 files.

We did not generate a new key. The ledger is signed with the same Ed25519 key as the author's identity record:

  • Public key: https://tklin.washinmura.jp/tklin-ida-key.pem

The signatures are raw 64-byte Ed25519 signatures: releases/2026-10-25-r11.json.sig and releases.json.sig.

Step 5: how a reader verifies

Check a file

shasum -a 256 IDAEO-en.pdf
# compare with files[].sha256 in the ledger
Enter fullscreen mode Exit fullscreen mode

To check all 12 at once, turn the ledger into shasum -c input:

jq -r '.files[] | "\(.sha256)  \(.path | split("/") | last)"' \
  2026-10-25-r11.json > SHA256SUMS
shasum -a 256 -c SHA256SUMS
# IDAEO-zh-Hant.pdf: OK
# ...
Enter fullscreen mode Exit fullscreen mode

A mismatch does not necessarily mean tampering. A store repackaging the file or a reader app re-saving it also changes the bytes, and the identity page says so.

Check the ledger signature

openssl pkeyutl -verify -pubin -inkey tklin-ida-key.pem -rawin \
  -in 2026-10-25-r11.json -sigfile 2026-10-25-r11.json.sig
# Signature Verified Successfully
Enter fullscreen mode Exit fullscreen mode

You need OpenSSL 3. The /usr/bin/openssl that ships with macOS is LibreSSL and does not support this. Use Homebrew's openssl@3 or similar. When I tried LibreSSL, it failed while loading the public key with unable to load Public Key.

Change one byte of the ledger and the same command should print Signature Verification Failure. "Altering one byte must fail" is part of the acceptance criteria, for the file hashes and for the signature.

Step 6: two kinds of DOI

The DOIs come from Zenodo, which issues two kinds:

Kind Value Meaning
Concept DOI 10.5281/zenodo.23691711 All versions; resolves to the latest
Version DOI 10.5281/zenodo.23691712 1st edition, 11th revision (r11) only

The work node's identifier and sameAs use the concept DOI. The citation line and the ledger's doi.version use the version DOI. The work stays the same across revisions; a citation pins one revision.

Recap

  • One @id for the work, one Book node per language edition, formats as MediaObjects in encoding
  • brief.txt (L0) and llms.txt (L1) for models that read plain text
  • A never-overwritten ledger per revision, with sizes and SHA-256 computed from the real files
  • Ed25519 over the ledger only; readers verify with shasum and OpenSSL 3
  • Concept DOI for the work, version DOI for citations
  • Do not write identifiers you do not have; put what you cannot claim in known_issues

The book used throughout this post is IDAEO: Market Strategy for the AI Era by TK Lin (林毅祥), who runs Washinmura, a lodge for rescued cats in Chiba, Japan. It is free to read in four languages under CC BY 4.0, DOI 10.5281/zenodo.23691711. Read it online at https://km.idaeo.ai/idaeo/textbook?lang=en, and find the identity page at https://km.idaeo.ai/idaeo/book?lang=en.

Top comments (0)