Alpha — published as written. A working document, not a specification. Nothing in it is settled and any of it can change without notice. It is published because the reasoning is part of the work, not only its result.
- Status: alpha, and open. Slice 1 in section 6 is unblocked and needs no decision. Slices 2 and 3 are blocked on section 5.
- Published at: https://mouseion.ragbaz.cc/rfc/0002
- Date: 2026-10-05
- Task:
m-scrolls-secret-links - Linked from: nowhere yet. The roadmap has no In progress section, and the roadmap is itself a versioned scroll, so linking this from it is a deliberate act rather than an edit. Proposed home: a new In progress section between Where things stand and Next, naming this RFC, published as roadmap version 2.
- Vocabulary: this document says scroll, letter, byline, chain, colophon, evidence, seal, unlisted. No new nouns are introduced.
Alpha — published as written. This is a working document, not a specification. Nothing in it is settled and any of it can change without notice; the sections that are still being argued about are the most likely to be rewritten. It is published because the reasoning is part of the work, not only its result: a design you cannot argue with is a design you cannot check.
1. What is being asked for
Two kinds of artifact to show the world, and one workflow built on top of them.
- A provenance view. A readable, public representation of what has been attested: non-tampered chains, evidence, signatures, trust standing and time, the parts that come from Nostoi, WeftMark, Frog and Ephor and that carry the EU AI Act weight. The trivial form of the artifact is a single self-contained HTML file.
- A letter to a named reader. A sender, pseudonymous or named with keys and vouching attestations, writes a letter, states the intended recipients in clear text, and gets a secret link. Whoever holds the link may read it. The sender can see that it was opened, can see a read receipt, and can see that a reply was started.
2. Most of this already exists, and that is the finding that matters
The inventory was taken before any design. The gap is much smaller than it looks.
| The ask | Already present | Where |
|---|---|---|
| a published artifact | scrolls, kinds dialogdoc/dialogue/document/page/image/other | src/scrolls.js:29 |
| reachable by link but absent from every listing | audience = 'unlisted', filtered out of listScrolls(), the listings and the home page, still served by id | src/scrolls.js:105-111 |
| a letter, rendered | renderPublishedLetter, a letter to the curator | src/render.js:405-414 |
| an unlisted route that refuses to confirm itself | /correspondence/inbox answers 404, not 403, to non-curators | src/worker.js:186 |
| a signed, append-only log of every state change | chain_records, nostoi-v1, appendWith in the same db.batch | src/chain.js:32-42 |
| signature, trust and time as three separate questions | stated as an invariant, rendered as three blocks on /attestation/<id> | src/attestations.js:5-24, src/render.js:447-492 |
| attestations from third parties, possibly mutual | trust_claims kinds witness.authorship, witness.credit_match, author.delegates, librarian.authorship, operator.ratifies, operator.trusts_librarian | migrations/0008_trust_claims.sql |
| revocations, with effective periods | trust_revocations, half-open periods, bitemporal fold() | src/trust.js, test/trust.test.mjs |
| a secret token whose hash is stored | newSessionToken() returns a token and a hash, stores only the hash; timingSafeEqual compares | src/identity/crypto.js:83-114 |
The largest gap is documented in the code itself. src/trust.js:1-4 says no public
ingestion route uses this module yet. No file in src/ queries trust_claims; only the
tests call it. The provenance resolver exists, is tested, and is unwired. Slice 1 is
connecting it.
3. What is genuinely missing
- A bearer secret for a scroll. An unlisted scroll is addressed by
0xplus namespace plus content digest. That is not a secret: the id is a function of the publication record, so anyone holding the manifest can compute it. A real secret link needs a random token, stored only as a hash, compared in constant time. The session pattern is already proven. - A visit log. Nothing in the project records a visitor. See section 5.
- Read receipt and reply-draft state. Neither exists. Both are state changes on someone
else’s object, so invariant 2 applies: chain them, in the same
db.batch. - Recipients in clear text.
scrolls.audienceis an enum and there is no free-text addressee field. - A provenance page. The data is there; the view is not.
4. Vocabulary, so we do not invent nouns
- The artifact is a scroll, always. The word letter is kept for the message, which is how
renderPublishedLetteralready uses it. - A scroll that is in no listing and reachable only through a secret link is unlisted,
the existing term. The existing sealed scroll means encrypted to the platform scroll
key, and that meaning should not be widened, so this RFC proposes: sealed stays
encryption-only; a scroll behind a bearer secret is unlisted with a seal key. Recorded
in
names.jsonthroughtools/names.mjs, perAGENTS.md:112-113. - A visit is a sighting. The chain already says
at. - Ticking the box is a read receipt. Having started to write is a draft opened. Both
are nouns the chain can carry as
kind.
5. Three things we cannot settle alone
5.1 Logging an IP collides with a written invariant
The request is to log times, ip, known sessions and the like. The codebase has twice declined this, and the evidence is concrete rather than a matter of tone:
src/identity/auth-gated.js:9-12: the abuse gates store one opaque key per protected route and never derive bucket keys from handles, credentials, tickets, accounts, pseudonyms, IPs or user agents.ip_hashexists in two migrations,0001_identity.sql:74onsessionsand:112onaudit_events, and has zero writers: no file insrc/mentions it, and no file readsCF-Connecting-IPorx-forwarded-forat all. The schema anticipated the question and the code answered it.
So this is not a wall to argue with; it is a decision the project has already made twice, and adding an IP log would reverse it.
Options, cheapest first:
- (a) No address at all. Log the time, whether the caller held a session, and the user
agent, which is already stored on
sessionstruncated to 300 characters. This distinguishes three different people from one person on three devices, which is what a read receipt actually needs, and honours the invariant unchanged. - (b) A keyed hash.
keyedHashBase64url(ip, secret)already exists atsrc/identity/crypto.js:64-77and needs a 32-byte Worker secret, which the project does not currently have. Rotating the key makes old rows unlinkable retroactively. The precedent iscorrespondence_messages.sender_email_hash: a hash in D1, never in the chain. - (c) A plain IP, truncated. Simplest to build, and the first thing in this codebase that would contradict its own stated principles. It also spends the reader’s identifier, not the sender’s.
Recommendation: (a).
5.2 A reply draft is not a read receipt
The request, corrected: not watching a recipient write, but letting one part of a dialogue say how far a plausible replier has come, so the other part can calibrate. The states suggested are open, skimmed, read-and-will-read-again, read-and-will-reply-today. That is a different thing from a read receipt, and the difference is who chooses.
A read receipt is a fact about the past: this reader reached the end. A progress note is a statement of intent about the future, offered by the reader, and it is the only kind of signal here that can be honest. Sending one unasked is surveillance; offering one is courtesy. So the mechanism is not a button the sender polls, it is a message the reader sends.
The states, and what each actually promises:
| the reader says | it means | it does not mean |
|---|---|---|
| opened | the link worked and something rendered | that a word was read |
| skimming | partway, in and out, not yet a position | that attention was low |
| read | reached the end at least once | that it was understood or agreed with |
| read, will read again | forming a view, not ready to answer | that a reply is coming soon |
| read, will reply | a reply is intended | when, or whether, it arrives |
| will not reply | the answer is no, and that is the answer | anything at all about the reader |
No state names a day. The first draft of this table had read, will reply today, and it
was wrong twice over. It was self-contradictory, since the phrase and the day together
promised exactly what the neighbouring column denied; and a state that carries a date turns a
courtesy into a debt. The reader would be making a promise to a stranger’s expectations at the
moment they least have the information to keep it, and the sender would be holding a date
rather than a disposition. Timeframes belong to the reply, where the person who can honour
them writes them down. will reply says what is true and costs nothing to say: it is the
reader telling the sender how to weigh the silence.
This is the same rule as the hour-floor in the fourth point below, applied to the other side. There, the museum coarsens what it observes; here the vocabulary declines to manufacture what was never observed. A signal that has been smoothed on one edge and sharpened on the other is not honest, it is just quiet.
Four rules, and the reasons are the design:
- The reader sends; the sender never polls for it. A pollable flag is a hidden camera. This is the answer to the failure mode in the original worry, and it costs one POST.
- Each state replaces the last; nothing is appended. A reader who moves from skimming to read has not made two statements, they have made one. The chain keeps the whole history for honesty, the sender’s view shows only the latest.
- It is a first-class chain record, not a counter.
scroll.state_changedwith the state in the body. It is evidence about the dialogue, so it belongs where evidence lives, and because it is chained, invariant 2 is satisfied for free. - No timestamps finer than the hour, and never in the reader’s view. The reader must be able to see exactly what the sender sees, including the coarsening. A receipt the reader cannot inspect is not informed consent, it is a surprise.
One asymmetry worth keeping. opened is the only state the museum can observe by itself, and even that it observes as a sighting with no state attached. The other five are given. If the reader never gives anything, the sender learns that the link was opened and nothing more, which is the correct default and also the honest one.
Where this contradicts the invariants, and how it does not. Naming intended recipients in clear text plus a per-link sighting log is a de-anonymisation surface for the reader, not the sender, so invariant 4 is untouched as long as the log records no address (see 5.1) and the reply never carries a byline the sender has not already been given.
5.3 Elias.Venn has no licence
tabenius/Elias.Venn is private and has no LICENSE file, so default copyright applies and
all rights are reserved. Since you are the author this is administrative rather than legal,
but it should be a deliberate act rather than an accident of omission, and the siblings differ:
weftmark is Apache-2.0, while nostoi and BAZ.AI-Governance are MIT.
Recommendation: add an explicit licence to Elias.Venn before anything is copied out of
it, and prefer extending Mouseion, which already adopted the Elias.Venn identity layer and
re-termed article to scroll, over forking a second copy. A separate editors repository earns
its keep only when a second consumer actually appears; a copy made for the future is a fork
with extra steps and no second user.
6. The slices
Slice 1, the provenance view. Unblocked, no decision needed, no migration.
Wire src/trust.js to a public page: claims, effective revocations, the chain records they
point at, and the attestations, rendered as the three separated facts the project already
distinguishes. This follows doc/03-Attestation_federation.md:170-173, whose checklist items
5 to 8 are unchecked and which already specifies that public artifact views list claims and
effective revocations, rendering all untrusted strings as escaped text.
Gate: a test that a claim appears, a revocation removes it, and nothing renders unescaped.
Slice 2, the secret link. Blocked on 5.1.
A scroll_seals table with scroll_id, key_hash, created_at, created_by, revoked_at.
A route of the form /s/<token>/<slug> added before the fallthrough, noindex, absent from the
nav at src/render.js:67, answering 404 rather than 403 when the token is wrong, primary host
only. Each successful read appends a sighting record to the chain.
Gate: a wrong token is indistinguishable from a nonexistent scroll, the raw token is never
stored, and a revoked seal kills the link.
Slice 3, the reader’s own state of mind. Blocked on 5.1.
POST handlers must be matched before pages(), which returns null for anything but GET and
HEAD (src/worker.js:79), so they belong in /api next to the existing scroll mutations. The
reader posts one of the six states in 5.2; the museum appends scroll.state_changed carrying
that state and never infers one. The sender’s view is a page on the scroll, never on the
reader’s link, and it shows only the latest state.
Gate: a chosen state survives a reload, is chained, a second choice replaces rather than
appends, and nothing identifying the reader is written beyond what 5.1 allows.
Slice 4, a self-contained HTML artifact.
A scroll kind that renders to one HTML file with its CSS inlined and no external requests, so
the artifact can be handed over as a single file. /leave already exists to make leaving the
museum explicit, and the raw origin already serves scroll bytes unframed.
Gate: the file renders identically with the network off.
7. What we are not doing yet
- Mutual vouching graphs beyond what
trust_claimsalready models. - Hard access limits such as a link that works three times. Counting is not limiting; when a limit is wanted it belongs on the seal, not in the reader’s view.
- Real-name verification.
real_namesexists, and linking a name to a key isauthor.authorshipand nothing more. - Any change to the invariants. Invariant 4 says a real name is never connected to a pseudonym; a letter with a named recipient and a pseudonymous byline must not leak the connection through the visit log, which is a further reason 5.1(a) is the recommendation.
8. Open questions for the roadmap
- Does a letters-to-named-people feature belong in Mouseion at all, given invariant 4? A letter names its recipient, and a pseudonym byline must stay unconnected even by inference from who was addressed.
- Should the provenance view be one page per scroll or one global page for the whole museum? The chain is global; claims are per subject.
gift-manifest.md, the one artifact the Marija handover directory asks for and which does not exist anywhere, is aragbaz.artifact/v1object living inragbaz-cf-template/extensions/artifacts/, not a Mouseion scroll. Recorded here so it is not built twice.
9. A second front door: message.ragbaz.cc
The letter workflow has a different reader and a different risk profile from a scroll. A scroll is looked up by anyone; a letter is handed to one person and must be indistinguishable from nothing at all when it has not been opened. Serving both from one origin means one hostname is both the most public thing here and the most private, and every future host decision becomes a decision about both.
So the message side gets its own front door, message.ragbaz.cc, alongside the existing
raw.mouseion.ragbaz.cc. classifyHost in src/origins.js already returns a kind per host
(raw, primary, alias, raw-alias, or null for an unknown host, which is the right
default), so a message kind is one more case and no new mechanism. What changes:
- The primary host keeps its behaviour exactly. No message route is reachable there, so there is nothing new to attack and nothing new to explain on the About page.
- The message host serves the reader’s view only: the letter behind its seal, the state the reader chooses to send, and the reply. It does not serve listings, chain pages, or the API.
- Its Content-Security-Policy is its own. A letter is one document from one stranger; it gets
form-action 'self'and nothing else, and noframe-ancestors 'none'exception. - Deployment is a
custom_domainentry inwrangler.jsoncbeside the existing five. Nothing is live until that lands, which is the right place for this decision to become real.
9.1 Skins belong to the sender, not the reader
The first draft of this section made a skin a reader-side chrome, which is wrong: a skin is what an author chooses when writing or editing online. Two things hang off it, and they are separable, which is why the word earns its place:
- A stylesheet. The presentation the author writes in and the reader first meets. Papyrus, a plain sheet, and whatever else earns its place. Presentation only: it cannot change what is stored, what is chained, or who may read it.
- An editor. The editing surface itself, with more to come. This is the Elias.Venn question, and it is now the load-bearing half rather than a nice-to-have, because online authoring is where a sender actually spends time.
So there are two seams, not one, and conflating them is how a message system ends up with a theme picker where the editor should be:
| seam | belongs to | varies | must never vary |
|---|---|---|---|
| skin | the sender, at authoring time | stylesheet, template, editor | what is stored, chained, or readable |
| front door | the reader, at the hostname | which surface is served | the data behind it |
Rules that keep the skin seam from becoming a leak or a fork:
- A skin is data, resolved through one lookup. A closed registry, an unknown id falling back to the default rather than erroring. A third editor is a new entry, not a refactor.
- A skin cannot widen anything. It selects presentation. It cannot add a route, widen an audience, or reveal a field the underlying data does not already carry for that reader.
- The choice is recorded, not implied. Which skin a scroll was written in belongs in the publication metadata and therefore in the chain, so the artifact’s appearance is as checkable as its bytes.
- It is not a query parameter. The sender’s choice is stored; a reader cannot switch skins to impersonate a different surface.
This raises the stakes on 5.3. An editor is not a nice-to-have import, it is the authoring
surface the sender uses, and tabenius/Elias.Venn still has no LICENSE. Adopting its roughly
140 KB of block-editor code under default copyright is not a shortcut; it needs the licence
added first, deliberately.
10. What kind of thing is a scroll, for a person who is not a publisher
The museum’s own first line already says it: *things made by people, by agents and by the two together.* This RFC records what that requires in code, because two of the three cases are not yet properly served and one of them is the most interesting.
Already fine. A hand-written HTML scroll publishes, stores, chains and displays exactly like
any other. It needs no model, no harness and no provenance: generation is absent and the
colophon says so. The compiled single file (src/single-file.js, route /single/<id>) is
precisely the artifact this audience wants: one file, opens anywhere, renders with the network
off, and the reader can hash it and compare it against the manifest.
Gap one: the colophon cannot say “no model was involved”. provenanceHtml renders an absent
generation as Not recorded, which is ambiguous between written by a person and *we do not
know*. Those are different claims and only the publisher can make the second. The fix is a
distinction, not a default: absence of generation is the museum saying it holds no model
record, never that a model was or was not used. A publisher who wants to assert authorship by
hand says so with an author.authorship claim, which is signed and checkable.
Gap two: an old-school hand-coded page cannot be edited here yet. Writing HTML by hand in a text editor and publishing the file works. Editing it in the museum means the editor, and the editor means Elias.Venn’s roughly 140 KB of block-editor code, which has no licence (5.3). The single-file compiler is the useful half without it: hand-written in, one file out.
Gap three, and it is a positioning question, not a feature. If the museum takes work written entirely by a person, then the provenance story has to work for a person who will never read a chain. The honest version of the provenance page for that reader is one sentence and a hash, not a table of claims. Whatever the provenance view grows into, it needs a plain-language floor, or it will end up explaining itself to nobody.