The Mark Protocol Specification

July 23, 2026 · View on GitHub

Version: 1.0 Draft
Date: 2026-02-17
Status: Working Draft
Project: Demarkus

Abstract

The Mark Protocol is an application-layer protocol for the transfer of markdown documents over QUIC. It provides a document-centric, privacy-first alternative to HTTP, designed around immutable versioning, human-readable wire formats, and mandatory encryption. The protocol uses text-based verbs and status values, YAML frontmatter for metadata, and a SHA-256 hash chain for version integrity verification.

Status of This Document

This is a working draft specification for the Mark Protocol version 1.0. It documents the normative behaviour of the protocol as currently defined. Features described in the project design document (DESIGN.md) that are not yet specified here (including federation) are considered future extensions and are not part of this specification.

1. Terminology

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.

Additional terms:

  • Document: A markdown file served by a Mark Protocol server.
  • Frontmatter: A YAML metadata block delimited by --- lines.
  • Version: An immutable snapshot of a document's content at a point in time.
  • Current version: The most recent version of a document.
  • Hash chain: A sequence of cryptographic hashes linking each version to its predecessor.

2. Protocol Overview

2.1. Scheme

The Mark Protocol uses the URI scheme mark. A conforming URI has the form:

mark://host[:port]/path

If the port is omitted, the default port 6309 is assumed.

2.2. Transport

The Mark Protocol runs exclusively over QUIC (RFC 9000). There is no plaintext fallback. TLS 1.3 is mandatory.

2.3. Content Format

All document content is markdown (CommonMark). Metadata is encoded as YAML frontmatter. There is no support for executable content; no scripts, no embedded code execution, no client-side dynamic behaviour.

3. Transport Layer

3.1. QUIC

A Mark Protocol server MUST accept QUIC connections. A Mark Protocol client MUST connect using QUIC.

Each request-response exchange takes place on a single bidirectional QUIC stream. The client opens a stream, writes the complete request, signals the end of writing, and reads the complete response until the stream is closed by the server.

3.2. Port

The default port for Mark Protocol servers is 6309 (UDP). Servers MAY listen on alternative ports. Clients MUST support specifying a non-default port in the URI.

3.3. TLS

All Mark Protocol connections MUST use TLS 1.3 or later. Servers MUST NOT accept unencrypted connections. Clients MUST NOT send requests over unencrypted connections.

Servers SHOULD use certificates issued by a trusted certificate authority for production deployments. Servers MAY use self-signed certificates for development purposes.

3.4. ALPN

The Application-Layer Protocol Negotiation (ALPN) identifier for the Mark Protocol is:

mark

Servers MUST include "mark" in their TLS ALPN extension. Clients MUST request "mark" in the ALPN negotiation.

4. Request Format

4.1. Structure

A request consists of a request line, optional frontmatter, and an optional body:

VERB /path\n
[---\n
key: value\n
...\n
---\n]
[body]

The request line is a single text line terminated by a newline character (\n, not \r\n). It consists of a verb and a path separated by a single space character.

4.2. Request Line

The request line MUST have the form:

VERB /path\n
  • The verb MUST be a known protocol verb (see Section 6).
  • The path MUST begin with /.
  • The path MUST NOT contain null bytes (\0), control characters (codepoints below 32 except horizontal tab \t), or the DEL character (codepoint 127).
  • The maximum length of the request line is 4096 bytes.

4.3. Request Metadata (Frontmatter)

If the line immediately following the request line is exactly ---, the request includes metadata. Metadata lines follow until a closing --- line is encountered. The content between the delimiters is parsed as YAML into a flat key-value map where all values are strings.

---\n
key1: value1\n
key2: value2\n
---\n
  • Metadata is OPTIONAL for all verbs.
  • The maximum size of the metadata block (excluding delimiters) is 65536 bytes (64 KB).
  • Servers MUST reject requests whose metadata exceeds this limit.

4.4. Request Body

Everything after the metadata closing delimiter (or after the request line if no metadata is present) constitutes the request body.

  • The body is OPTIONAL for all verbs.
  • For PUBLISH requests, the body contains the document content.
  • For PUBLISH and APPEND, the body MUST be valid UTF-8. A document is markdown text (§2.3); the server MUST reject a non-UTF-8 body with bad-request and MUST NOT store it.
  • There is no protocol-level size limit on the body; servers SHOULD enforce a maximum document size (see Section 12.3).

5. Response Format

5.1. Structure

A response consists of YAML frontmatter followed by an optional markdown body:

---\n
status: <status-value>\n
key: value\n
...\n
---\n
[body]

5.2. Response Frontmatter

All responses MUST include frontmatter. The frontmatter MUST include a status field.

All frontmatter values are strings. Implementations MUST parse frontmatter as map[string]string to prevent YAML type coercion of timestamps, numbers, and booleans.

5.3. Response Body

The body is everything following the closing frontmatter delimiter. It is markdown-formatted text.

  • The body MAY be empty (e.g., for not-modified responses).
  • Error responses SHOULD include a human-readable markdown body describing the error.

5.4. Error Body Format

Error responses SHOULD use the following body format:

# <Status Title>

<Human-readable error message>

Where the status title is the status value with the first letter capitalised and hyphens replaced with spaces (e.g., not-found becomes Not found).

6. Verbs

6.1. FETCH

Retrieves a document.

Request:

FETCH /path\n

Conditional request metadata (OPTIONAL):

  • if-none-match: An ETag value from a previous response.
  • if-modified-since: An RFC 3339 timestamp from a previous response.

Success response (ok):

---
status: ok
modified: <RFC 3339 timestamp>
etag: <64-char hex SHA-256>
version: <integer>
---
<markdown body>

Conditional response (not-modified):

If the request includes if-none-match and it matches the current ETag, or if the request includes if-modified-since and the document has not been modified after that time, the server MUST respond with:

---
status: not-modified
---

The not-modified response MUST NOT include a body.

When both if-none-match and if-modified-since are present, the server MUST check if-none-match first. If it matches, not-modified is returned without checking if-modified-since.

Version access:

A path of the form /doc.md/vN (where N is a positive integer) requests a specific version. The response includes additional metadata:

---
status: ok
modified: <RFC 3339 timestamp>
version: <requested version>
current-version: <highest version number>
---
<markdown body>

Errors:

  • not-found: The document does not exist.
  • server-error: Internal error or the file exceeds the size limit.

Special path: FETCH /health is a health check endpoint. Servers MUST respond with status: ok and a body indicating server health.

6.2. LIST

Lists the contents of a directory.

Request:

LIST /path/\n

Success response (ok):

---
status: ok
entries: <count>
---
<markdown body with directory listing>

The body MUST be a markdown document containing a list of entries:

  • Directories are listed as - [name/](url-encoded-name/)
  • Files are listed as - [name](url-encoded-name)

Servers MUST exclude hidden files (names beginning with .) from directory listings.

Servers MUST impose a maximum entry count. The RECOMMENDED limit is 1000 entries. If the listing is truncated, the body SHOULD end with a note indicating truncation.

Errors:

  • not-found: The directory does not exist, or the path refers to a file.
  • server-error: Internal error.

6.3. VERSIONS

Retrieves the version history of a document.

Request:

VERSIONS /path\n

Success response (ok):

---
status: ok
total: <version count>
current: <highest version number>
chain-valid: <true|false>
chain-error: <error description>
---
<markdown body with version list>

The body MUST list all versions from newest to oldest:

# Version History: /path

- [v3](/path/v3) - 2026-02-17T10:00:00Z
- [v2](/path/v2) - 2026-02-16T09:00:00Z
- [v1](/path/v1) - 2026-02-15T08:00:00Z

Metadata:

  • total: The total number of versions.
  • current: The highest version number.
  • chain-valid: "true" if the hash chain is intact; "false" if any link is broken.
  • chain-error: Present only when chain-valid is "false". Contains a human-readable description of the first broken chain link.

Only documents with version history (written through the protocol) are served. Flat files without a versions/ directory are treated as non-existent.

Errors:

  • not-found: The document does not exist or has no version history.
  • server-error: Internal error or versioning not configured.

6.4. PUBLISH

Creates a new immutable version of a document. Requires authentication.

Request:

PUBLISH /path\n
---\n
auth: <raw-token>\n
---\n
<document content>

The auth metadata field is REQUIRED. The server hashes the raw token with SHA-256 and looks up the resulting hash in its token store. The token must grant the publish operation on the requested path.

The request body is the document content. It is stored as-is (the server prepends its own store frontmatter; the original content is preserved verbatim).

Document contract: a published document is markdown text. The request path MUST end in .md and the body MUST be valid UTF-8 (§2.3, §4.4). The server MUST reject a path that does not end in .md, or a body that is not valid UTF-8, with bad-request and MUST NOT create a version. This gate also applies to APPEND (§6.6).

Success response (created):

---
status: created
version: <new version number>
modified: <RFC 3339 timestamp>
---

The created response MUST NOT include a body.

Behaviour:

  • PUBLISH creates a new version unless the body is identical to the current version. The server MUST NOT modify or overwrite any existing version.
  • If the body is byte-for-byte identical to the current version's content, the server MUST NOT create a new version. It MUST respond with ok and the current version's version and modified metadata.
  • If the document does not exist, version 1 is created.
  • If the document exists, the version number is incremented from the current highest version.
  • If the document exists as a flat file (no version history), the server MUST migrate the flat file to version 1 before creating version 2.

OKF type default: when a written document declares no type metadata, the server assigns the default type (Document; see §14), so every stored concept document is a typed Open Knowledge Format concept by construction. This applies to both PUBLISH and APPEND (§6.6). Reserved OKF files (index.md, log.md) are exempt, as OKF defines them as navigation and history rather than concepts. A document that already declares a type is stored unchanged. Because the default is part of the document's metadata, it participates in duplicate detection: republishing a previously untyped document acquires the default type once, creating a single new version.

Optimistic concurrency (OPTIONAL):

The request MAY include an expected-version metadata field containing a decimal integer. If present, the server compares it to the current document version:

  • If expected-version matches the current version, the write proceeds normally.
  • If expected-version does not match, the server MUST return a conflict status with the following metadata:
    • your-version: The expected-version value the client sent.
    • server-version: The current version on the server.
  • If expected-version is absent, the server writes unconditionally (no conflict detection).

Note: Due to the append-only version model, a conflict may be detected after a version file has been written (e.g., a concurrent writer advanced the version between the pre-check and the write). In this case the server still returns conflict, but the written version is preserved to maintain hash chain integrity. Since PUBLISH is idempotent (identical content produces a no-op), clients can safely retry on conflict by fetching the latest version and re-publishing. For non-idempotent operations like APPEND, clients MUST fetch the latest version and verify whether their append was applied before retrying (see section 6.6).

Authentication errors:

  • not-permitted: No token store configured on the server (publishing disabled).
  • unauthorized: Missing auth field or token not recognised.
  • not-permitted: Token does not grant publish on the requested path.

Archived documents:

  • PUBLISH with a body on an archived document MUST return archived and MUST NOT create a new version. The document must be unarchived first.
  • PUBLISH with an empty body on an archived document MUST unarchive the document and return ok.
  • PUBLISH with an empty body on an active document MUST return ok (no-op).

Other errors:

  • not-found: Path validation failed (e.g., path traversal attempt).
  • bad-request: Path does not end in .md, or the body is not valid UTF-8 (see Document contract above).
  • conflict: expected-version does not match the current version (see optimistic concurrency above).
  • server-error: Internal error, content exceeds size limit, or publishing not configured.

6.5. ARCHIVE

Marks a document as archived. Archived documents return status: archived on FETCH, but version history is preserved. Version-pinned fetches (e.g., /doc.md/v3) continue to work. Requires authentication with the publish capability.

Request:

ARCHIVE /path\n
---\n
auth: <raw-token>\n
---\n

Success response:

---
status: ok
---

Behaviour:

  • ARCHIVE sets the archived flag on the current version file. It does NOT create a new version.
  • FETCH on an archived document MUST return status: archived with no body.
  • Version-pinned FETCH (e.g., /doc.md/v3) MUST still return the content regardless of archive status.
  • To unarchive a document, PUBLISH with an empty body (see section 6.4).

Authentication errors:

  • not-permitted: No token store configured on the server (archiving disabled).
  • unauthorized: Missing auth field or token not recognised.
  • not-permitted: Token does not grant publish on the requested path.

Other errors:

  • not-found: Document does not exist or path validation failed.
  • server-error: Internal error.

6.6. APPEND

Appends content to the end of an existing document. Creates a new immutable version where the body is the existing content followed by a newline and the appended content. Requires authentication with the publish capability.

Request:

APPEND /path\n
---\n
auth: <raw-token>\n
expected-version: <N>\n
---\n
<content to append>

The auth and expected-version metadata fields are REQUIRED. The expected-version value MUST be >= 1. The request body MUST NOT be empty.

Success response (created):

---
status: created
version: <new version number>
modified: <RFC 3339 timestamp>
---

Behaviour:

  • APPEND reads the current document, concatenates the request body after a newline separator, and writes the result as a new version.
  • The document MUST already exist. APPEND does not create new documents: use PUBLISH for that.
  • The combined content (existing + newline + appended) MUST NOT exceed the document size limit.
  • The expected-version metadata field is REQUIRED for APPEND (unlike PUBLISH where it is optional). Since APPEND is non-idempotent, the server cannot safely retry internally. The value MUST be >= 1; the server MUST reject expected-version: 0 or absent expected-version as a bad request.
  • Conflict semantics match PUBLISH (see section 6.4). On conflict, fetch the latest version and verify whether your append was applied before retrying.
  • The new version's publisher metadata is taken from the APPEND request (it is not merged with the prior version's metadata). The OKF type default (§6.4) applies, so an append that declares no type to a non-reserved path stores type: Document.

Authentication errors:

  • not-permitted: No token store configured on the server.
  • unauthorized: Missing auth field or token not recognised.
  • not-permitted: Token does not grant publish on the requested path.

Other errors:

  • bad-request: Missing or invalid expected-version (must be >= 1), a non-.md path, or a body that is not valid UTF-8 (see §6.4 Document contract).
  • not-found: Document does not exist or path validation failed.
  • archived: Document is archived. Unarchive first via PUBLISH with empty body.
  • conflict: expected-version does not match the current version. Response includes your-version and server-version metadata.
  • server-error: Internal error, empty body, or combined content exceeds size limit.

6.7. LOOKUP

Looks up documents by subject and returns a compact, importance-ranked list of matches. LOOKUP is a discovery aid: a card catalog, not full-text search. It matches a subject query against each document's declared tags and title, never against the document body. Servers SHOULD answer LOOKUP from an in-memory catalog and MUST NOT read document bodies at query time.

LOOKUP operates over current versions only; archived documents MUST be excluded. The path in the request line is a scope: LOOKUP / covers the whole server, LOOKUP /docs/ restricts to that subtree.

Request:

LOOKUP /docs/\n
---\n
query: auth middleware\n
filter: project=broker,modified-after=2025-01-01\n
limit: 10\n
auth: <raw-token>\n
---\n
  • query (REQUIRED): the subject text. The server lowercases it and splits it on whitespace into terms. A document matches if any term matches its declared tags or its title. Matching is case-insensitive and term-based; the precise rule (exact tag membership, title substring) is implementation-defined but MUST be limited to tags and title. The query MUST be at least 2 characters; a missing, empty, or too-short query MUST return bad-request.
  • filter (OPTIONAL): a comma-separated list of key=value predicates applied before ranking. Each predicate matches a declared metadata value by exact equality, except the built-ins modified-after and modified-before, which compare an RFC 3339 timestamp (or date) against the document's modification time. A document MUST satisfy all predicates to be included. A malformed filter MUST return bad-request.
  • limit (OPTIONAL): the maximum number of results. Default 10. Servers MUST impose a hard cap (RECOMMENDED 1000).
  • auth (OPTIONAL): a token used to authorise results on read-auth-protected paths (see Read authorisation below).

Success response (ok):

---
status: ok
matches: <count>
---
# Lookup matches for "<query>" in <scope>

| Path | Importance | Title | Tags |
|------|------------|-------|------|
| /docs/auth-middleware | 0.90 | Auth middleware design | go, auth, middleware |
| /docs/gateway | 0.50 | Gateway overview | go |

The body MUST be a markdown table, one row per result. Columns are the document's server-relative path, its importance, its title, and its declared tags. The Path is server-relative; clients compose the full mark://host/path URL from the host they connected to. The response MUST NOT include document body content; clients FETCH the documents they choose. matches is the number of rows returned.

Ranking: results are ordered by (1) the number of distinct query terms matched, then (2) descending importance, then (3) descending modification time, then (4) ascending path. Importance influences ordering only among documents that already matched the query; it MUST NOT cause an unmatched document to appear in the results.

Declared catalog metadata (set on PUBLISH as publisher metadata):

  • tags: a comma-separated list of subject labels, e.g. tags: go,auth,middleware. The match target for query, and available for exact membership matching via filter.
  • importance: a decimal in the range [0,1] used as the ranking weight. Absent or invalid values MUST be treated as 0.5.
  • title: an OPTIONAL one-line title shown in results and included in the query match target. When absent, the server SHOULD derive it from the document's first level-1 heading, falling back to the path's base name.

tags, importance, and title are the only publisher metadata keys a server interprets for LOOKUP; all other declared metadata remains opaque and is reachable only through filter.

Read authorisation: a server that enforces per-path read authorisation MUST filter LOOKUP results so that documents the requester is not authorised to read are omitted entirely; no path, no title, no tags, and not counted in matches. Knowledge of a subject MUST NOT reveal the existence of protected documents.

Errors:

  • bad-request: Missing, empty, or too-short query, or a malformed filter.
  • not-found: The scope path does not exist or is not a directory.
  • server-error: Internal error.

A valid LOOKUP over an existing scope with no matching documents MUST return ok with matches: 0 and a header-only table, not not-found.

7. Status Values

Status values are text strings. There are no numeric status codes.

ValueMeaning
okRequest succeeded. Body contains the requested content.
createdPublish succeeded. A new version was created.
not-modifiedConditional request: the resource has not changed. No body.
not-foundThe requested resource does not exist.
archivedThe document has been archived. Version-pinned fetches still succeed.
unauthorizedMissing or invalid authentication token.
not-permittedValid authentication but insufficient capability for the requested operation or path.
conflictVersion conflict; expected-version did not match the current version.
bad-requestMalformed request, or a document that violates the content contract (non-.md path, non-UTF-8 body).
server-errorThe server encountered an error processing the request.

7.1. Future Status Values

The following status values are reserved for future use:

ValueIntended meaning
too-largeDocument exceeds the size limit.
unavailableServer temporarily cannot fulfil the request.

8. Metadata Fields

8.1. Request Metadata

FieldApplicable verbsFormatDescription
if-none-matchFETCH64-char hex stringETag from a previous response. Enables conditional fetch.
if-modified-sinceFETCHRFC 3339 timestampTimestamp from a previous response. Enables conditional fetch.
authPUBLISH, ARCHIVE, APPENDStringRaw authentication token. The server hashes this with SHA-256 and looks up the hash in its token store.
expected-versionPUBLISH (optional), APPEND (required)Decimal integerExpected current version for optimistic concurrency. If present and does not match the server's current version, the server returns conflict. APPEND requires this field (>= 1).
queryLOOKUPStringSubject text matched against each document's tags and title. REQUIRED; minimum 2 characters.
filterLOOKUPComma-separated key=valuePredicates applied before ranking. Exact match on declared metadata, plus built-ins modified-after / modified-before.
limitLOOKUPDecimal integerMaximum number of results. Default 10; server-capped (RECOMMENDED 1000).
tagsPUBLISHComma-separated stringSubject labels for the document. Interpreted by the server: matched by LOOKUP query and filter.
importancePUBLISHDecimal in [0,1]Ranking weight used by LOOKUP. Interpreted by the server. Absent or invalid is treated as 0.5.
titlePUBLISHStringOne-line title shown in LOOKUP results and matched by query. Defaults to the first level-1 heading, then the path base name.

Beyond the interpreted fields above, a PUBLISH request MAY carry additional publisher metadata as arbitrary key: value frontmatter lines. The server stores these opaquely and exposes them to LOOKUP filter predicates. Reserved store fields (§9.4) MUST be rejected. §9.4 specifies how publisher metadata is persisted, including the Open Knowledge Format field names that are stored as bare frontmatter fields.

8.2. Response Metadata

FieldApplicable verbsFormatDescription
modifiedFETCH, PUBLISH, APPENDRFC 3339 timestampDocument modification time (UTC, second precision).
etagFETCH64-char lowercase hexSHA-256 hash of the raw file bytes.
versionFETCH, PUBLISH, APPENDDecimal integerVersion number of the returned or created document.
your-versionPUBLISH, APPEND (conflict)Decimal integerThe expected-version the client sent. Present only in conflict responses.
server-versionPUBLISH, APPEND (conflict)Decimal integerThe current version on the server. Present only in conflict responses.
current-versionFETCH (version access)Decimal integerHighest available version number.
entriesLISTDecimal integerNumber of entries in the directory listing.
totalVERSIONSDecimal integerTotal number of versions.
currentVERSIONSDecimal integerHighest version number.
chain-validVERSIONStrue or falseWhether the version hash chain is intact.
chain-errorVERSIONSStringDescription of chain verification failure. Present only when chain-valid is false.
content-hashFETCHsha256- + 64-char lowercase hexSHA-256 hash of the response body (stripped of store frontmatter). Enables content-addressed retrieval.
matchesLOOKUPDecimal integerNumber of catalog matches returned in the table body.

9. Versioning

9.1. Version Model

The Mark Protocol uses an append-only version model. Every write to a document creates a new version. Published versions are permanent and MUST NOT be modified or deleted, with one exception: a publisher-declared retention policy prunes the oldest versions of a document (§9.9). Version history is otherwise an append-only log.

Version numbers are positive integers starting at 1, monotonically increasing by 1 for each write. Retention pruning never affects version numbering.

The version model is defined over observable protocol behavior, not a storage medium. A server MAY persist versions in any store (the reference implementation ships a filesystem store and a PostgreSQL store) provided the stored version bytes (§9.4), version numbering, hash chain (§9.5), and verb semantics are preserved regardless of the persistence layer.

Immutability covers document content and history, not store-owned operational state: the archived field (§9.4) of the current version MAY be updated in place by archive and unarchive operations rather than by creating a new version, since the flag is operational metadata, not content. The hash chain is unaffected because only a successor version hashes its predecessor, and the current version has no successor at the time its flag changes.

9.2. Path-Based Version Access

Specific versions are accessed via the path structure:

/doc.md          → current version
/doc.md/v1       → version 1
/doc.md/v42      → version 42

The version segment MUST match the pattern v followed by a positive integer (no leading zeros required). v0 is not a valid version. Paths with a version segment that does not match this pattern are treated as regular paths.

9.3. On-Disk Layout

This section describes the reference layout for filesystem-backed stores. A store backed by another medium (for example a relational database) is exempt from this layout but MUST persist the same stored version bytes (§9.4) and preserve the observable version model (§9.1) under its own representation.

Filesystem-backed servers SHOULD store versioned documents using the following layout:

root/
  doc.md              ← symlink to versions/doc.md.v<current>
  versions/
    doc.md.v1
    doc.md.v2
    doc.md.v<N>

The current file (doc.md) SHOULD be a symbolic link to the latest version file. Version files reside in a versions/ subdirectory at the same level as the document.

Version files are named <filename>.v<N> where N is the version number.

9.4. Version File Format

Each version file MUST be prefixed with a store-managed frontmatter block. The block carries the store's own operational fields followed by any publisher-declared metadata.

Version 1 (genesis):

---
version: 1
archived: false
---
<original document content>

Version N (N > 1):

---
version: <N>
archived: false
previous-hash: sha256-<64-char lowercase hex>
---
<original document content>

version, archived, and previous-hash (omitted for version 1) are reserved operational fields owned by the store. Publishers MUST NOT set them, and a server MUST reject a PUBLISH whose metadata declares a reserved key.

Publisher-declared metadata is written into the same block. The field names defined by the Open Knowledge Format (type, title, description, resource, tags, and timestamp) are written as bare frontmatter fields so the document's persisted metadata matches the OKF spec for the fields it covers; tags is serialized as a YAML flow list. Every other publisher key is written under a meta. prefix so it cannot collide with a reserved or OKF-recognized field. For example:

---
version: 3
archived: false
previous-hash: sha256-<64-char lowercase hex>
meta.importance: 0.8
tags: [sales, revenue]
title: Orders
type: BigQuery Table
---
<original document content>

An implementation MAY bound the count and total size of publisher metadata fields (the reference implementation permits up to 50 keys totaling 1024 bytes).

The store frontmatter is separate from any frontmatter that may exist in the original document content; the original content is stored verbatim after the closing delimiter. This block is stripped before a document body is served, so the relationship is one of representation, not interoperable transport: a demarkus document's content model is OKF-compatible, but a server does not by itself serve or ingest OKF bundles (see §13).

9.5. Hash Chain

Each version file (except version 1) MUST include a previous-hash field in its store frontmatter. The value is the SHA-256 hash of the complete raw bytes of the previous version file (including that file's own store frontmatter), formatted as sha256- followed by 64 lowercase hexadecimal characters.

This forms a hash chain:

v1 (genesis)     v2                    v3
┌────────────┐   ┌─────────────────┐   ┌─────────────────┐
│ version: 1 │   │ version: 2      │   │ version: 3      │
│            │──►│ previous-hash:  │──►│ previous-hash:  │
│ content... │   │   sha256(v1)    │   │   sha256(v2)    │
└────────────┘   │ content...      │   │ content...      │
                 └─────────────────┘   └─────────────────┘

9.6. Chain Verification

To verify the integrity of a document's version history:

  1. Read all retained version files, sorted by version number (oldest first).
  2. For each version N after the oldest retained version: a. Compute sha256(<raw bytes of version N-1 file>). b. Format as sha256-<hex>. c. Compare with the previous-hash value in version N's store frontmatter. d. If they do not match, the chain is broken at version N.

The oldest retained version is the verification root: its own previous-hash (absent for version 1, referencing a pruned file otherwise) is not checked.

If any version file has been modified after publication, the hash recorded in the next version will not match, and the tampering is detected.

When retention pruning (§9.9) has removed the oldest versions, verification applies to the retained contiguous suffix. The oldest retained version's previous-hash references a deleted file and cannot be verified; every later link remains verifiable.

9.7. Immutability Enforcement

Servers MUST NOT overwrite existing version files. Before writing a new version file, the server MUST verify that no file exists at the target path. If the target file already exists, the write MUST fail.

9.8. Flat File Migration

When a PUBLISH is performed on a document that exists as a flat file (no version history), the server MUST:

  1. Create the versions directory if it does not exist.
  2. Migrate the flat file content to versions/<filename>.v1 with store frontmatter (version: 1, no previous-hash).
  3. Create the new version as versions/<filename>.v2 with a previous-hash referencing the hash of the migrated v1 file.
  4. Update the current file to a symlink pointing to the new version.

9.9. Version Retention

A publisher MAY bound a document's version history by declaring a retention metadata key on PUBLISH or APPEND. The value MUST be a positive integer; a server MUST reject a write whose retention value is not an integer or is less than 1.

When the newly written version N carries retention: R and more than R versions exist, the server MUST delete the stored version files with version numbers less than or equal to N − R, in ascending version order, after the write has succeeded. Deletion MUST stop at the first failure so the retained versions always form a contiguous suffix of the history (§9.6). The current version is never deleted; R ≥ 1 guarantees at least one version remains.

Retention is evaluated per write: a write that omits the key prunes nothing, regardless of what earlier versions declared. Absent retention, the default append-only model applies unchanged. Reads of a pruned version return not-found; VERSIONS lists only the retained versions.

Retention is intended for generated documents that are republished wholesale (graph exports, indexes), where old versions carry no value. It is destructive: pruned versions are unrecoverable through the protocol. Clients SHOULD warn and require explicit confirmation before a write that sets retention on a document not known to be generated.

Pruning requires no separate capability: it executes under the write authorization of the PUBLISH or APPEND that carries the key, the same trust level that can archive the document. A server MUST record version deletions in its audit log, attributing them to the authenticated writer (in the reference implementation: path, pruned version range, and token label).

Because pruning is the store's only destructive operation, deletion targets MUST NOT be derived from request input: version numbers come from enumerating the document's own stored versions, and each deletion MUST be scoped to the document being pruned. A filesystem store MUST additionally confine every deletion to the store root with symlink escapes rejected at delete time (the reference implementation resolves every removal inside an os.Root anchored at the store root, so a planted or raced symlink cannot redirect a delete outside the store, and a version file that is itself a symlink is unlinked, never followed). A database store achieves the same confinement by binding deletions to the document's path and version-number parameters, never to interpolated request text.

10. Caching

10.1. ETag

The server MUST compute an ETag for every successful FETCH response. The ETag is the SHA-256 hash of the raw stored version bytes (§9.4, before any frontmatter stripping), formatted as 64 lowercase hexadecimal characters. Because every storage backend persists identical stored version bytes, a document's ETag is the same regardless of the backend serving it.

10.2. Conditional Requests

Clients MAY include if-none-match and/or if-modified-since metadata in FETCH requests.

  • if-none-match: If the value matches the current ETag, the server MUST respond with not-modified.
  • if-modified-since: If the document has not been modified after the given RFC 3339 timestamp, the server MUST respond with not-modified.

When both are present, if-none-match takes precedence. If it matches, not-modified is returned without evaluating if-modified-since.

10.3. Not-Modified Response

A not-modified response MUST have an empty body and empty metadata (aside from the status field).

11. Security Considerations

11.1. Encryption

All Mark Protocol communication MUST be encrypted via TLS 1.3 or later. There is no plaintext mode. This ensures confidentiality and integrity of all document transfers.

11.2. Path Traversal

Servers MUST validate all request paths to prevent directory traversal attacks. The path validation algorithm MUST:

  1. Normalise the path (resolve . and .. segments).
  2. Resolve symbolic links in the target path to detect symlink-based escapes.
  3. Verify that the resolved absolute path is within the content root directory.

Servers MUST return not-found (not not-permitted or any other status) for path traversal attempts to avoid disclosing information about the filesystem structure outside the content root.

11.3. Size Limits

Servers MUST enforce the following limits:

ResourceLimit
Request line4096 bytes
Request metadata65536 bytes (64 KB)
Document size (read and publish)1 MB (RECOMMENDED)
Directory listing entries1000 (RECOMMENDED)

11.4. No Tracking

The Mark Protocol is designed to minimise tracking. Conforming implementations:

  • MUST NOT send user agent identification.
  • MUST NOT send referrer information.
  • MUST NOT use cookies or session identifiers.
  • MUST NOT collect IP addresses beyond what QUIC requires for connection handling.
  • SHOULD log only the operation, path, and status; no personally identifiable information.

11.5. No Client-Side Execution

The Mark Protocol serves markdown content only. There is no mechanism for executable content (scripts, active content, or client-side code execution). Clients MUST NOT execute any content received via the Mark Protocol.

11.6. Input Sanitisation

Servers MUST reject paths containing null bytes or control characters (codepoints below 32, except horizontal tab). Servers MUST sanitise all user-supplied strings before writing them to log files to prevent log injection attacks.

When the content directory or any document path involves symbolic links, the server MUST resolve all symlinks and verify that the final resolved path remains within the content root. Symlinks that escape the content root MUST be treated as not-found.

11.8. Authentication

The Mark Protocol uses capability-based token authentication. Tokens grant specific operations on specific path patterns; they do not identify users.

Secure by default: Servers MUST deny all publish operations when no token store is configured. Reads are public by default: read authentication is opt-in per path.

Read authentication: Tokens with the read operation protect specific paths. When any token grants read on a path pattern, requests to matching paths require a valid read token. Paths not covered by any read token remain public. This enables private intranets (protect /**) and mixed public/private servers (protect /internal/** while leaving the rest open).

Servers MUST enforce read auth on FETCH, LIST, and VERSIONS operations. Content-addressed FETCH (by hash) MUST resolve the hash to a path and check read auth on that path. Versioned paths (e.g., /doc.md/v2) MUST check auth on the base path (/doc.md). The well-known manifest path (/.well-known/agent-manifest.md) is always public.

Token storage: The server stores SHA-256 hashes of tokens, never the raw tokens themselves. The token store is a TOML file:

[tokens]
"sha256-a1b2c3d4..." = { paths = ["/docs/*"], operations = ["publish"] }
"sha256-e5f6a7b8..." = { paths = ["/*"], operations = ["read", "publish"], expires = "2026-12-31T23:59:59Z" }

Token fields:

  • paths: Array of glob patterns. * matches any single path segment (not recursive).
  • operations: Array of permitted operations (read, publish).
  • expires: OPTIONAL RFC 3339 timestamp. If present, the token is invalid after this time.

Authentication flow:

  1. Client includes auth: <raw-token> in request metadata.
  2. Server computes sha256-<hex of SHA-256(raw-token)>.
  3. Server looks up the hash in its token store.
  4. If not found: respond with unauthorized.
  5. If found but the token does not grant the requested operation on the requested path: respond with not-permitted.
  6. If authorised: proceed with the request.

Token generation: The demarkus-token generate tool creates cryptographically random tokens and appends their hashed entries to the token store file. The raw token is printed once and never stored by the server.

11.9. Versioned-Only Serving

Servers MUST only serve documents that have been written through the protocol, i.e., documents with at least one stored version (in the filesystem layout, a versions/ directory containing at least one version file). Content present in the storage medium without version history — such as flat files placed directly on the filesystem — MUST be treated as not-found.

This ensures every served document has:

  • An immutable version chain
  • SHA-256 hash chain for tamper detection
  • Proper store frontmatter

12. Content-Addressed Fetch

Every successful FETCH response that serves a document includes a content-hash field containing the SHA-256 hash of the response body (the document content after stripping store frontmatter). The format is sha256-<64 hex characters>. Directory listings and error responses do not include content-hash.

Clients can fetch a document by its content hash instead of its path:

FETCH /sha256-<64 hex characters>

The server maintains an in-memory index mapping content hashes to document paths. Only current (non-archived) versions are indexed. The server resolves the hash to a path and serves the document normally, including all standard response metadata.

If no document matches the hash, the server returns not-found.

Paths matching /sha256-<64 hex characters> are reserved for content-addressed fetch. Servers MUST NOT allow documents to be created at these paths.

Benefits:

  • Location-independent content retrieval: any server with the content can serve it
  • Client can verify received content matches the requested hash
  • Foundation for distributed mirroring and caching

12.1 Agent-Driven Hash Discovery

Servers do not crawl or discover content from other servers. Agents fill this role by building and publishing hash indexes to hubs.

Workflow:

  1. An agent fetches documents from one or more servers, collecting content-hash values from each response.
  2. The agent builds a mapping of content hashes to server locations.
  3. The agent publishes this mapping as a document to a hub server (e.g., a markdown table or structured list).

Example index document published to a hub:

# Content Index

| Hash | Server | Path |
|------|--------|------|
| sha256-a1b2c3... | mark://docs.example.com | /guide.md |
| sha256-d4e5f6... | mark://notes.example.com | /readme.md |
| sha256-d4e5f6... | mark://mirror.example.com | /copy.md |

Resolution flow when content is missing:

  1. Agent fetches a document by path: server returns not-found.
  2. Agent checks a hub's content index for the document's last known content-hash.
  3. Agent finds alternative servers hosting that hash.
  4. Agent fetches FETCH /sha256-<hash> from one of those servers.
  5. Agent verifies the response body matches the requested hash.

Key properties:

  • Servers remain simple: they serve content and answer hash lookups, nothing more.
  • Agents own the discovery logic: crawling, indexing, and routing decisions.
  • Hubs are just servers: the index is a regular published document, not a special protocol feature.
  • Multiple agents can maintain independent indexes on the same or different hubs.
  • No single point of failure: if a hub is unavailable, agents can query servers directly by hash.

13. Future Extensions

The following features are planned but not part of this specification:

  • Federation: Cross-server content mirroring and discovery.
  • Subscriptions: Notification of document changes.
  • OKF interop: An import/export codec that reads and emits Open Knowledge Format bundles. demarkus already aligns its persisted document metadata with OKF field names (§9.4), so a single document's content model is OKF-compatible; the planned codec adds bundle-level round-tripping (frontmatter on the served document, index.md/log.md conventions, bundle-relative links), validated against the OKF reference sample bundles. demarkus remains a superset at the system level; it layers versioning, a hash chain, QUIC transport, capability auth, and LOOKUP discovery on top of an OKF-compatible document.

These will be specified in future versions of this document.

14. Protocol Constants

ConstantValue
Default port6309 (UDP)
ALPN identifiermark
URI schememark
TLS minimum version1.3
Max request line4096 bytes
Max request metadata65536 bytes
Recommended max document size1 MB
Recommended max directory entries1000
Default LOOKUP limit10
Recommended max LOOKUP results1000
Hash algorithmSHA-256
Hash formatsha256-<64 lowercase hex chars>
Default OKF typeDocument

15. References

  • RFC 2119: Key words for use in RFCs to Indicate Requirement Levels
  • RFC 9000: QUIC: A UDP-Based Multiplexed and Secure Transport
  • RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3
  • RFC 3339: Date and Time on the Internet: Timestamps
  • CommonMark Specification: https://spec.commonmark.org/
  • YAML 1.2 Specification: https://yaml.org/spec/1.2/

Mark Protocol Specification: "The web we want, not the web we got."