PGN container (spec)
Status: draft v1 (2026-05-19) Prerequisite reading:
- identity-and-trust v0.4.1 — defines the L2 manifest format that this container embeds.
- op vocabulary §10.9 — defines the three-layer L1/L2/L3 model.
What this spec defines
Section titled “What this spec defines”A backwards-compatible way to embed an OpenFile session manifest inside a standard PGN file, producing a single self-contained artifact that:
- Opens cleanly in any chess tool that reads PGN. Lichess, ChessBase,
scid, raw
cat, GitHub renderer — all see the moves, headers, and comments. The manifest is invisible to them. - Verifies cryptographically when opened by an OpenFile-aware tool.
The manifest provides the trust frame; the PGN provides the content;
the manifest’s
pgnHashbinds them together. - Is detectable cheaply. A 16-byte peek at the start of the file is
enough to determine whether a
.pgncontains an OpenFile manifest.
The format uses PGN’s built-in %-comment-line escape mechanism (PGN
spec §6), which conforming parsers MUST skip. This makes the embedding
fully spec-compliant rather than relying on tool tolerance.
Design constraints
Section titled “Design constraints”These shaped every choice below; documented for posterity.
- PGN’s 255-character line limit (§4.3) is a hard ceiling. The
80-character soft limit is “strongly discouraged” but exceeded by
common fields like
[FEN ...]. - Single file, no archive. No zip, no tar, no MIME multipart.
- Round-trip byte stability so
pgnHashstays consistent across read/write cycles by editors that preserve line endings. - No file-extension fragmentation.
.pgnstays.pgn. (See “Why not.opgn” below.)
The %-block format
Section titled “The %-block format”A leading block of %-prefixed lines, before any PGN content:
%OPGN/1 ofm-bytes=612 ofm-encoding=b64%eyJ2ZXJzaW9uIjoib3BlbmZpbGUtbWFuaWZlc3QtdjQiLCJvd25lclB1Ympl…%S2V5IjoiN0t6bE1KdlhNZzRaeFBjdGxuTFZGMy82OHJzay9MQ3BUK0RYWlBJa…%[... more 76-char base64 lines, each prefixed with % ...]%fSwic2lnbmF0dXJlIjoiYUhtdU5GekI2bk9NWHJ0QUNuQUh6eFhPOG13QWZW…[Event "Sicilian study"][White "Carlsen, Magnus"][Black "Caruana, Fabiano"][Result "1-0"]
1. e4 c5 2. Nf3 d6 ...The meta line (first %-line)
Section titled “The meta line (first %-line)”%OPGN/<version> ofm-bytes=<bytes> ofm-encoding=<encoding>OPGN/is the magic prefix. The slash-version follows immediately with no space.<version>is a positive integer file-format version. v1 is current. Loaders MUST reject unknown versions.ofm-bytes=<bytes>declares the manifest’s length in bytes (after base64 decode). Used as a sanity check during load.ofm-encoding=<encoding>declares the encoding. v1 defines onlyb64(standard padded base64, RFC 4648). Loaders MUST reject unknown encodings.- Fields are space-separated. Key=value pairs may appear in any order after the magic, but writers SHOULD emit them in the order above for readability.
- The line as a whole MUST be ≤ 255 characters (PGN hard limit).
The manifest body (subsequent %-lines)
Section titled “The manifest body (subsequent %-lines)”- Each line is
%<chunk>\nwhere<chunk>is up to 76 chars of the base64-encoded manifest, MIME-wrapping convention (RFC 2045 §6.8). - With the
%prefix and\n, total line length is at most 78 chars — comfortably under both the 255 hard limit and the 80 soft limit. - Lines are concatenated in order, dropping the
%prefix, to recover the base64 string. - After base64 decode, the result MUST be a valid v4 OpenFile manifest
(canonical JSON). The decoded length MUST equal
ofm-bytes.
End of the %-block
Section titled “End of the %-block”The block ends at the first non-%-prefixed line. From that line
through end-of-file is the PGN content (one or more games, in standard
PGN format).
Scanning rules
Section titled “Scanning rules”Tolerant detection
Section titled “Tolerant detection”A loader scans the file from the start, collecting consecutive %-
prefixed lines. The block ends at the first non-%-line.
Within the collected %-block, the loader looks for the first line
matching %OPGN/<digits> exactly (after the % and before any space
or end-of-line). All %-lines:
- Before the
OPGN/line: treated as ordinary PGN comments (other tools may have placed them there). Skipped during manifest extraction. - The
OPGN/line itself: parsed as the meta line. - After the
OPGN/line, until the first non-%-line: form the manifest body chunks.
If no %OPGN/-prefixed line appears in the leading %-block, the
file is treated as plain PGN with no manifest (no error).
Multiple OPGN/ lines
Section titled “Multiple OPGN/ lines”A file MUST contain at most one %OPGN/<version> line in its leading
%-block. Loaders MUST reject files with multiple.
Line-ending normalization
Section titled “Line-ending normalization”For computing pgnHash, the PGN portion (from the first non-%-line
through end-of-file) is normalized to LF line endings + trailing
whitespace trimmed per line, then UTF-8 encoded.
Producers and consumers MUST apply this normalization before hashing. The on-disk file MAY use CRLF or LF; the hash is over the normalized form.
Trailing whitespace on lines is whitespace at the end of a line before the newline. Whitespace within a line is preserved.
Round-trip semantics
Section titled “Round-trip semantics”Export (exportOpgn(target, { ownerSigner }))
Section titled “Export (exportOpgn(target, { ownerSigner }))”pgnText = createGameFromTarget(target).getPgn()— get the canonical L1 form.- Normalize
pgnText(LF + trim trailing whitespace per line). pgnBytes = utf8(normalizedPgnText).manifest = await buildManifest(target, { ownerSigner, pgnText: normalizedPgnText }).b64 = base64(canonicalJson(manifest)).- Wrap
b64to 76-char chunks; prefix each with%. - Prepend
%OPGN/1 ofm-bytes=<len> ofm-encoding=b64\n+ wrapped chunks. - Append the normalized PGN text.
- Return the resulting bytes.
Import (loadOpgn(bytes, { verifier }))
Section titled “Import (loadOpgn(bytes, { verifier }))”- Decode
bytesas UTF-8. - Walk lines from the start. Collect
%-prefixed lines until the first non-%-line. Call this themetaBlock. - Within
metaBlock, find the first line starting with%OPGN/. If none: return{ pgnText: <rest>, manifest: null, valid: null }(plain PGN, no manifest). - Parse the meta line. Reject if version, encoding, or fields are malformed.
- Reject if a second
%OPGN/line appears. - Concatenate the body chunks (
%-lines after the meta line withinmetaBlock), stripping%. Base64-decode → manifest bytes. - Verify
manifest bytes.length === ofm-bytes. Reject if mismatch. manifest = parseManifest(manifestBytes).- Extract the PGN portion: bytes from the first non-
%-line through end-of-file. - Normalize the PGN (LF + trim trailing whitespace per line).
result = await verifyManifest(manifest, normalizedPgnBytes, { verifier }).- Return
{ pgnText: normalizedPgnText, manifest, valid: result.valid, error: result.error }.
Detection helper (isOpgn(bytesOrFirstChunk))
Section titled “Detection helper (isOpgn(bytesOrFirstChunk))”Returns true iff the file starts with %OPGN/<digit>. Reads at most
the first 16 bytes; safe to call on any candidate .pgn file.
Error codes
Section titled “Error codes”loadOpgn returns one of:
| Code | Meaning |
|---|---|
valid: true | Manifest is present and verifies against the PGN. |
valid: null | No manifest; file is plain PGN. |
valid: false, error: 'unknown-version' | Meta line has a version we don’t support. |
valid: false, error: 'unknown-encoding' | Meta line declares an encoding we don’t support. |
valid: false, error: 'malformed-meta' | Meta line is structurally invalid. |
valid: false, error: 'multiple-opgn-lines' | More than one %OPGN/ line in the leading block. |
valid: false, error: 'length-mismatch' | Decoded manifest length ≠ ofm-bytes. |
valid: false, error: 'decode-error' | Base64 decode failed. |
valid: false, error: <code from verifyManifest> | Manifest itself failed verification (pgn-hash-mismatch, owner-signature-invalid, etc.). |
File extension and MIME type
Section titled “File extension and MIME type”- Extension:
.pgn(not.opgn). The whole point of this format is that it remains a valid PGN file. A separate extension would fragment the ecosystem — Lichess uploads, file pickers, OS-level associations all already understand.pgn. - MIME type:
application/vnd.chess+pgn(standard+pgnstructured- suffix). Tools that want to indicate “this PGN carries OpenFile metadata” can useapplication/vnd.openfile+pgnat the HTTP layer, but the file extension stays.pgn.
For UI labeling, apps can read isOpgn(bytes) to detect manifest
presence and badge accordingly.
Why .pgn and not a new extension
Section titled “Why .pgn and not a new extension”The detection question — “how do I find OpenFile-signed files in a
directory of PGNs?” — is solved by a content-based magic check, not by
file-extension fragmentation. The %OPGN/ magic on line 1 is detectable
in a 16-byte peek. Library/file-browser UIs can show a badge.
In return, OpenFile-signed files retain universal interop:
- Lichess upload of a manifest-bearing
.pgn→ moves come through. - ChessBase / scid / Hiarcs open it cleanly.
- Email attachment works without OS-level “unknown file type” warnings.
catshows recognizable PGN content (with a small meta block at the top).
This is the same pattern as .docx being a .zip, or .svg being
.xml, or .geojson being .json: extend the popular format using
its existing escape mechanisms; keep the extension.
Why %-lines and not header tags
Section titled “Why %-lines and not header tags”An earlier design considered embedding the manifest in PGN tag pairs
(e.g. [OpenFileManifest "<base64>"]). It was rejected because PGN’s
255-character hard line limit forced chunking into many headers — and
the 80-character soft limit was violated by all reasonable chunk sizes.
The %-comment mechanism is explicitly designed for non-PGN content
within a PGN file (spec §6), supports arbitrary line counts, and stays
within both line-length limits with comfortable margin. It’s the
mechanism the PGN spec itself points to for this use case.
Implementation footprint
Section titled “Implementation footprint”Reference: ~150 LOC of source + ~150 of tests.
The module exports:
exportOpgn(target, { ownerSigner }): Promise<Uint8Array>loadOpgn(bytes, { verifier }): Promise<{ pgnText: string, manifest: Manifest | null, valid: boolean | null, error?: string,}>isOpgn(bytes): booleanNo new dependencies on top of OpenFile’s existing surface (buildManifest,
parseManifest, verifyManifest, base64 helpers, canonicalize).