Artifact 2bfd5426f1436e91d376e1d14606d1a1243379fd25e563d86fc16b8079d54e44:
- File docs/rfcs/0016-attachments.md — part of check-in [6688524a07] at 2026-09-11 14:49:10 on branch rfc-0016 — RFC 0016: attachments — fossil's A card in the DAG, the bytes in the steward's own bucket (user: dmestas, size: 13607) [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.
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.
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.
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?