Artifact d4119882562d38d5913983c97611d0ec18b531bb1c6b377f9de1c76011f20682:
- File docs/rfcs/0016-attachments.md — part of check-in [7e893b1d3e] at 2026-09-12 01:41:00 on branch attach-paste — Paste or drop to attach: a file previews in the composer and uploads when the post, comment or edit is submitted; the server commits it against the new object (user: dmestas, size: 17453) [annotate] [blame] [check-ins using] [more...]
- Feature Name:
attachments(files on tickets, wiki pages, technotes and forum posts: fossil's A card in the DAG, the bytes in the steward's own bucket) - Start Date: 2026-09-11
- Status: Draft for review
- Depends on: RFC 0001 Principles 3, 5, 7 and §11 (disposability); RFC 0004
(the
bandBletters); RFC 0008 and its steward amendment (who binds a bucket, who reviews, who removes); RFC 0005 (event vocabulary); RFC 0015 (a forum's attachments live with its companion); the moderation staging quarry already runs (maybeStage) - Upstream: zit#186 (
attach add / rm / list,attachment-size-limitwith a 1 MiB default); zit#187 (recognise the stub). Neither gates Phase 1. - Precedent: fossil stores attachments as two artifacts through the same compressed, delta-capable path as any file, governed by one setting whose default is unlimited, and keeps large files out of history with unversioned content instead
Summary
An attachment is a file on a ticket, a wiki page, a technote or a forum post. Fossil settled its shape long ago: the bytes are a blob, and a small artifact carrying one A card binds a filename to a target and to that blob's hash. quarry's index already folds A cards with fossil's exact semantics. What is missing is writing them, showing them everywhere, and deciding where bytes uploaded through a forge should live.
Three decisions:
- The A card stays fossil's. Nothing in the format changes. A fossil client reads every attachment quarry writes.
- What the source points at depends on who wrote it. A person with a clone attaches a real blob through zit, under a low cap (zit#186). quarry never writes a file blob: an upload through the forge becomes a stub, a few hundred bytes naming the content by hash, and the bytes go to a bucket the repository's steward brought — S3, R2, anything that speaks the S3 signature. Presigned upload and download mean the bytes never touch the forge.
- Nothing renders until a person vouched for it. Uploads from accounts the repository does not trust are held; a held file is a link with a name, never an image on a page, until a steward accepts it.
Attachments that arrive by import or sync are real blobs and keep working as they do today. Both kinds show on the same pages and a reader cannot tell them apart.
Why not the DAG, and why not ours
A blob in the DAG travels to every clone for ever, deletion included, because a deletion is a new A card and not a removal. That is right for history and wrong for a 300 MB screen recording somebody dropped on a bug. Fossil answers with a size setting defaulting to unlimited and with unversioned content for big files; most operators set neither.
A bucket quarry owns would fix the clone and create a custodian: files the forge stores, transmits, scans and answers for. Bring-your-own storage moves every one of those to the steward. The forge brokers references — mints a presigned request, verifies what landed, writes the stub — and holds no bytes. Storage cost, retention, deletion and the keys are the steward's; a bucket they delete takes its files with it and leaves stubs that say so. What the forge keeps is the one duty no design removes: it publishes the link on its domain, so it needs a way to stop publishing it.
RFC 0001 already names S3 as a truth tier. Principle 7 asks only that the exit stays open, and it does: the bucket is the steward's, its layout is content-addressed and documented, and an export writes every attachment out beside its A card.
The stub
A blob quarry writes in place of the file. Plain text, so a client that does not know it shows a small readable file under the attachment's name.
quarry-attachment 1
sha256 4f9a…e01c
size 18349022
type video/mp4
name screen-recording.mp4
No URL. The bytes are found by content hash in whatever bucket the
repository binds today, so a steward can move buckets without touching
history. The A card's N card carries the same mimetype, so fossil's own
attachment page reports it correctly. zit#187 asks zit to recognise the
shape in attach list and artifact; nothing depends on it.
The index learns to tell a stub from a file at fold time (the first line
is enough) and records external, sha256 and size beside the row.
Every list and page reads those rather than the blob.
Binding a bucket
A steward binds one bucket per repository on its settings page:
| field | what |
|---|---|
| endpoint | https://<account>.r2.cloudflarestorage.com, an S3 endpoint, or any S3-compatible one |
| bucket | the bucket name |
| region | as the provider requires; auto for R2 |
| access key, secret | scoped to that bucket; stored encrypted in OLTP, never returned by any op |
| public base | optional: a public or custom domain the bucket serves from; without it every download is presigned |
| limit | the per-file cap for uploads through quarry, default 100 MiB |
The binding is forge-local (OLTP), like a grant. The settings page tests it on save: a presigned PUT and DELETE of a probe object, so a wrong key is a red line on the form and not a broken upload later. Fleet shows the bucket's last probe beside the repository the way it shows a mirror's last pull. A companion forum (RFC 0015) uses its parent's bucket; a mirror cannot bind one, since a mirror accepts no writes.
The secret is sealed with AES-256-GCM under a key quarry keeps in
secret.key beside oltp.db, made on first use with mode 0600. A copy
of the database alone yields no credentials; a steward whose forge lost
the key file enters the secret again on the settings page, and that is
the whole recovery. The ops are bucket.get, bucket.set,
bucket.probe and bucket.unset; none answers the secret.
Objects are keyed attachments/<sha256> so the same bytes attached twice
are one object, and so the export and the migration below can verify what
they read against the hash in the stub.
Writing
Three ops, one flow. The browser never sends the file to quarry.
attachment.begin — {repo, target, name, size, type, sha256}.
Gated on b (tickets, wiki, technotes) or B (forum posts), on the
target existing and being writable, on a bound bucket, on the limit, and
on the type policy. Answers a presigned PUT whose signed policy fixes the
key, the content length and the content type, valid for fifteen minutes,
and a ticket id for the commit.
attachment.commit — {repo, ticket}. quarry HEADs the object,
checks size and, where the provider reports it, the checksum; refuses if
either differs from what begin was told. Then it writes the stub through
zit attach add - --name NAME --mimetype TYPE --user WHO --comment TEXT,
which writes the blob and the A card, and answers both hashes. The
attachment exists from this point and not before: a PUT that never
commits is an orphan object the steward's bucket lifecycle rule deletes
after a day.
attachment.remove — {repo, target, name}. zit attach rm, an A
card with no source. If the repository's setting says so, quarry then
DELETEs the object; by default it does not, because a removal is a
decision about the page and deletion is a decision about the bytes, and
the steward owns the second.
With no script, the ticket page carries a plain <form
enctype="multipart/form-data"> that posts to quarry, which performs
begin, the PUT and commit on the reader's behalf, streaming and never
buffering the file to disk. That is Principle 3 kept: the scripted path is
the enhancement, the form is the floor, and the file still never rests on
the forge. attachment.list — {repo, target} — answers name, size,
type, who, when, held-or-live and the download form, and the same rows
ride inside ticket.get, wiki.get, technote.get and forum.get.
Reading
Every target lists its attachments, in one section with one shape: name, size, who, when, a download, and for holders of the letter an add and a remove. The ticket page has it today; wiki pages, technotes and forum posts get it in Phase 1.
Downloads. A real blob is served by quarry's existing route from a
separate attachment origin with X-Content-Type-Options: nosniff, a
Content-Security-Policy that permits nothing, and Content-Disposition
naming the file. A stub is served by the bucket: a link on the public
base when the repository is public and one is set, otherwise a presigned
GET minted at render time that expires in an hour, so a private
repository's file cannot be shared onward.
Inline. An image renders inline only when two things hold: its type is on the allowlist — PNG, JPEG, GIF, WebP, plain text — and a person vouched for it (below). SVG is always a download: inline SVG is script on the page's origin. Everything else is a link.
Bodies. The markup resolver, the one seam every reference already
goes through, learns attach:NAME: a link to the attachment of that name
on the same object, and an image when used as one. 
in a bug report shows the picture; a fossil client renders a link.
Trust, held uploads and screening
An upload is held unless the uploader is trusted for that family:
holds the repository's moderation letter — 5 on the forum, q on
tickets, l on the wiki — or is a steward. This is the rule maybeStage
already applies to forum posts, extended to the same objects' files. A
held attachment exists in the DAG (the A card is written; a fossil client
sees it) but on quarry's pages it is a link with its name and size, never
an image, until a steward accepts it in the review queue. The queue lives
beside the forum's on the settings page and offers accept, reject and
remove; reject writes the sourceless A card and, if configured, deletes
the object.
quarry does not scan bytes it never sees. What it enforces without seeing
them is the type policy (allowlist by default; a steward may open
archives or executables for their repository), the size limit in the
signed policy, and the held rule. A steward who wants malware scanning
runs it on their bucket, where every provider sells it, or as a command
the review queue can call with the object's presigned URL. That command
is a repository setting in the shape of the existing forum-hook:
accept, hold, or reject on stdout.
Report and remove. Every attachment carries a report control. A
report reaches the stewards through the landing family (RFC 0011) and
lists in the queue. Removal is the sourceless A card plus, for a stub, an
optional object delete; for a real blob that arrived by sync, the remedy
is zit shun, which removes the artifact from the store and records it
as never to be fetched, and which the queue offers to the operator for
exactly this case. Both leave an audit row: who removed what, when, why.
Export, migration and the exit
- One file: the download.
- One object's files: a zip from the object's page.
- A repository's files:
attachment.export, a streamed archive of every live attachment with its A card beside it, real and stub alike, each verified against its hash as it is read. This is the steward's backup and the exit for the bytes the DAG does not hold. - Migration:
attachment.migrate, offered to a steward whose repository arrived by import with real blobs. For each live real attachment it copies the blob to the bucket, writes a stub and a new A card, and lists the old blobs for ashunthe operator may run. The page says plainly that until the shun runs every existing clone still holds the bytes.
Events and limits
attachment.added and attachment.removed join the vocabulary (RFC
0005), on the target's repository, so webhooks and the activity digest
can say "dmestas attached spec.pdf to #4112". Attachments do not notify
a ticket's followers directly in this RFC; the digest carries them and
the next comment is where the file is discussed.
The DAG-tier cap is zit's attachment-size-limit (zit#186); quarry never
exercises it since it never writes a file blob. The bucket-tier cap is the
binding's limit. Both are per repository.
What this does not propose
- Any storage the forge owns. Covered above.
- A default bucket offered by the operator. It reintroduces custody; the operator who wants attachments on their own repositories binds a bucket as any steward does.
- Chat attachments. Chat is not in the DAG; when messages have a durable home this design attaches to it unchanged.
- Email attachments (RFC 0014 declined them) and document previews.
Phases
Phase 1 — finish reading. Attachments on wiki, technote and forum
pages and in their ops; the separate attachment origin with its headers;
inline images behind the allowlist; the attach: resolver; the two
event nouns; the index recording the stub fields. No zit change, no
bucket.
Phase 2 — binding. The bucket table, encrypted credentials, the
settings section with the probe, Fleet's row, attachment.list as an op.
Shipped 2026-09-11: the bucket.* ops, the sealed secret, the
Attachments section and Fleet's probe line.
Phase 3 — writing. begin, commit, remove, the presigned flow,
the no-script form, the held rule and the review queue, report and
remove with the audit row. Needs zit#186 for attach add.
Shipped 2026-09-11 against zit 0.3.13: attachment.begin, .commit,
.remove, .vouch, .reject, .held and .link, bucket.policy
and bucket.limit, the form floor on the pages and at
POST /api/v1/r/{repo}/attachments/{target}, the stub folded with
external, sha256 and size on the row, held cards, the queue in
the settings section, and the two switches. Deviations from the text
above are in the open questions.
Phase 3, follow-up — paste and drop. Shipped 2026-09-11: a paste
or drop into a composer previews the file and puts the attach:
reference at the caret, and nothing is sent until the form is
submitted; then each file is begun as a DEFERRED ticket — no target, a
family — and PUT, and forum.post, forum.reply, ticket.new,
ticket.set and wiki.save commit the tickets through their attach
list once the object has a hash. A paste or drop on a section fills
its picker. See open question 8.
Phase 4 — export and migration. attachment.export,
attachment.migrate, the operator's shun offer.
Open questions
- Held for whom. The trusted rule reuses the moderation letters. A repository with moderation off (the default for a native repository) would hold nothing; is that right, or should attachments be held by default even where posts are not?
- The bucket limit's default. 100 MiB is proposed as "a screen recording fits, a disk image does not". The steward pays for it either way.
- Deleting objects on remove. Off by default here. A steward who wants a removal to also delete the bytes flips one setting; is that the right default for a public forge, or should removal always delete?
- The form floor's bound. §Writing says the no-script form streams and never buffers. A presigned PUT signs the content length, and a multipart part's length is unknown until it has been read, so the floor counts and hashes the file in memory, never on disk, and is bounded at 32 MiB; a larger file needs the scripted path, which knows the size from the File object. Multipart upload to the bucket would lift the bound at the cost of three more S3 verbs. Left as is.
- The lifecycle rule. §Writing suggested a bucket rule deleting
day-old
attachments/objects. With one key per content hash such a rule deletes live files too, so the running guide says the opposite: no rule, and orphans — objects no stub names — are the steward's to sweep. A separateuploads/prefix with a copy on commit would let a rule work, at the cost of a second object per file. - Report and the audit row. Phase 3 shipped vouch, reject and
remove; the report control and the audit row of who removed what
and why are not yet built, and the forum-hook-shaped scanner is not
called. The events are the ingest's:
attachment.addedand.removedare raised when the A card folds, so the ops raise nothing themselves and a client that asks commit and then listens hears exactly one. - Missing means unbound. A stub is drawn as missing when its repository has no bucket bound; whether the bucket actually holds the object is not asked at render time, since a HEAD per card per page would be a request to the steward's bucket for every reader. The download redirect answers 404 from the bucket in that case.
- Deferred tickets. A ticket begun as a composer is submitted names no target: the gate at begin is the family's letter, and the target is checked for existence and family at commit, so a ticket begun with B for the forum cannot land on a wiki page. It keeps the ordinary hour, since the upload happens at submit and the object is committed moments later; a post refused after its files were PUT leaves them as orphans for the steward's sweep. Whether a moderated post's files should be uploaded again on release or committed by the moderator, and whether the local ticket tier should learn to carry an A card (today a new ticket takes files only when written to the fossil), are open.