- 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 `b` and `B` letters); 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-limit`
with 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:
1. **The A card stays fossil's.** Nothing in the format changes. A fossil
client reads every attachment quarry writes.
2. **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.
3. **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 a `shun` the 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`.
**Phase 4 — export and migration.** `attachment.export`,
`attachment.migrate`, the operator's shun offer.
## Open questions
1. **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?
2. **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.
3. **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?