Archon · Example document

Living document

The plain-text document is now the expensive one

An architecture document about Archon, built with Archon. The page you are reading is the artifact the argument is about: one self-contained HTML file that gains sign-in, anchored comments, inline edits and presence at runtime, and loses none of it when you save it to disk.

Subject  Archon itself Source  github.com/aiur-team/archon Built by  templates/build Permanent link  /d/3c7f1a

The problem

Expand

A rich page always explained a system better than prose. It was never worth the effort for twelve readers. That arithmetic has inverted.

Documents are plain text because writing anything better cost more than the audience justified. That constraint is gone, and nothing else was holding the format in place.

Prose was never the best explanation. It was the affordable one

Markdown is good. Google Docs and Notion are good. They are fast to write, they diff, they survive tooling changes, and a reader with no context can open one. None of that is in dispute here, and a document that pretended otherwise would be arguing with the wrong thing.

The claim is narrower and harder to escape: for explaining a system, a rich HTML page beats all of them, and it always did. A diagram that responds to a click shows a mechanism that a static picture can only assert. An example the reader can run answers the question the paragraph was going to raise. A state machine the reader can step through settles an argument that three paragraphs of prose leave open. Nobody has ever preferred the flat version on its merits.

They preferred it on its cost. A rich page was a small application: a build, a bundle, a stylesheet that had to work in both themes, and a week of someone's attention. An internal architecture document is read by about a dozen people. A week of engineering time to serve twelve readers is not a defensible trade, so the flat version won every time, and the reasoning became invisible — a convention rather than a calculation.

What actually changed

The cost of the better artifact collapsed

An agent writes a working, theme-aware, interactive page about as fast as it writes the prose describing one. The week is now an afternoon. The calculation that produced the convention no longer produces the same answer.

What did not change

The reasons prose was chosen

Diffability, greppability, and surviving a decade all still favour text. Those are real and this document does not wave them away — the Design section is largely about paying them back.

The cost that replaced it is collaboration, not authoring

Removing the authoring cost exposes the cost that was hiding behind it. Google Docs is not chosen for its typography. It is chosen because a document is a conversation: somebody comments on a sentence, somebody else suggests a rewrite, and an owner decides. A static HTML file has none of that. It is a better explanation and a worse workspace, and the workspace is why teams keep reaching for the worse explanation.

What a reviewer needsMarkdown in a repoA hosted documentA static HTML file
Interactive explanationNoNoYes
Comment on one sentenceOnly in reviewYesNo
Suggest an edit in placeAs a patchYesNo
Survives being emailedYesNoYes
Reviewed before it changesYesNoYes

No column wins. That is the problem worth solving, and it is the one Archon takes on: the right-hand column is the better artifact, and it is missing exactly two rows. Adding those two rows without losing the two it already wins is the whole design.

The honest counter-argument, stated first. A page an agent writes in an afternoon is a page nobody has read line by line. Prose fails visibly; a broken interactive element fails by quietly showing the wrong thing. This is a real cost of the position and it is not fully paid off. The partial answer is in Design: the build refuses to emit a document with an unbalanced tag, an unfilled placeholder or a duplicate section id, and it prints an anchor report on every rebuild. That catches structure, not meaning.

The solution

Expand

Build one self-contained HTML file, then let a hosted layer add sign-in, comments, suggestions, edits, roles, history and presence on top of it without touching the file.

sections/*.htmlsource, in git
templates/build
One HTML fileCSS and JS inlined
served, not rebuilt
Live layer/api/* · only when hosted
works with no network needs a host and a session Everything to the right of the file is additive. Remove it and the document still reads.

The document is a static file. The collaboration is a layer that attaches to it at runtime.

One build produces one HTML file with every stylesheet and script inlined and no external request except a font. It opens from file://, it survives being emailed, and it can be published anywhere that serves bytes. When that same file is served by an Archon site, client modules in it call /api/*, find a session, and reveal comments, suggestions, inline editing, roles, history and presence.

Always

The text and the interaction

Inlined at build time. No host, no session and no network required.

When hosted

Comments, edits, roles

Revealed by a session, enforced by a function. Absent everywhere else.

Optional

Presence and realtime

Needs a third-party broker. With no key the endpoint returns 204 and the feature simply is not there.

Degradation is the design, not the fallback

Each layer is inlined into the artifact by its own placeholder in layout.html, and an absent asset contributes zero bytes — not an empty wrapper, not a blank line. A document built with no features is byte-for-byte the document that existed before the feature slots were added. That property is what makes the additive claim checkable rather than aspirational.

file://Read itevery diagram, every table, both themesworks
hosted, no sessionRead itthe gate decides who sees HTML at allgated
hosted, signed inComment, suggest, edit, sharerole-checked on every writefull
no broker keyNo presence, no live eventsrefresh insteadabsent

The client contract is one mechanism, not four. session.js calls /api/session once and sets data-session on the root element; CSS reveals controls; every other module listens for a session event and nothing polls. data-session is a rendering hint and never a security control. A reader can set it in the developer tools and make an edit button appear. The button calls a function, the function resolves the caller's real role, and the function returns 403.

What this buys that a hosted document does not

Against Markdown

The explanation is an application

Interactive diagrams and runnable examples live in the artifact, hand-built from the template's components. No diagram library: it would render one fixed theme, could not be checked before publishing, and needs a script the CSP blocks.

Against a hosted document

The text stays reviewable

In repository mode an accepted edit becomes a commit on a branch and a pull request against sections/*.html. The document changes the way code changes, and the copy on your laptop is a real copy rather than a stale export.

Where this is weakest. In standalone mode there is no repository, so the overlay is the document and nobody reviews it. That is the price of having no repository, and it is why export-back had to be built before editing rather than after. Design says how both modes work and what each one gives up.

Design

Expand

One block identity carried across rebuilds, two deployment modes chosen by configuration and never by a request, and a storage boundary that keeps every durable word in the document rather than the database.

1
Build
aligns old block text against new
carries data-aid, or orphans it loudly
anchors.json
2
Reader
selects a sentence, comments
stores block id + quote + prefix + suffix
Blobs
3
Page
re-resolves on every load
exact · drifted · moved · orphaned
Reader

A comment outlives the sentence it was made about, because the build is the only participant that ever sees both versions.

Browser anchoring libraries see one document and a stored quote. Archon has a build step that sees the old block texts in anchors.json and the new ones in sections/*.html, and it aligns them once, with full context and no time pressure. That is the whole reason anchoring here is a solved problem rather than a heuristic.

Layer 1: data-aid is the one block identity in the platform

anchors.ts scans each section into a list of block elements, normalises their text (collapse whitespace, trim), and diffs that list against the previous build's list with a sequence matcher. A new block is minted "a" + sha1(text)[:8]. Everything else is carried:

Alignment opcodeWhat the build doesWhy
equalCarry the idSame text, same block
replaceCarry if similarity ≥ 0.6An edited paragraph is still that paragraph
replace, below 0.6Orphan the old idOrphan loudly rather than guess wrongly
deleteOrphan the old idThe block is gone
insertMint a new idNothing to carry

Then one move pass: an orphaned id whose exact text reappears elsewhere in the section reclaims that block. Sequence alignment respects order, so two similar siblings cannot swap. And the build prints a report on every rebuild — how many blocks kept an id, how many were re-anchored, how many moved, how many were orphaned, with the orphans named. A writer who orphans twelve threads finds out at the moment they do it, not when a reader complains.

Two rejected alternatives are worth stating, because both look reasonable. An id derived from position — sha256(doc|section|ordinal), or "p:7" — renumbers every block below an insertion, and every anchor below it silently moves. That failure is invisible, which is worse than being wrong loudly. A quote with no block id fails the moment somebody rewrites the quoted words, which is precisely the change that made the comment worth reading.

Layer 2: the quote, resolved in the browser, degrading in four visible states

An anchor stores block, the normalised exact text, up to 32 characters of prefix and suffix, and a start offset used only as a tiebreak. The page finds the block by data-aid, searches its normalised text, and scores hits by common affix length minus a gentle distance penalty. There is no fuzzy matching: it hides failure instead of reporting it, and it blocks the main thread on long documents with short generic quotes.

StateConditionWhat the reader sees
exactBlock found, quote foundThe highlight, in place
driftedBlock found, quote goneThe comment on the block, marked "text changed"
movedBlock gone, quote found in exactly one other blockThe comment there, marked "moved"
orphanedBlock gone, quote absent or ambiguousGrouped under "Not attached any more", with its quote

Anchor state is never stored. The client computes it at read time, every time. A stored state goes stale on the next rebuild, and the whole point of two layers is that they degrade independently. moved requires a unique hit; nothing is ever silently relocated.

One consequence sharpens the design more than any other rule. The builder is JavaScript, so norm() and the block scanner are one module compiled once and inlined into the page. Under earlier builders this code existed twice — in Python the two whitespace sets genuinely differed, and in Rust they were kept in step by a CI fixture whose only job was to prove two languages agreed. There is now nothing to drift. This is the single largest benefit of the builder being JavaScript, and a client that reimplements norm() "for convenience" recreates the entire problem.

AWhat an anchor survives, exactly
Change to the documentdata-aidQuoteResult
Restyle, rewrap, change classesKeepsKeepsNothing changed
Fix a typo elsewhere in the paragraphKeepsKeepsNothing changed
Rewrite the quoted wordsKeepsLosesOn the block, "text changed"
Move the paragraph to another sectionKeepsKeepsNothing changed
Split one paragraph in twoLosesKeeps in one halfRe-found, marked "moved"
Delete the paragraphLosesLosesOrphaned, with its quote
Rename the documentKeepsKeepsNothing changed — the key is the permanent id
Rewrite a whole sectionLosesLosesEvery thread orphaned. This is correct

The last row is not a defect. A rewritten section is a different argument, and carrying comments onto it would attach objections to sentences nobody wrote.

Two modes, selected by configuration and never by a request

An edit is an overlay keyed by aid, applied over the built HTML. Promoting that overlay to a durable source is a separate, explicit act, and the two modes differ only in what that act does. An overlay replaces the contents of a block and never the block's identity, which is why editing cannot break anchoring; a DOM patch that rewrote block boundaries would be forbidden in both modes.

StandaloneRepository-backed
What the host getsOne built HTML file, uploadedA repository holding many documents
Source of truth for textThe file, plus the overlaysections/*.html in git
Where the block manifest livesA private sidecar in the store, at mode/<docId>/manifest.json<instance>/dist/<instance>.edit.json, in the deploy
PromotionOwner's choice. Rewrites the file, or opens a PR once a repository is attachedA commit on a per-author branch, and one pull request per author per document
Who may write a direct editA decider onlyAny member
Review gateAcceptance. There is no other oneThe pull request

The mode is chosen by four environment variables and nothing else. With DOCS_REPO absent and none of its three companions present, the service is standalone. With DOCS_REPO present, all of DOCS_GITHUB_TOKEN, DOCS_BOT_EMAIL and a valid branch must validate. Partial configuration is fatal in both directions: a configured repository never falls back to standalone, and standalone never borrows a repository value. Both produce a 500 with the stable code invalid-state rather than a quiet degradation, because the quiet degradation would mean writing a reader's edit somewhere nobody is reviewing.

The cost of standalone mode, said plainly. The overlay is the document, and nobody reviews it. That is not an oversight to be fixed later; it is what "no repository" means. It is also why promotion had to be built before editing rather than after, and why a direct edit in standalone mode is restricted to a decider while repository mode lets any member propose one.

The storage boundary: nothing durable is only in the database

State lives in Netlify Blobs, one store named doc-state, strong consistency, one blob per record — never a shared mutable array. Two readers appending to one array lose a comment; a compare-and-swap loop prevents the loss but makes every write on a document contend with every other write on that document, and a live review session is exactly when concurrent writes happen. The permanent six-hex id from doc.json is the key, never the slug and never the directory name, so renaming a document orphans nothing.

In the store

Conversation and receipts

Threads and comments, append-only audit events, access grants and invitations, open suggestions, and the pending-edit receipt. Written through one mutate() helper with an ETag compare-and-swap and a six-attempt backoff; append-only keys use onlyIfNew.

Not in the store

The document, and anything ephemeral

Every published word lives in the artifact and, in repository mode, in git. Presence is derived on the client from heartbeats — a 20-second beat, a 50-second expiry, a bye on pagehide — and is never persisted, never written to Blobs, never audited.

The pending edit is the clearest statement of the boundary. It is a receipt carrying a baseHash of the text it replaced, and /api/pending drops any entry whose baseHash no longer matches the deployed build's manifest. When the pull request merges and the site rebuilds, the hash changes and the entry disappears on its own; there is no cleanup job. If Blobs lost everything tomorrow, no published content would be lost — comments and grants would be, and that is the honest limit of the claim.

BWhat this storage choice rules out, and what would reverse it

Blobs has no index and no change feed, so several features are not merely unbuilt but unbuildable on this substrate: no query across documents, no full-text search of comment bodies, no transaction spanning two blobs, no count without a read, and no per-user index — which means no unread count, no "shared with me" list and no cross-document revoke. Offboarding is deleting the identity user.

Six named triggers move the decision to Postgres: a query that is no longer scoped to one document; a request to search comment text; one document passing roughly 200 threads with a slow first paint; sustained writes above about one per second on a single thread; a change that must span two documents atomically; and per-user state that is not per-document. Presence deliberately does not fire the fourth trigger, because a fact with a twenty-second lifetime does not belong in a durable store of any kind — Postgres would not fix it either.

CWhy realtime needs a third party, and what that costs

Netlify Functions cannot hold a WebSocket: 10 seconds synchronous, 60 seconds streaming, 15 minutes background. Netlify Edge Functions can hold a stream open indefinitely, which looks like the answer and is not — Netlify has no fan-in. No shared memory between isolates, no bus, no publish-subscribe, and Blobs has no change feed, so an SSE endpoint could only poll Blobs from inside the stream. That is polling with extra steps.

So the broker is mandatory rather than preferred: Ably, one channel per document, subscribed with the browser's built-in EventSource and published with fetch, the token minted by a function. No SDK and no script tag, which is the constraint that eliminated every alternative including Ably's own client library. Cloudflare Durable Objects is the better technical fit and is the named migration target, rejected for now only because it means running a Worker that is otherwise unnecessary.

This is the repository's first external service dependency and its first secret. Server events carry an id and a hash and never text, presence is never persisted, and no write path may depend on the broker — so an outage is a slower document, not a broken one, and a migration is one client module with no data to move.

API

Expand

Fourteen functions under /api/, every one of them resolving authority through the same function and the same validator. The client reveals controls; only an endpoint grants anything.

Read

session · pending · threads · events · access · suggestions

Gated on canRead or canSeeMembers.

Write

threads · edit · suggestions · suggestion · access

Origin-checked, capability-checked, and never trusting an author field in the body.

Neither

login · logout · accept · realtime-token · retention

Session and infrastructure. retention has no route at all.

Every endpoint answers the same question with the same code before it does anything else: what may this person do to this document?

resolveRole(docId, user) is the sole authority, and validateAccessRow() is the single shared check that its answer is well formed. Both exist because the capability check was previously hand-copied into at least four places and the copies drifted. One function, or they drift again.

The route table

Each function declares its own path in export const config. There is no redirect table and no /.netlify/functions/ path in use; /api/* is excluded from the edge gate because every function gates itself. That is a division of labour, not a hole: the edge gate decides who may see HTML, and it never decides who may write.

RouteMethodsTakesReturnsRequires
/api/sessionGET?doc= six-hexsub, email, name, canComment, canEdit, doc, role, shared, canSuggest, canAccept, canShare, canSeeMembersA session
/api/pendingGET?doc=Map of aid{text, by, at, pr, via?}canRead
/api/threadsGET, POST?doc=&limit=&cursor=; body {kind, section, anchor|title, docVersion, body}{threads, nextCursor}; 201 {thread}canRead / canComment
/api/threads/:doc/:idPOST, PATCH{body} to reply; {status} to resolve or reopen{thread}canComment / threadControl
/api/eventsGET, POST?doc=&month=YYYY-MM&limit=&after=; body {kind, target, docVersion, summary}{v, docId, month, events, nextAfter}; 201 the eventcanSeeMembers / per-kind
/api/suggestionsGET, POSTbody {docId, aid, text, note, baseHash, baseText}Array with state: open|superseded; 201 the recordcanRead / canSuggest
/api/suggestionPOST{docId, aid, sugId, action, reason}{receipt, pr} or {ok:true}canAccept, or be the author to withdraw
/api/editPOST{docId, aid, text, baseHash?}{receipt:{aid, text, by, at, pr, via:"edit"}}canSuggest and canEdit
/api/accessGET, POST, PATCH, DELETE{doc, email|sub, role} or {doc, allowedDomains}{doc, allowedDomains, members, invitations}; 204 on write, or 200 {ok, doc, allowedDomains} for the domain listcanSeeMembers / owner and canShare
/api/access/transferPOST{doc, sub}204Owner only
/api/realtime-tokenGET?doc={token, issued, expires, capability, clientId}, or 204canRead
/api/hosted/sessionGET{v, authenticated}, or that plus accountId, login, email, emailVerified, csrfTokenNothing
/api/hosted/auth/startPOSTForm: destination, switchAccount?, csrfToken?303 to Auth0Origin
/api/hosted/auth/callbackGET?code=&state=303, alwaysSigned state
/api/hosted/auth/logoutPOST303 to Auth0's logoutOrigin + CSRF
retentionNo route. schedule: "@daily"One log lineNothing

Four rules that hold across every endpoint

An author field in a request body is an impersonation attempt, not an input. Several endpoints accept author, email and name in the body and discard them. Every actor stored anywhere comes from the session.

Every mutating handler verifies the origin first. The identity token is a cookie, so every POST, PATCH and DELETE is otherwise exposed to cross-site forgery. requireOrigin(req) is the first statement, not a middleware someone can forget to register.

Failure is uninformative on purpose. The sign-in routes collapse a bad state, a bad PKCE verifier, a refused consent and an unexpected scope into one /login/?status=expired, because a message that distinguished them would tell somebody probing the callback which half of the transaction they had guessed right. /api/suggestion answers a withdrawal by a non-author with 403 if they can read the document and 404 if they cannot, so the status code never reveals that a suggestion exists.

Absence is a status code, not an error. With no ABLY_API_KEY, /api/realtime-token returns 204. That single switch is the entire degradation story: a file opened from disk stops at the protocol test, an embedded copy never gets a session because the CSP blocks /api/session, and a standalone site works untouched until its owner decides otherwise.

The capability matrix

Five roles, and the row is what an endpoint actually checks — never the role name.

RolecanReadcanCommentthreadControlcanSuggestcanEditcanAcceptcanSharecanSeeMembers
ownerany
editorany
commenterown
viewernone
nonenone

Resolution has a fixed precedence: the bound owner subject, then an explicit grant by subject, then an unexpired invitation matched on the caller's verified email, then the document's own allowedDomains list — exact, case-insensitive, full-domain equality against a verified address, yielding viewer and never more — then none. A site-wide PUBLIC_DEFAULT_ROLE tier used to sit at the end and is gone: with open sign-in, "any other authenticated caller" is the internet. "Verified" is load-bearing: sign-in is open to anyone with a Google or GitHub account, so anyone can present any address to an identity provider, and the provider's verification claim is the only thing standing between an invitation and whoever guessed the invited address. An unverified match resolves none and consumes nothing, and an identity carrying no address at all is a valid session that matches no invitation.

Sharing is opt-in — a document with no access record behaves exactly as it did before roles existed, and shows no share panel. The first owner is captured once from the DOC_OWNERS environment variable on that person's first sign-in, by a create-only write, so ownership is not first-come and not seizable from the client.

threadControl: "own" is checked inside the compare-and-swap, not before it. A commenter resolving somebody else's thread is refused against every fresh draft the retry loop reads, and denial takes precedence over the no-op shortcut — otherwise a silent 200 for "already resolved" would tell a non-owner the thread's current status.

AThe edge gate, and how it learns which document it is guarding

The gate runs on /* and decides the exclusions in code: /api/*, /_assets/*, /login/*, /docs/*, /publish/* and /invite/* pass through with no session check, because sign-in and the invitation prompt have to be reachable before a session exists. Everything else is gated, which is a default-deny rather than a list of guarded paths.

It has no way to validate the session cookie itself — that means reading the session store, and this is a Deno edge function. So it asks: an internal GET /api/hosted/session with the browser's Cookie header forwarded and nothing else. That route is one of the pass-through paths, so the call cannot recurse. There are exactly three answers. A principal continues below. Nobody gets 303 /login/?destination=, and the destination is only ever a path the sign-in grammar already accepts — a path that is not expressible is simply not offered, rather than widening the allowlist. And an outage — a 503, a malformed body, a transport failure — gets a plain 503 and never a redirect to sign-in, because telling a visitor they are signed out is a claim the gate has no evidence for.

With a session, it fetches the downstream response and reads only the first line of the body, which must match <meta name="doc-id" content="([0-9a-f]{6})"> within 96 bytes. That is why {{DOC_ID}} is the first placeholder in layout.html: it lets the gate identify the document without buffering the page. It then resolves the role and requires canRead, replaying the retained bytes on success.

BOne apply path, whatever the entry point

A direct edit and an accepted suggestion reach durable state through exactly one operation, in netlify/lib/gitedit.mjs. It owns the mode decision, the manifest, the effective base, the repository write and the receipt compare-and-swap. It owns no authority at all: the caller has already proven who the actor is and what that actor may do, and the module refuses to re-derive either from a request, from public HTML, or from a document file. It imports no identity helper, no functions context and no notification fan-out, and it emits no log line.

The effective base is the applied receipt when one exists and its baseHash still equals the manifest row hash, otherwise the manifest hash itself. A stale receipt never becomes authority. A mismatch is 409 carrying the current hash and the current text — but only when that text round-trips through both converters exactly; otherwise it carries null rather than a lossy approximation.

The GitHub side is deliberately narrow: a deterministic branch docedit/<docId>/<first 16 hex of sha256(sub)> that exposes neither the email nor the raw subject, exactly zero or one open pull request per author branch, a ten-second timeout, no redirects, a two MiB response cap, and a commit that replaces only the located byte range. The text's author stays the commit author; the site is only the committer. It never merges, closes, labels, reviews or comments.

Build order

Expand

Forty-two written specifications across four phases, sequenced so that two tickets in one phase never touch the same file. The constraint that shaped the plan was file collision, not feature dependency.

Phase 15Ids, placeholders, identity contract, the anchor scanner, Netlify. Nothing user-facing
Phase 28Gate, store helper, session probe, editable blocks, history, access library
Phase 310Every read path. A reader sees everything and can write nothing
Phase 419Write paths and the sharp edges. Sequenced, not parallel

The phases are not feature tiers. They are the shape of a file-collision graph.

The rule that produced this plan is one line: two tickets in the same phase never touch the same file. Phase 1 is not "foundations" in the vague sense — it is the set of edits to shared files, done once, so that eight later lanes can run at the same time without merging into each other. Phase 4 is nineteen tickets and mostly sequential for the same reason, from the other direction: the write paths all converge on edit.mjs and gitedit.mjs.

Each ticket is written before its code, and names its file surface

The forty-two specifications live in docs/tickets/ as P1-A through P4-T. Each one declares what it changes, what it depends on, and how it is verified. Above them sits docs/research/00-integration-plan.md, which rules: nine research documents were written in parallel, they agreed on the large facts and disagreed on twenty-six concrete points, and the plan lists every disagreement with the ruling and the reason. Where the plan and a research document differ, the plan is correct.

"Frozen" would be the wrong word, and the record says so. Specifications freeze contracts between tickets — a session projection, an event shape, a client surface — so a downstream ticket cannot renegotiate them. The plan itself was revised twice and says where. Ruling #10, "there is no editor role", was reopened when a share panel became a requirement. P4-E was retired outright: a bearer share link cannot attribute a comment, because a stored comment needs a proven subject, and it cannot be revoked. The letter gap between P4-D and P4-F is that decision, left visible.

Four phases, and what a reader has at the end of each

PhaseTicketsWhat landsWhat a reader can do
15Permanent id/slug/aliases; every placeholder and hook call site in the builder; the identity contract; anchors.ts with its report; Netlify configuration and the --site buildNothing new. The rebuild is byte-identical except the doc-id meta line
28Edge gate, login and logout; the Blobs store helper; the session probe and reveal rules; editable-block detection and the edit manifest; history.json; the realtime token endpoint; the access library; identity split from authorisationSign in and read a gated document. No comment or edit UI exists
310Threads and events APIs; the comments client with four anchor states; the changelog; GET /api/pending; realtime transport; presence; /api/access and a doc-scoped session; the read-only share panel; the gate learns the grant storeSee everything, write nothing. Comments, history, who is online, who has access
419Comment writes; the edit write path; suggestions and their decision surface; enforcement in every handler; the soft lock; access writes and invitation acceptance; notifications; retention; standalone connect and promotion; documentation lastComment, suggest, edit, accept, share, and read the audit trail

The dependency graph, and its longest chain

Three tickets have no dependencies at all and start immediately: P1-A (permanent ids), P1-C (the identity contract) and P1-E (Netlify configuration). Everything else names its predecessors. The graph is wide in the middle and narrow at both ends.

P1-Bplaceholders
P1-Danchors
P2-Dmanifest
P3-Epending
P4-Bedit write
same file
P4-M → P4-Nenforce, then unify
P4-S → P4-Rstandalone promotion
build-time read path write path, serialized Nine sequential hops after a root. This is the critical path.

The tail is the expensive part, and it is expensive for a reason worth stating: P4-B, P4-M and P4-N cannot run in parallel because they share edit.mjs and gitedit.mjs. P4-B builds the edit write path, P4-M wires capability enforcement into every existing write handler, and P4-N then extracts the single apply path so that accepting a suggestion and saving a direct edit cannot drift into two different conflict checks. Doing P4-N first would mean extracting a shared path from one caller, which is a guess rather than an extraction.

A second chain of comparable length runs through sharing — P1-C → P2-B → P2-G → P3-H → P3-J → P4-J → P4-K and P4-L — and it is genuinely parallel to the editing chain, which is what phase 3 being ten wide buys.

AWhy documentation is the last ticket rather than the first

P4-G depends on essentially everything and is merged last. That is not neglect: it is the one ticket whose cost is entirely rework if an interface moves. Documentation written against a moving contract has to be rewritten each time the contract moves, and each rewrite is a chance for the document and the code to disagree in a way nobody notices.

The gates that protect correctness in the meantime are mechanical rather than editorial: check-dist rebuilds every document and fails if a committed artifact changed by one byte; check-function-modules imports every server module without invoking anything, so an unresolvable import or a missing export fails at link time rather than in production; check-test-inventory fails when a test file exists that no CI step names, which turns "added a test and forgot to wire it up" from a silent no-op into a red build.

Open questions

Expand

Five unresolved questions, ranked by how much the answer would change the design. The first one decides whether Archon can be offered as a service at all.

Load-bearing

Hosting without a customer's token

No answer means no hosted offering, only self-hosting.

Changes the storage model

Where comments live

Repository, host, or GitHub's own threads. Three different products.

Unmeasured

Browser-side rate limits

Moving reads to the client trades one ceiling for another.

Open questions

Each of these is a decision that is not the author's to make alone, written so a reader can answer one without reading the rest of the document. They are ranked by how much the answer changes the design, not by how hard they are.

1

How can a hosted Archon write to a customer's repository without holding a credential for it?

This is the load-bearing one, so it is first. Repository mode works today because DOCS_GITHUB_TOKEN is a site environment variable on a site the document's own team controls. Self-hosting has no problem here. A hosted offering does: the host would hold a token that can write to every customer's repository, which is the single most attractive credential in the system and the one whose compromise is unrecoverable.

Three shapes exist and none is obviously right. A GitHub App with a fine-grained installation, scoped to contents and pull requests on selected repositories, is the conventional answer — but the host still mints tokens, so it is a smaller blast radius rather than a different trust model. A customer-side runner that polls the host for approved edits and commits them locally moves the credential entirely to the customer, and costs a component they must operate. Browser-side commits using the reader's own GitHub OAuth grant means the host never holds a write credential at all, and means every editor needs push access, which most reviewers do not have and should not be given.

What changes on the answer: the second option makes hosting a hybrid product with an installed component; the third makes the role model in the API section partly redundant, because GitHub's permissions would decide who may edit. Only the first leaves the design as documented.

2

Should comments live in the repository, on a host, or in GitHub's own comment system?

They live on a host today, in Blobs, and that choice was made against a specific alternative — authority in a committed file — for a specific reason: an invitation must be immediate, and in standalone mode there is no repository to commit to. That reasoning is sound for grants. It is weaker for comments, which do not need to be immediate in the same way.

In the repository, comments would diff, review and survive the host disappearing, and the anchor churn would be visible next to the text change that caused it — which is already true of anchors.json. It also means every comment is a commit, which is unbearable during a live review, and it means a reader who may comment must be able to write to the repository, which re-opens question 1 in a worse form. In GitHub's own review threads, the comments would live where engineers already read them, with notifications and permissions solved. But GitHub anchors to a line in a diff, and this design exists precisely because a line number is the anchor that does not survive an edit. A pull request comment also disappears from view once the pull request merges, which is the moment a design comment becomes most worth keeping.

What changes on the answer: the repository option deletes the threads half of the storage layer and most of phase 3. The GitHub option deletes it too and replaces the anchor model with GitHub's, which this document argues against on the merits. Assumed answer: keep them on the host, and treat the durability gap honestly — if Blobs were lost, no published word would be lost, but every comment would be.

3

What is the rate-limit ceiling if every reader's browser talks to the GitHub API directly?

This follows from question 1's third option and is worth pricing separately. Today, reads never reach GitHub at all: the document is a static file and the manifest is baked into the deploy, so a hundred readers cost GitHub nothing. Moving writes — or worse, base-text reads — into the browser changes that.

Unverified. The numbers are not measured here and should not be quoted from memory. What is measurable and known is the shape of the exposure: an authenticated per-user hourly quota is spent per reader rather than per site, so it scales with audience rather than with edits, and a document open in twenty tabs would spend it on nothing. A secondary limit on concurrent requests would bite first during exactly the event this platform is built for — a live review where thirty people load the same page at once.

What changes on the answer: if per-reader quota is comfortable, browser-side commits become viable and question 1 has a clean answer. If it is not, the host must proxy, and proxying means holding a credential, which returns to question 1's first option. These two questions are one question and should be decided together.

4

What happens at 200 concurrent readers?

Realtime runs on a broker's free tier, and the binding constraint is 200 concurrent connections, account-wide rather than per document. Message volume is not the constraint: a ten-reader hour is roughly 18,000 messages against a six-million monthly allowance. Connections are. Two hundred people reading documents at the same moment is about twenty busy documents, or one all-hands document.

Two paths are already framed: pay, or migrate to the named target, which is one client module and no data migration because presence is never persisted and events carry only ids and hashes. The free tier carries no service level agreement, and the mitigation is structural rather than contractual — no write path may depend on the broker, so an outage is a slower document. What is genuinely open is when to move, and whether the first sign of the ceiling will be visible or will look like presence quietly not working for the twenty-first person.

5

Who reviews a document an agent wrote in an afternoon?

The Problem section rests on authoring becoming cheap, and this is the bill for that. A build failure catches an unbalanced tag, a duplicate section id, an unfilled placeholder and an orphaned anchor. Nothing catches a confident sentence that is wrong. An interactive element that renders correctly and models the system incorrectly is worse than prose, because it looks like evidence.

What changes on the answer: nothing in the architecture, which is why this is last rather than first. But it is the question a reader should leave with, because it is the only one on this list that the design cannot answer — and the comment and suggestion layer this document describes is, at bottom, the proposed answer to it.

What is deliberately not being built

  • A second identity vendor. There is one, and it is Auth0 — which brokers Google and GitHub, so a visitor continues with an account they already have and Archon never sees a password. This entry originally read "no GitHub OAuth, no Auth0, no single sign-on"; the platform now has all three through one broker, and the sentence is kept rather than deleted because what it was defending is still true: one identity vendor, chosen once. No second broker, no per-deployment provider matrix, and still no multi-factor authentication of Archon's own — that is the identity provider's to enforce, and an organisation that mandates it configures its own tenant.
  • Cross-document features. No "shared with me" list, no cross-document revoke, no unread count and no full-text search of comment bodies. The storage decision rules these out, and they were cut deliberately rather than deferred.
  • A diagram library. It renders one fixed theme, cannot be checked before publishing, and needs a script the CSP blocks. Diagrams are hand-built from the template's components.
  • Conflict-free merging of two edits to one block. Two people editing the same block is a genuine conflict and the second writer is told. Merging the text would be the fix that hides the problem.