Skip to content
quarry Artifact [caa7af59aa]
Login

Artifact [caa7af59aa]

Artifact caa7af59aaf4b380c5bf6bf0ab934c294a34653a80fa148817aec267fb3355bf:


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. ![crash](attach:crash.png) 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

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

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?