WPN-1 — Writing Provenance Norm, version 1.0
Status: Draft 1.0, revision 4 (informative notes under sections 10 and 11.2; optional attestation fields declared_period and statement; normative text otherwise unchanged) — reference version (English). French and Spanish translations are informative; in case of divergence, this English text prevails. Licence: Creative Commons Attribution 4.0 (CC BY 4.0). Editor: Alamosoft (France).
1. Purpose and scope
WPN-1 defines how a writing workshop records the process by which a literary text was written, and how anyone can later verify that record without trusting the workshop.
A WPN-1 attestation proves that:
1. a given sequence of workshop events existed no later than the time of a public blockchain block; 2. the sequence has not been altered since; 3. the sequence was signed by a key that the workshop had publicly registered, and had not revoked, at that time.
A WPN-1 attestation does not prove who was at the keyboard, nor anything that happened outside the workshop. Every attestation MUST display this limitation (section 10).
The key words MUST, MUST NOT, SHOULD and MAY are to be read as in RFC 2119.
2. Building blocks
WPN-1 invents no cryptography. It assembles public, widely deployed standards:
Purpose — Standard
Hashing — SHA-256 (FIPS 180-4)
Signatures — Ed25519 (RFC 8032)
Keyed hashing — HMAC-SHA-256 (RFC 2104)
Merkle trees and inclusion proofs — RFC 6962 / RFC 9162, section 2.1
Canonical JSON — Subset of RFC 8785 (section 3)
Unicode normalization — NFC (Unicode Standard Annex 15)
Public timestamps and key registry — Polygon PoS smart contract (section 16); OpenTimestamps on Bitcoin
Hashes and HMAC values are written as 64 lowercase hexadecimal characters. Signatures are written as 128 lowercase hexadecimal characters.
3. Canonical JSON (WPN-JSON)
WPN-JSON is a strict subset of RFC 8785 (JCS):
- Allowed values: string, integer with absolute value below 2^53,
true,false,null, array, object. - Floating-point numbers are forbidden.
- Object keys MUST be printable ASCII strings, unique, sorted by byte value.
- No whitespace between tokens.
- Strings are UTF-8, escaped as in RFC 8785:
"and\escaped, control characters as\b \f \n \r \tor\u00xx(lowercase hex),/not escaped, U+2028 and U+2029 not escaped, all other characters emitted literally. - An empty object is written
{}, an empty array[].
A verifier MUST reject any line or object whose bytes differ from the WPN-JSON serialization of its own parsed value (this rejects duplicate keys, whitespace and alternative escapes).
4. Text normalization and text hash
Before hashing, a text is normalized:
1. Line endings \r\n and \r become \n. 2. The text is converted to Unicode NFC. 3. Trailing characters U+0020, U+0009, U+000A, U+00A0 and U+202F at the very end of the text are removed (and no others).
text_hash = SHA-256(UTF-8(normalized text)).
4.1 Extracting the text of a manuscript
A manuscript is an ordered list of chapters; each chapter has an optional title and a structured document.
1. For each chapter in order: if the title, trimmed, is not empty, it is one block; then each leaf block of the document, in document order, is one block. 2. Leaf blocks are paragraphs, headings and code blocks. Container blocks (lists, list items, block quotes) contribute their leaf blocks, in order. 3. The text of a leaf block is the concatenation of its text runs; a hard line break contributes \n. Empty leaf blocks are skipped. 4. All blocks of the manuscript are joined with \n\n, then the result is normalized (section 4).
Test vector TV-6 illustrates this algorithm.
5. Events and the event chain
Each manuscript has its own chain of events. An event is a WPN-JSON object:
`json {"data":{...},"ms":"<manuscript id>","prev":"<hash>","seq":1,"ts":"2026-09-26T08:00:00.000Z","type":"manuscript.create","v":1} `
Field — Rule
v — Integer, 1 for this version
ms — Manuscript identifier: opaque string (a ULID is RECOMMENDED), MUST NOT contain personal data
seq — Integer, starts at 1, increases by exactly 1
ts — UTC timestamp YYYY-MM-DDTHH:MM:SS.sssZ, never decreasing within a chain
type — Event type (section 6)
prev — Hash of the previous event; 64 zeros for seq 1
data — Object whose content depends on type
event_hash = SHA-256(WPN-JSON(event)). The hash of the last event is the chain head.
A verifier MUST check canonical form, v, a constant ms, consecutive seq from 1, prev links, the timestamp format and non-decreasing order, and event types.
Timestamps are declared by the workshop. The only time a verifier can rely on is the time of the blockchain block that anchors the chain (section 11).
6. Event types
6.1 Types and fields
Integers count Unicode code points after NFC normalization. ch is an opaque chapter identifier (ULID RECOMMENDED).
Type — data fields — Meaning
manuscript.create — lang, norm — Chain opened; lang is a BCP 47 tag; norm is "1.0"
import — import_id, ch, chars, content_digest — Text brought in from outside (origin imported); allowed at any seq
session.start — ch — Writing session opened on a chapter
session.end — ch, typed, pasted, ai_inserted, deleted, duration_s — Volumes observed during the session
paste — ch, chars, source (external or internal), content_digest — Text pasted
ai.request — fn, model, prompt_ver, lang, input_digest — Assistance requested
ai.response — req_seq, model_digest, output_digest — Assistance produced; model_digest identifies the exact model weights used
ai.decision — req_seq, decision (accepted, rejected, accepted_edited), chars — Author's decision
signal — kind, chars — Server-side reclassification or anomaly (section 8)
doc.snapshot — ch, doc_digest, c_author, c_ai_edit, c_ai_gen, c_other — Composition of one chapter at a point in time
milestone — n, text_hash — Version number n (1, 2, 3…) named by the author; the name itself is never recorded here
attestation.issue — att_hash, text_hash — An attestation was issued (section 10)
Implementations MAY define additional types prefixed with x-. Verifiers MUST ignore unknown x- types and MUST reject other unknown types.
6.2 Content digests
Contents (pasted text, AI inputs and outputs, imported text, documents) are never written in the chain, and never hashed with plain SHA-256, which would allow dictionary guessing of short texts. They are represented by a content digest:
content_key = HMAC-SHA-256(author_secret, "wpn1/content-key:" || ms)(section 11.1);content_digest(x) = HMAC-SHA-256(content_key, UTF-8(normalized x)), hex.
doc_digest is the content digest of the WPN-JSON serialization of the chapter document, marks included. text_hash (section 4) remains a plain SHA-256, so that a recipient can compare it with the text he received.
6.3 Assistance functions (fn)
fn — Meaning — Writes into the manuscript
proofread — Spelling, grammar and typography check — Point corrections (fix)
consistency — Contradictions between text and story bible — No
characters — Character tracking — No
timeline — Chronology check — No
tics — Repetitions and writing habits — No
summary — Chapter summaries — No
critique — Structural critique — No
research — Documentary research — No
bible_extract — Story-bible preparation from a text — No
editorial_kit — Synopsis, pitch, back-cover text — Paratext only
rewrite — Rewrites of a selected passage — Yes (ai_edit)
generate — Passage generation — Yes (ai_gen)
translate — Translation into another language version — Yes (ai_gen)
Other functions MUST use the x- prefix.
7. Origin classes and composition
Every character of the manuscript carries one origin:
Origin — Class — Displayed as
typed — typed by the author — Author — Green: "Written by the author"
fix — point correction accepted (section 7.1) — Author — Green
ai_edit — author's text rewritten by AI, then kept — AI-edited — Orange: "Edited with AI"
ai_gen — text proposed by AI, then kept (including AI translation) — AI-generated — Violet: "Written by AI"
pasted, imported, unknown — Other — Grey: "From elsewhere"
- A character retyped by the author inside an AI span becomes
typed. - Composition counts (
c_author,c_ai_edit,c_ai_gen,c_other) count the code points of text runs only (block separators and line breaks are not counted), plus chapter titles, which are counted asc_author. - Counts are computed by the workshop server from the stored documents, never from client-reported figures. Percentages are derived for display only.
- Analysis, research and brainstorming that write nothing into the manuscript have no origin class; they appear only as
ai.requestevents.
7.1 Bounds of fix
An accepted correction is fix only if it changes at most 3 words and at most 20 % of the characters of the corrected span. Otherwise it is ai_edit.
8. Server-side safeguards
A conforming workshop MUST apply at least the following rules before counting composition:
1. Mark validation on insertion. A newly inserted fix, ai_edit or ai_gen span MUST correspond to an AI output of the same manuscript; a newly inserted imported span MUST correspond to an import event; otherwise it becomes unknown. Spans already validated are not revalidated later. 2. AI reconciliation. If newly inserted text not marked as AI contains at least three consecutive 8-word shingles of an AI output of the same author, it is reclassified as ai_gen and a signal event (kind: "ai_reconciled") is recorded. Shingles are keyed with shingle_key = HMAC-SHA-256(author_secret, "wpn1/shingle-key:" || ms) and are kept for the life of the manuscript. 3. Internal paste. Pasted text counts as internal only if the server finds the same text, with the same origins, in a manuscript of the same author; it then keeps its origins and is excluded from the typing budget. Otherwise it is pasted. 4. Typing budget. Text newly inserted as typed beyond a plausible typing rate (default: 15 characters per second sustained over any 30-second window) is reclassified as unknown and a signal event (kind: "typing_rate") is recorded. 5. Unmarked insertions. Text inserted without an origin mark is unknown.
These safeguards raise the cost of misrepresentation. They cannot detect text generated elsewhere and retyped by hand.
9. Checkpoints, signatures and keys
At the end of each session, whenever an attestation is issued, and before each anchoring, the workshop signs a checkpoint:
`json {"head":"<hash>","kid":"<key id>","ms":"<manuscript id>","seq":3,"ts":"2026-09-26T09:12:45.000Z","v":1} `
signature = Ed25519(workshop secret key, WPN-JSON(checkpoint)).kid= first 16 hex characters of SHA-256 of the 32 raw bytes of the public key.- Every signing key MUST be registered in the contract of section 16 (
publishKey) before use. A revoked key (revokeKey) remains valid only for signatures covered by an anchor whose block is earlier than the revocation block. - The workshop also publishes its keys at
/.well-known/wpn-keys.json(section 12.2) for convenience; the contract is authoritative.
10. Attestation
An attestation is a WPN-JSON object signed by the workshop:
Field — Content
v, norm — 1, "1.0"
ms, seq, head — Chain position covered: the head before the attestation.issue event
text_hash — Hash of the attested text (section 4.1)
lang — Language of the manuscript
period — {"first":"<ts>","last":"<ts>"} of the covered events
sessions — Number of sessions
composition — {"c_ai_edit":n,"c_ai_gen":n,"c_author":n,"c_other":n}
paratext — Object: paratext kind (synopsis, pitch, back_cover, x-…) → origin class (author, ai_edit, ai_gen)
aids — Sorted list of fn values used (section 6.3)
signals — Object: signal kind → count
test — true for attestations issued in a test period or on a test network
issued — Issue timestamp
kid — Signing key
declared_period — OPTIONAL. {"from":YYYY,"to":YYYY}: writing period *declared by the author* when a pre-existing text was imported. Informative: the workshop records the declaration but cannot prove it (see note below)
statement — OPTIONAL. {"hash":"<sha256 hex>","lang":"<BCP 47>","template_ver":n}: the author's declaration on their honour. hash is the SHA-256 of the exact declaration text (section 4) rendered in lang from template version template_ver; the text itself is not part of the attestation. Informative: the workshop records the declaration but cannot prove its content (see note below)
sig = Ed25519(secret key, WPN-JSON(attestation)), stored beside the object, not inside it.att_hash = SHA-256(WPN-JSON(attestation)), recorded in the chain by theattestation.issueevent atseq + 1, followed by a signed checkpoint.
Informative note (revision 3, 27 September 2026; not normative).declared_periodis a statement made by the author, typically when importing a text written before the workshop. It is *not* proven by anything in this norm: what is proven is the anchored date of theimportevents (the text existed in the workshop no later than that block), the sessions and events that followed, and their anchors. A rendering that showsdeclared_periodMUST label it as declared by the author, next to the proven facts. When the field is absent, the attestation is unchanged from revision 1.
Informative note (revision 4, 27 September 2026; not normative). statement binds the attestation to the exact text of a declaration made by the account holder (identity not verified by the workshop) in one language; that original text alone engages the author. A rendering in another language MAY show a translation rebuilt from the same template version, labelled as a translation with a link to the original. Nothing in this norm proves the content of the declaration.
Every human-readable rendering of an attestation, in any language, MUST include this statement or its official translation:
This attestation describes what happened in the workshop. It does not cover anything done outside it.
A rendering MUST NOT claim or suggest that the text is "certified human", "AI-free" or equivalent, in any language, nor use a third party's certification mark.
11. Daily anchoring
11.1 Per-author secrets and derived keys
For each author, the workshop holds a random 32-byte author_secret. For each manuscript ms:
leaf_key = HMAC-SHA-256(author_secret, "wpn1/leaf-key:" || ms)content_key = HMAC-SHA-256(author_secret, "wpn1/content-key:" || ms)shingle_key = HMAC-SHA-256(author_secret, "wpn1/shingle-key:" || ms)
Only leaf_key is ever disclosed (in a proof bundle). Destroying author_secret makes all of an author's leaves and digests permanently unlinkable (right to erasure).
11.2 Batch
Once per day (UTC), and MAY additionally be on demand:
1. For each manuscript whose head changed since its last anchored leaf, compute leaf = SHA-256(0x00 || HMAC-SHA-256(leaf_key, head bytes)), where the head is covered by a signed checkpoint. 2. Sort the leaves by byte value and compute the Merkle root as in RFC 6962 (node = SHA-256(0x01 || left || right)). 3. Anchor the root:
- Polygon PoS: call
anchor(batchId, root, leafCount, periodEnd, normVersion)on the contract of section 16.batchIdstarts at 1 and increases by 1;periodEndis the Unix time (UTC, seconds) of the batch cut-off;normVersionismajor × 100 + minor(100 for 1.0). The anchor time is the timestamp of the block containing theAnchoredevent, once that block is finalized. - Bitcoin via OpenTimestamps: timestamp the 32 root bytes; the
.otsproof commits to SHA-256(root).
4. Publish the batch: batchId, root, leafCount, periodEnd, Polygon transaction hash, .ots file.
Informative note (revision 2, 27 September 2026; not normative). One batch per day covers the *whole* workshop: a single Merkle root, a single Polygon transaction and a single OpenTimestamps proof, whatever the number of authors and manuscripts. Only manuscripts whose head changed since their last anchored leaf enter the batch; a day without any writing produces no batch and no transaction, so the service issues at most about 365 anchoring transactions per year. Anchoring once per session would multiply transactions and publicly expose each author's writing rhythm without adding proof, since the event chain already commits to every session of the day. A single anchor at attestation time would prove only that the ledger existed on that date, not that the text grew over the months. A workshop MAY additionally anchor on demand when an attestation is issued.
12. Proof bundle and published files
12.1 Proof bundle
The author can download a proof bundle, a ZIP file with the extension .wpn. It is produced once the covering batch is anchored and contains:
File — Content
manifest.json — {"files":{"<name>":"<sha256 hex>",…},"norm":"1.0","v":1}
ledger.jsonl — Events from seq 1 to the anchored head, one WPN-JSON line each, \n-terminated
checkpoints.json — [{"checkpoint":{…},"sig":"<hex>"},…]
attestation.json — {"attestation":{…},"sig":"<hex>"}
inclusion.json — {"batch_id":n,"head":"<hex>","index":n,"leaf_key":"<hex>","path":["<hex>",…],"root":"<hex>","seq":n,"tree_size":n}
polygon.json — {"block":n,"chain_id":137,"contract":"0x…","tx":"0x…"}
root.ots — OpenTimestamps proof (binary)
keys.json — Same format as 12.2, keys valid at issue time
attestation.<lang>.pdf — Human-readable rendering (informative, not verified)
The anchored head in inclusion.json MAY be later than the attestation (the author kept writing); the ledger then extends to that head. Disclosing leaf_key reveals nothing about the author's other manuscripts.
12.2 Published keys
/.well-known/wpn-keys.json:
`json {"contract":{"address":"0x…","chain_id":137},"keys":[{"kid":"…","public_key":"<64 hex>","status":"active"}],"v":1} `
status is active or revoked. The contract events are authoritative.
13. Verification procedure
A verifier, with no access to the workshop, and knowing the contract address of section 16:
1. Checks manifest.json hashes. 2. Parses ledger.jsonl line by line with the checks of section 5 and obtains the head at every seq. 3. Checks that polygon.json names the contract and chain of section 16, reads the transaction and its Anchored event from any Polygon PoS node, checks batchId, root and leafCount against inclusion.json, and reads the block time (the anchor time). 4. Recomputes leaf from leaf_key and the anchored head, and verifies inclusion (RFC 9162, 2.1.3.2) against the root. 5. For each checkpoint used: verifies the signature; checks that its kid has a KeyPublished event in the contract earlier than the anchor time, and no KeyRevoked event earlier than the anchor time; checks that its head matches the ledger at its seq. 6. Verifies the attestation signature under the same key rules, recomputes att_hash, and finds the matching attestation.issue event at seq + 1, with the same text_hash, before the anchored head. 7. Verifies root.ots against Bitcoin (for example with the OpenTimestamps client), when the proof is complete. 8. Optionally, recomputes text_hash from the text received and compares.
The result is valid only if steps 1 to 6 succeed. Step 7 adds an independent witness; a pending OpenTimestamps proof does not invalidate the attestation. The date shown to users is the anchor time, not a timestamp declared in the ledger.
14. Versioning
- A new version of WPN is published as a new document (1.1, 2.0…) with test vectors.
- Every event, checkpoint and attestation states its version; a new version never invalidates proofs made under a previous one.
- The contract records
normVersionwith each anchor.
15. Test vectors
The reference implementation (wpn1-reference.php) and an independent implementation produce the following values. A conforming implementation MUST reproduce them exactly. Full values and canonical bytes are in wpn1-test-vectors.txt.
TV-1 — text normalization
- Input:
"Chapitre 1\r\n\r\nL'été où tout a commencé… \n" - Normalized:
"Chapitre 1\n\nL'été où tout a commencé…" text_hash:ce4cbedfc407edbeb68c4be4aa94dcb2b8acce884e7ac4fcb45c4c5aed84a081
TV-2 — canonical JSON
- Canonical form:
{"a":true,"data":{"chars":120,"source":"external"},"n":null,"txt":"É/\"x\"\n","type":"paste"} - SHA-256:
6f706143c8ddba3543ef93a7290d58b19b3d5d14101bf8b57c506b9e23fba6e0
TV-3 — event chain (manuscript 01J8Z0Q4R7W2X9V3B5N6M8K1PQ, chapter 01J8Z0R2C9H3P5Q7S9T1V3W5X7)
seq — type — hash
1 — manuscript.create (lang fr, norm 1.0) — 3070554ad3798aab8a929ca318e410370beb2299eea9041a11b3ae83ac088814
2 — session.start — ca5164bb9689d5531404c0ae5a3b56afadae8a53c85de82d374925698526e4b4
3 — session.end (typed 5230, pasted 0, ai_inserted 0, deleted 412, duration_s 4359) — e612e51c4ecc09d5756cc97a2cd83ea5e330cbf07ef2b6e3879f874e7162a2fc
A line with an extra space ("seq": 2) MUST be rejected.
TV-4 — checkpoint signature (RFC 8032 test key 1, for testing only)
- Public key:
d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a kid:21fe31dfa154a261- Signature:
b2e005a7c2e4fa9c4c1aef02f5ee15b6e1683226a3f3d12cbd4775faff0afd24fdd53d511f13127edf4110d20ac5b8becc0a9ad901ceaed0d9c90cd6887f3b07
TV-5 — Merkle tree (5 leaves, author_secret = SHA-256 of test-author-secret-do-not-use, manuscripts TEST-MS-A to TEST-MS-E, heads = SHA-256 of head-A to head-E)
- Root:
c0705586ae79c628f84728731bdc33acee9e876d506804a5f7958f74d22afdbf - Audit path of leaf index 2:
f24ae9b4…5ecc,f0b430d8…f5cc,f5573757…92ea - OpenTimestamps digest, SHA-256(root):
7ac539435f0c71baca8bce0d04ad48b0271cf0971f0929a53b90092cd263c31f
TV-6 — extraction and composition (one chapter titled Chapitre 1: a paragraph with a typed run and an ai_edit run, a bullet list with a pasted item, a paragraph with a typed run, a hard break and an ai_gen run, an empty paragraph)
- Extracted text:
"Chapitre 1\n\nMaïa ouvrit la porte. La nuit était tombée.\n\nUn souvenir\n\nElle dit :\n« Viens. »" text_hash:a1d99016cc7f6ec4e651063e450acc4af96db4e4f7f25e828005005544cd4fb4- Composition:
{"c_ai_edit":22,"c_ai_gen":10,"c_author":41,"c_other":11} content_digest("Un souvenir")for manuscript01J8Z0Q4R7W2X9V3B5N6M8K1PQ:05b62d9bd0aae23ab71299e6471257fcffcd2c45d930b9f90e277f45fe1e9a72
TV-7 — attestation (covers TV-3 at seq 3, TV-6 text, test key)
att_hash:44be298cde66f2a57ca5ec6e7219d7edb31794765e10f8d935f2ecf62625612d- Signature:
c52720f04e8d00ae163bad1cccd3ec1ba05e99a44149d134807eb59d2979f7e635ff0d94a20ddbdece55632a1e1a4fac6b73f95a31d78a28addbba4d3731d608
16. Deployments
Network — Chain id — Contract (WpnAnchor) — Status
Polygon PoS — 137 — [published at mainnet deployment] — Authoritative
Polygon Amoy (test) — 80002 — [published at test deployment] — Test only; attestations anchored here carry "test":true
Contract interface (Solidity, compiled with evmVersion shanghai):
anchor(uint64 batchId, bytes32 root, uint32 leafCount, uint64 periodEnd, uint16 normVersion)— selector0xabb2d768event Anchored(uint64 indexed batchId, bytes32 indexed root, uint32 leafCount, uint64 periodEnd, uint16 normVersion)— topic0x297da5d4057f6f656228075b105783787f50a5161b9c37a713f074ea56978b3dpublishKey(bytes8 kid, bytes32 publicKey)— selector0xdb1a935d; the contract checks thatkid= first 8 bytes of SHA-256(publicKey)event KeyPublished(bytes8 indexed kid, bytes32 publicKey)— topic0xf8fd73ad0edaa4188048c1a150bf768ca9697791c0c20925f85ba1bff1dee901revokeKey(bytes8 kid)— selector0x4bbf3337event KeyRevoked(bytes8 indexed kid)— topic0x77290325bd5bac07a842f9260f160a5827154105b1ea9d3217b36552a44e1a7e
A verifier MUST hard-code the authoritative contract address and chain id once published, and MUST NOT take them from a proof bundle.