Artifact acfd20b3347c378d8135766b2d45001a7967dd69ee877f70e0a18fba1c84a78d:
- File docs/running.md — part of check-in [93f25d23e0] at 2026-09-26 16:01:44 on branch the-login-limiter-keys-on-a-trusted-address — The trusted-proxy rule reaches the client-error route, is believed only from loopback, and the deploy note says the flag must ship with it (user: dmestas, size: 44575) [annotate] [blame] [check-ins using] [more...]
Running quarry
Two ways: the compose stack, or by hand. The hand-rolled path is what this was developed against and is what the compose file encodes, so it is written down first — a run book that stays true even if the images are never built.
What the stack is
| piece | role | state |
|---|---|---|
| zit | the store. quarry shells out to it for every read and write (0001 §2) | the .zit files themselves |
| quarry | the forge: indexes, UI, API, sync fronting | derived indexes, embedded NATS, OLTP database |
| notify | notifications: workflows, subscribers, topics, the inbox quarry embeds | SQLite |
| dagnats | the workflow engine notify runs on: embedded NATS + JetStream + an HTTP bridge | JetStream data |
zit is not a runtime dependency in the ordinary sense — it is quarry's
only store interface, so its version is part of quarry's behaviour.
On 2026-08-16 three zit builds changed what quarry does: frames got
31% faster, a card-parsing fix changed which artifacts parse at all (and
until it landed, sqlite's 794 tickets were invisible), and a new
stat -R let quarry serve a bare store. Pin the pair; do not let a
deploy resolve zit from whatever is on PATH.
By hand
dagnats
Runs under launchd on this machine as com.dmestas.dagnats, binding
NATS on 4222 and its bridge on 8080, with data in
~/Library/Application Support/dagnats.
launchctl kickstart -k gui/$(id -u)/com.dmestas.dagnats
A freshly-built Go binary replacing it must be ad-hoc signed or
launchd kills it with OS_REASON_CODESIGNING and the service silently
fails to come back:
go build -o ~/go/bin/dagnats ./cmd/dagnats
codesign -s - -f ~/go/bin/dagnats
launchctl kickstart -k gui/$(id -u)/com.dmestas.dagnats
In standalone mode dagnats binds loopback, which is why anything talking to it shares its network namespace under compose rather than reaching it by service name.
notify
notify bootstrap -dev -db notify.db -org quarry -env dev # prints apiKey once
notify serve -dev -db notify.db -addr 127.0.0.1:3000 \
-nats-url nats://127.0.0.1:4222 -bridge-url http://127.0.0.1:8080
curl -s localhost:3000/readyz # {"status":"ok","bridge":true,"db":true}
-dev derives a fixed, publicly-known master key. Outside development
set NOTIFY_MASTER_KEY_HEX.
Provisioning is quarry's job, not an operator's (RFC 0011 §7). What
used to be a page of curl here is now code: quarry creates a notify
subscriber when an account is added, adds or removes the account from
a repository's repo-<name> activity topic when it is granted,
revoked, or subscribes to a thread, and creates its workflows
(quarry-mention, quarry-forum-reply, quarry-activity,
quarry-landing, quarry-changelog, quarry-error, quarry-slow,
quarry-test — defined in internal/notify/workflows.go) at startup, leaving any workflow an
operator already edited untouched. quarry-landing is provisioned
ahead of need: nothing triggers it yet, that lands with the pull
request review surface. All of it is best-effort: a notify outage
never fails the account or grant op that raised it.
Scripts are compacted at start, not at build. quarry's own scripts
are minified in memory when the process starts (the stylesheet has its
comments and indentation stripped the same way), then served under
content-hashed, immutable names; the sources in the repository
stay readable and there is no build step to run. make wire
URL=https://your-forge/ prints what a first visit costs per file. Set
QUARRY_ASSETS_RAW=1 to serve the sources as written while editing them.
Email needs a provider, and quarry gives notify its own. A workflow
with an email step and no mail provider behind it fails where only
somebody reading notify's database would find it: store: not found,
per message, while the settings page goes on offering an email column.
So if quarry has a mailer — RESEND_API_KEY and QUARRY_MAIL_FROM,
the same pair that sends invites — it hands notify the same credential
at startup and logs notify: email now sends as …. One credential, in
one place, and nothing to POST by hand.
Email is off until a person switches it on. The four family workflows carry an email step (plain text, one link; mention, reply and landing accept replies), but notify reads an absent preference as enabled, so a step alone would mail every account about everything. At every start, and when an account is created, quarry writes an explicit "no email" for each family the person has not answered, and never touches a cell they set themselves. The settings page is where they answer.
An email integration notify already holds is left exactly as it is, so
configuring SMTP or SES in notify's own console is still the way to use
a different provider from the one quarry sends invites with — a restart
will not replace it. With no RESEND_API_KEY quarry asks the companion
for nothing rather than creating an integration that cannot send.
Invitations are the exception that proves the split: they are sent by quarry's own mailer and are not a notify workflow, because an invitee has no account, no subscriber and no inbox for any other channel to reach — and a forge that sent them both ways delivered two invitations from one address.
Receiving email needs nothing but notify. A reply to a notification
email comes back to reply+<token>@<inbound domain>, and notify can
receive it itself (notify#340): set NOTIFY_SMTP_ADDR=0.0.0.0:25 and
NOTIFY_INBOUND_MX_HOST=mail.example.org, let the service bind a
privileged port, and point the domain's MX at the box. On a systemd
host that is one drop-in:
# /etc/systemd/system/notify.service.d/smtp.conf
[Service]
Environment=NOTIFY_SMTP_ADDR=0.0.0.0:25
Environment=NOTIFY_INBOUND_MX_HOST=mail.example.org
AmbientCapabilities=CAP_NET_BIND_SERVICE
and two records — mail.example.org A <the box> (not proxied: a CDN
does not carry SMTP) and mail.example.org MX 10 mail.example.org.
Register the domain with POST /v1/inbound/domains {"name":…} and
POST /v1/inbound/domains/{name}/verify; verification is a live MX
lookup, so it fails until the records have propagated and can simply be
repeated. This is not a mail server: an address without a valid reply
token is refused at RCPT TO with 550 5.1.1, before any message body
is read, so there is no inbox to spam and nothing to filter. It speaks
plaintext today; a sending MTA that offers STARTTLS falls back cleanly.
A host that already runs an MTA can pipe instead of listening:
notify inbound < message.eml reads one raw message on stdin and hands
it to the same path, so an /etc/aliases line does the job without a
second port.
What a reply can answer. Four notifications carry a binding quarry can read back, and hitting reply on any of them writes the same thing the web form would:
| notification | the reply becomes |
|---|---|
| a forum thread you follow | a reply on that thread |
| an @mention in a forum post | a reply on that thread |
| an @mention in a repository's room | a line in that room, answering yours |
| an @mention in a team's room | a line in that room, answering yours |
This is per ACCOUNT and needs no privilege: the gate is the room's own
capability (C for a repository's chat, 3 for the forum, membership
for a team's room), checked against the person the notification was
addressed to. A reply from any other address is refused — the binding
names one account and only that account can answer it.
The chat bindings were minted from the day chat had mentions and nothing read them back, so a reply to one was accepted, understood and dropped with "no thread behind transaction". They land now.
Two limits worth stating:
- A chat reply names the line it answers, when it still can. The window is finite and a mail can sit in a drafts folder for a week; a reference that has aged out is refused, and the reply is then posted without it. Losing the thread link beats losing the reply.
- A reply to a forum post held for moderation is refused, as the web form is. Nothing is silently queued.
Upgrading a forge provisioned before RFC 0011 Phase 3. The workflows
now carry more than one step each: quarry-mention, quarry-forum-reply
and quarry-landing gained a push step after their in-app one, and
quarry-activity gained a 15-minute digest before its in-app step
(RFC 0011 §6). Provisioning upserts with notify#303's
PUT /v1/workflows/{slug}?onConflict=keep, which creates a missing
workflow and leaves an existing one exactly as it is — an operator may
have edited its copy, and keep is what protects that edit from the
next restart. So an existing install keeps the single-step definitions
it was provisioned with. A forge provisioned for the first time needs
nothing.
To pick the new definitions up, delete those four workflows once and
restart quarry; the next startup recreates them from
internal/notify/workflows.go. The {slug} is the trigger identifier,
not the workflow's ULID:
K=... # the notify apiKey from bootstrap
for w in quarry-mention quarry-forum-reply quarry-landing quarry-activity; do
curl -fsS -X DELETE -H "Authorization: ApiKey $K" \
http://127.0.0.1:3000/v1/workflows/$w
done
# then restart quarry; startup recreates all six.
Deleting a workflow deletes nothing a person can see — messages already delivered stay in their inbox. Do it during a quiet moment anyway: a trigger fired between the delete and the restart reaches nobody.
What a companion that does not answer costs. The preferences pair
(GET|PUT /v1/subscribers/{id}/preferences) and the per-device
credential DELETE have shipped, so both halves normally land. When
notify is unreachable quarry says so rather than pretending:
me.prefs.setwrites its forge-local half first (the toast and desktop switches, the per-repository mute) and reportsapplied.notify: falsewhen the notify half could not be stored. The channel matrix a person set is then not yet in force. A read that notify did not answer marks every familyunknownand the matrix is left ALONE on the next save, rather than writing quarry's defaults over settings it could not read.me.push.unsubscribekeeps the device row markedpending_remote_deletewhen notify FAILED the delete, instead of forgetting a browser notify still pushes to, and retries on the nextme.push.subscribe. A 404 is not a failure — a credential notify does not hold is the end state unsubscribe asked for — so the row is forgotten there and then. Thepush_device.pending_remote_deletecolumn is now only ever a transient state between a failed delete and its retry.
Both clear themselves once notify answers — no migration, no reset.
quarry
quarry serve --addr 127.0.0.1:8950 --data ./data \
--notify http://127.0.0.1:3000 --notify-key "$K" \
--repo quarry=$HOME/quarry
--data is not optional in practice: it defaults to .quarry beside the
first --repo, which writes derived state into whichever repository
happens to be listed first. Always pass it.
Gates are open until they are closed, and closing them is two separate credentials on purpose — accounts are the forge identity (argon2id), the sync secret is the RFC §6.3 value the protocol fixes:
quarry account add --user you --admin --data ./data # closes web/API writes
quarry user add --repo name=$HOME/quarry --user you --data ./data # closes push
The account gate is global: the first account closes writes on every repository in the fleet, not just one.
Behind a proxy, name the one header it sets that a client cannot, with
--trusted-proxy-header (or QUARRY_TRUSTED_PROXY_HEADER). Only a
request that carries that header AND arrives over loopback is believed
for the client's address, which the login limiter keys on, and for
X-Forwarded-Proto, which sends http readers to https and sets HSTS.
From any other peer the header is ignored, so a directly reachable
listener cannot be fooled by a client that writes it.
Behind cloudflared this flag must ship in the same deploy as the code
that introduced it, set to CF-Connecting-IP. Unset, no forwarded
header is believed, and every request arrives from cloudflared on
127.0.0.1. Every sign-in then keys on that one address: five wrong
passwords from anyone lock sign-in for everyone, with backoff up to
about two hours. The http-to-https redirect and the HSTS header also
stop. Cookies stay Secure only because --origin is https.
quarry serve ... --origin https://forge.example --trusted-proxy-header CF-Connecting-IP
The attention stream and HTTP/2
Signed-in pages open one Server-Sent Events connection for the attention
stream — the inbox badge and the count in the tab title (RFC 0011 §5).
In-page banners are off by default and are turned on per account on
/me (the notifications section), which is also where desktop
notifications are opted into; with both off the stream still moves the badge and the
title, and nothing ever covers the page being read. On plain HTTP/1.1 a browser allows six connections per
origin, and a repository page already spends one or two of them on the
chat and timeline streams. So the default opens the attention stream on
repository pages only: a stream on every page would compete with the
page it sits on for the same six slots, and the symptom is a page that
stalls halfway through loading rather than an error anybody can read.
With HTTP/2 in front there is no such limit — one connection carries every stream — so the whole forge can hold it open:
quarry serve ... --attention-everywhere
Use it when quarry is behind TLS termination that negotiates h2, or behind a proxy speaking h2c to a browser. quarry serves plain HTTP/1.1 itself, so the flag is a statement about what is IN FRONT of it and quarry cannot check it: turned on without h2, it is the six-connection problem on every page.
Nothing else changes. Without the stream a page still renders its own state and the inbox badge is fetched once after load, which is the Principle 3 behaviour every one of these surfaces keeps.
Push
/me's notifications section carries the whole of RFC 0011 §6: the
family x channel matrix (mention, reply, landing, activity, by in-app,
push and email), the two forge-local switches, the repositories this
account muted, and Push on this device. /me/notifications still
works as a bookmark — it redirects there.
Push is the one feature in quarry that needs JavaScript, recorded as an
explicit exception in RFC 0011 §11. Enabling it registers /sw.js, asks
the browser for notification permission from the click that asked for
push, subscribes with notify's VAPID key, and posts the resulting
PushSubscription to POST /me/push. With JavaScript off the control
says so and nothing else on the page changes. On iOS a site must be
added to the Home Screen before Safari will grant push at all — the
control says that too.
Quarry never holds a VAPID key and never stores an endpoint: the subscription goes to notify as subscriber credentials, and quarry keeps a hash, a user-agent label and a timestamp so the page can list the browsers somebody may want to turn off. That is also why a browser other than the one being used is turned off from that browser.
What to expect until the upstream work lands:
- notify#302 (the
webpushprovider) — subscriptions are accepted and stored, but nothing is delivered by push yet. The in-app inbox, the badge and the attention stream are unaffected.
notify#305 (the preferences route, the per-device credential DELETE
and the trigger identifier on every message row) has shipped. When the
companion is unreachable the banner after saving preferences says the
companion did not answer and the page reports applied.notify honestly;
a device whose delete failed shows removal pending and is retried the
next time push is enabled.
Neither needs a migration or a reset on this side.
The activity robot
A repository can have its timeline talk in its chat channel
(RFC 0011 §10): settings → Activity robot, in the same event-pattern
syntax webhooks take (*, a noun like checkin, or an exact kind like
landing.accepted). Empty turns it off. Robot lines never count as
unread and never mention anybody.
Your account
/me is where a person manages their own things: their identity (login,
site-admin standing, when the account was created), their email, their
password, their signed-in sessions, and the notifications matrix above.
It absorbed the two pages that used to hold pieces of this alone —
/me/notifications and /me/sessions both still work as bookmarks,
redirecting to the matching section (#notifications, #sessions).
The account menu's avatar popover links straight to it.
A small set of emails are transactional: they are about the account
itself — an invite, being given or losing access to a repository, an
address confirmation, a password change — and no preference switches
them off. They never go through notify's matrix
(internal/notify/workflows.go's four families, the matrix on /me):
that matrix is for content a person chooses to hear about, and an
account changing hands or losing its way to a repository silently is
not something a preference should be able to produce.
internal/api/transactional.go names the exact list and says why
extending it is a decision, not a convenience.
A person sets their own address on /me's email section. Nothing is
confirmed until a six-digit code — mailed to the NEW address, good for
ten minutes — is typed back in on that same page; until then, quarry
keeps mailing whatever address was confirmed before, if any. Five wrong
codes burn the pending one outright, same as letting it expire, and
either way the only way forward is asking for a fresh one. Losing the
old address to a new confirmation sends the old one a notice, so a
change nobody made does not go unnoticed just because the old inbox is
still being read. An administrator can also set or correct an existing
account's address from /fleet/people, stored confirmed at once — the
same trust as typing an invite's address — and the account is told,
there, that an admin did it.
A person can also change their own password from /me, given their
current one. Doing so ends every OTHER session that account holds —
the browser making the change stays signed in — and mails the account's
confirmed address to say so, the same as an admin-driven
account.passwd ends every session of the account it targets.
A forge started with no RESEND_API_KEY or no --mail-from
($QUARRY_MAIL_FROM) — the same two settings account.invite already
needs — simply sends none of this. Every op still answers correctly:
an invite link is returned either way, a caps change still takes
effect. Only the mail itself does not go.
Agents
An account may be machine-operated. Settings is not where this
lives — it is on /fleet/people, beside the account it describes: mark
one an agent and name the person answerable for it, or take the mark
off.
The mark is one fact, operator, and its non-emptiness is what makes
the account an agent. Two questions with one field, because neither is
worth much alone: "a machine wrote this" without "and X runs it" tells
a reader to distrust the words without telling them who to ask.
Declaring is safe; hiding is not. Any account may mark ITSELF an agent, naming itself as the operator. Only a site admin may clear a mark, mark somebody else, or name another person as answerable — that last is a claim about a person, not about yourself.
The badge appears wherever a name is drawn: the timeline, tickets, the forum, chat, landings and attachments all render names through one function, so it reaches them from a single rule rather than a convention each surface has to remember.
The mark is on the ACCOUNT, not the write. A write-level mark is more honest and much larger — it would have to reach the artifact format — and this is what makes that worth building later: you cannot ask which of an account's writes were machine-made until you can ask whether the account is a machine.
A forgotten password
Press Reset password on the person's row on /fleet/people. It mints
a one-time link that lets them choose a new password themselves. The link
goes to the address on the account when it has one, and is shown to you
either way so you can pass it on by hand — which is the usual case on a
forge where most accounts have no address.
Three things about the link are deliberate:
- It is good for one hour, and once. An invite gets seven days because it is sent to somebody who is not waiting for it; a reset is asked for by somebody sitting there. A live link is a credential, so it lasts as long as the person needs and no longer.
- Their current password keeps working until they use it. A reset that blanked the password would lock them out harder the moment the link went astray — the failure this exists to end, not to reproduce.
- Minting again supersedes. "Send it again" drops the unspent one, so there are never two live links where you believe there is one.
Redeeming ends every other session of the account, because a forgotten password is sometimes a lost device.
Never set a password on somebody's behalf. account.passwd still exists
for scripts and for an account whose owner cannot be reached at all, but
using it means a password that you chose travels to them down some
channel, and both of you hold it afterwards. The link means nobody but
its owner ever sees it.
An account that has never been claimed has no password to reset: it wants the invite it is already waiting on, and the row offers Re-send invite instead.
Fleet admin
/fleet is the operator page — every store's ingest health, and the
upstreams it mirrors. It is site admin only: admin on one repository
is not enough, because the page reports operational detail across the
whole fleet and can force writes into any of it.
Health is per-process. Total and Fallbacks count what THIS server
has ingested since it started, so a restart with a warm index reports
zero of both however much the store holds. The parse-fallback number
worth looking at is the one a quarry reindex produces.
Mirroring
A mirrored store tracks an upstream: quarry pulls, and never pushes. The four postures a repository can be in, and what each one opens or refuses, are laid out in hosting.md.
Configuring a mirror needs admin on the repository being configured —
which its creator holds from the moment they created it — not site
admin. That includes mirror.writes: flipping a tracking store to
accept local writes creates two write heads on it, but it is that
repository's own fork to reconcile, not the forge operator's call.
curl -u you:pw -X POST http://localhost:8080/api/v1/mirrors \
-H 'Content-Type: application/json' \
-d '{"repo":"fossil","url":"https://fossil-scm.org/home","every":3600}'
every is seconds and is floored at 60 — these upstreams are other
people's servers, and the floor is a courtesy the configuration surface
cannot opt out of. --mirror-tick (default 1m) is how often due-ness is
evaluated, not how often anything is pulled.
A repository also says, on its OWN side, whether other people may keep a copy that follows it — see hosting.md for what "mirrorable" means and why a private repository is never mirrorable regardless of the setting.
Pulls take the store's single-writer lease, so mirroring is safe with more than one node serving the same store. A pull that brings nothing new does not trigger a re-index and is not logged; almost every one is quiet, and a line per store per interval buries the ones that matter.
Two limits worth knowing before you rely on it:
- The store needs a working checkout.
zit pullrefuses-Rand operates on a working directory, so a genuinely bare store cannot be mirrored. Stores created byzit cloneorzit import-fossilcarry one, so this bites only hand-built bare stores. - The upstream must allow anonymous clone. Some Fossil servers serve
a browsable timeline while refusing sync —
sqlite.org/lemonis one. Note thatfossil cloneexits 0 on that refusal, so check for the repository, not the exit status.
Attachments: binding a bucket
Files uploaded through quarry go to a bucket the repository's steward brings (RFC 0016); the forge keeps a stub in the repository and never the bytes. A steward binds one bucket per repository from its settings page, under Attachments, or through the op:
curl -u you:pw -X POST http://localhost:8080/api/v1/r/fossil/bucket \
-H 'Content-Type: application/json' \
-d '{"endpoint":"https://<account>.r2.cloudflarestorage.com","bucket":"fossil-files",
"region":"auto","accessKey":"...","secret":"...","publicBase":"https://files.example.org"}'
At R2, create the bucket, then an API token scoped to that bucket
with object read and write; the region is auto. A public or custom
domain on the bucket is optional — set it as the public base and
downloads link there; without it every download is presigned. Any S3
endpoint works the same way with its own region.
The binding is probed before it is kept — a presigned PUT and DELETE
of one small object under attachments/ — so a wrong key or a token
without delete is refused on the form. Probe again from the page or
with bucket.probe; Fleet shows each store's last probe beside its
mirror's last pull. The secret is sealed under secret.key beside
oltp.db (created 0600 on first use) and never returned by any op; a
forge that loses the key file has the steward enter the secret again.
The probe also sends the browser's preflight — an OPTIONS naming the
forge's origin (--origin / QUARRY_ORIGIN), PUT and content-type
— and records the answer as corsOk. The page's scripted upload PUTs
the file straight to the bucket from the browser, which the bucket
refuses until its CORS rule allows the forge; the multipart form, which
posts to quarry, needs no rule. A failed preflight therefore never
refuses a binding: the settings section says browser uploads are
blocked and shows the rule to paste, in R2's shape with the forge's own
origin filled in:
[{"AllowedOrigins":["https://forge.example"],"AllowedMethods":["PUT","HEAD","GET"],
"AllowedHeaders":["content-type","content-length"],"MaxAgeSeconds":3600}]
At R2 it goes under the bucket's Settings, CORS policy; any S3 takes
the same rule as a PutBucketCors configuration. Probe again once it
is in place. A forge started without --origin cannot ask on the
browser's behalf and records corsOk false.
Attachments: uploads, letters and the lifecycle
With a bucket bound, a holder of the upload letter — b on tickets,
wiki pages and technotes, B on forum posts (RFC 0008) — sees "Attach
a file" in the Attachments section of those pages. With script the
browser hashes the file, asks attachment.begin for a presigned PUT
(fifteen minutes, the length and the type signed), sends the bytes to
the bucket itself and calls attachment.commit, which HEADs the object,
checks its length against what begin was told, and writes the stub and
the A card through zit attach add. Without script the same form posts
the file to quarry, which carries it to the bucket in memory — never to
disk — and commits; that path is bounded at 32 MiB, since a presigned
PUT signs a length the form cannot know until the file is read. API
clients have the same floor at POST /api/v1/r/<repo>/attachments/<target>
with a multipart file. Uploads are refused over the binding's limit
and off the type policy: text, the common images, PDF, JSON, the common
audio and video; archives only when "allow archives" is on in the
section; executables never. Needs zit 0.3.13, which adds zit attach.
With script, a file can also be pasted or dropped, and nothing
leaves the browser until something is posted. A paste or drop into a
composer — a new thread, a reply, a new ticket, a ticket's comment or
description box, the wiki editor — previews the file under the box (a
thumbnail for a picture, the name and size otherwise; a clipboard
screenshot arrives as image.png and is renamed
pasted-YYYYMMDD-HHMMSS.png, in UTC), puts the reference the markup
resolves at the caret —  for an image,
[name](attach:name) otherwise — and offers a Remove that only
forgets it. When the form is submitted the script uploads each pending
file first: attachment.begin — with the target when the object
exists, with the family alone when it does not — the PUT, the progress
bar; then the form goes with the tickets as hidden
attach inputs, and the op that writes the object (forum.post,
forum.reply, ticket.new, ticket.set, wiki.save) commits each
against it. A refusal on any file keeps the form from going and names
the file on the error line; a refusal at commit comes back in
attachErrors and never undoes the write. A paste or drop on an
Attachments section — the one around the focused element, else the
first on the page that offers an upload — fills its file picker, and
its Upload button is the one control that sends at once. A post held
for moderation leaves its files unattached. A new ticket takes files
only when it is recorded in the fossil now: the local tier has no A
card to write, so the control is offered to administrators alone.
An upload is held unless the uploader holds the family's moderation
letter — q on tickets, l on the wiki and notes, 5 on the forum —
or administers the repository. A held file is in the repository (a
fossil client sees it as any attachment) but on quarry's pages it is a
link with its name and size, never a picture, until a moderator vouches
for it from the card or from the "Held uploads" list in the Attachments
settings section; reject removes it. Removal — the card's Remove, or
attachment.remove — writes fossil's sourceless A card and leaves the
object in the bucket unless "delete the object when an attachment is
removed" is on. Downloads of an upload redirect to the bucket: the
public base when the repository is public and one is set, otherwise a
presigned GET good for an hour.
Objects are keyed attachments/<sha256>, and an object no stub names
is an orphan: an upload that began and never committed, or one whose
attachment was removed with deletion off. quarry writes nothing else
under the prefix. A lifecycle rule that deletes attachments/ objects
a day old would take live files with it, so do not set one; sweep
orphans instead by listing the prefix against attachment.list (or
zit attach list, which marks stubs external:sha256:…) and deleting
what neither names. Objects a day old that no stub names are safe to
delete: an upload is complete or abandoned within the hour.
Fossil chat bridge
An opt-in connector between one repository's chat channel and one
Fossil server's chat (RFC 0011 §9). It is configured per store, from
/fleet or through the op:
curl -u you:pw -X POST http://localhost:8080/api/v1/r/fossil/mirror/chatbridge \
-H 'Content-Type: application/json' \
-d '{"url":"https://fossil-scm.org/home","user":"quarrybot","password":"..."}'
Detach with {"detach":true}. The password is stored and is never
returned by any op or rendered on any page; the only reader is the
poller.
The upstream needs two things, neither of them a default:
- A login holding fossil's
Ccapability. Chat is gated on it. http_authentication_okin the repository's config, or fossil refuses HTTP Basic and every request is anonymous. It is not afossil setting, so it goes in directly:
fossil sql -R /path/to/repo.fossil \
"INSERT OR REPLACE INTO config(name,value,mtime) \
VALUES('http_authentication_ok','1',now());"
Quarry polls /chat-poll with a nine-minute client timeout (fossil
hangs the request for chat-poll-timeout, 420 s by default) and backs
off from 30 s to 5 minutes while an upstream is failing. Every request
carries a same-origin Referer: without one fossil treats the poll as
CSRF-unsafe and refuses its own table setup, which surfaces as a
database error rather than as an authentication failure.
What it does. Messages from the upstream arrive in the channel as
fossil:<user> with Origin: fossil. A fossil deletion becomes a
tombstone on the message it removed. An attachment becomes a link to
the upstream's /chat-download — fossil keeps the file. Quarry's own
messages go out to /chat-send prefixed **name:**, because fossil
attributes everything to the login the bridge signs in as. The bridge
never forwards robot lines or lines that came from fossil, and never
re-imports its own login's messages.
What it does not do.
- No edits. A fossil message cannot be edited from quarry, and a quarry edit is not carried upstream. Only deletions cross, and only in the fossil-to-quarry direction.
- No mentions. Fossil chat has none, so a bridged line raises nobody's badge and triggers no notification.
- No account mapping.
fossil:drhis a name from another server's user table, not a quarry account, and the prefix says so. - Two Fossil servers are two chats. Fossil's chat is a server-local table that never syncs, so bridging a mirror connects you to that ONE server's conversation, not to the project's.
- The window is still quarry's. A bridged channel holds seven days like every other; the bridge is a connector, not an import.
When this repository's activity robot is on, the upstream's
chat-timeline-user announcements are skipped — otherwise every
check-in is announced twice, once by each robot.
Run the bridge on one node. Unlike a mirror pull, which takes the
store's single-writer lease, the bridge holds no fleet-wide lock:
every node that serves the repository and sees the row starts its own
poller. Two nodes bridging the same repository means each upstream
message is imported twice and each quarry message is sent upstream
twice. Until there is an advisory lock for it, bridge from one node —
in a multi-node deployment, configure the bridge and run
--allow-private-bridges-style node-specific configuration on exactly
one of them, or keep the row out of the shared OLTP on the others.
Restarting a node is safe: the msgid map makes a re-read of the same
message a no-op, so a cursor that has not caught up does not double
anything.
Private addresses are refused by default. The bridge is a server-side fetcher holding a credential, so it will not connect to a loopback, private or link-local address — that last range is where cloud metadata services live. The check runs on the resolved address at connect time, so a public name pointing inward is refused too. Bridging a fossil on the same host is an opt-in:
quarry serve --allow-private-bridges ...
Redirects are refused, not followed. Go re-sends the
Authorization header across a same-host redirect, and here that
header is the fossil password. Configure the final URL. A URL carrying
credentials (https://user:pass@host/) is rejected outright: the user
and password are separate fields, and a URL is copied into log lines
and status rows.
Roles, and what a role change tells
A person's standing on a repository is a capability string (RFC 0004), and there are two doors onto it:
account.grant/account.revoketake one of the four preset roles — reader, developer, maintainer, admin.account.capstakes the whole string, which is what the settings editor writes and what no preset covers. Empty removes the account's explicit string; the repository's categories still apply.
All three raise RFC 0005's account.added, account.caps and
account.removed — through one helper, because three copies would drift
and the drift would be a role change that reaches a webhook when it is
made one way and vanishes when it is made another. A change that changed
nothing raises nothing: these are operational events, so they must name
real transitions rather than every save of the same role.
The event is raised at the write, and only there. Telling the person
their access changed (their inbox line and the mail that goes with it)
is a separate job and does not raise one: when both did, a webhook heard
a grant twice, and heard account.caps for an arrival that is
account.added.
The payload carries a summary — the same sentence the settings page
shows ("added as maintainer", "reader → maintainer", "removed from the
repository") — so a webhook body, the chat robot's line and the settings
trail all say what happened instead of naming the kind.
What the forge retires on its own
RFC 0001 §8 keeps the OLTP database small by construction: it holds the forge's own bookkeeping, not the repository's history, and it is litestream-replicated. Two tables broke that by only ever growing, and both are now swept on the same six-hourly beat (and once at startup):
| table | kept | what goes |
|---|---|---|
exception |
30 days since the last occurrence | a signature nobody has seen again, and its recent hits |
ci_run |
90 days | a finished run that is not the newest for its subject |
Neither sweep needs configuring and neither loses anything a page still reads. Two rules bound the CI sweep:
- An unfinished run is never pruned, whatever its age. That row is the poller's work list, and deleting it strands a run whose verdict nobody will ever record. An ancient unfinished row is a bug to find, not a row to hide — look for one before assuming the sweep is broken.
- The newest finished run per subject survives the window. quarry
stores only the mapping from run to check-in, and that mapping is what
makes a verdict explicable: a
ci-failbadge that cannot name the run behind it is a verdict with no evidence. So the badge stays traceable for as long as the check-in does, and what the window removes is the retries — the several runs a check-in accumulates from re-runs and supersessions, which is where the growth was. Subject is (repo, check-in, pair), so a premerge run (RFC 0009) keeps its own newest alongside the check-in's.
Each sweep logs only when it removed something, so a quiet forge stays quiet in the journal.
Building on Linux
scripts/build-linux.sh is the build for a Linux host, and the one the
VPS deploy uses. It installs the pinned zit release for the host's
architecture (checksum verified against the release's SHA256SUMS),
then builds quarry from the tree with the version stamped from the
checkout, and leaves both binaries in ./bin:
ZIT_VERSION=0.3.7 scripts/build-linux.sh
bin/quarry version # quarry 0.1.0-beta+<checkin>
bin/zit version
It builds ON Linux rather than cross-compiling for a reason worth
knowing: the DuckDB bindings ship a static C++ library per platform that
links against the GNU C++ runtime, and a cross-link from macOS with zig
as the C compiler fails on missing std::string symbols. Proving the
recipe without a Linux box takes one container:
docker run --rm -v "$PWD":/src -w /src "golang:$(sed -n 's/^go //p' go.mod)" scripts/build-linux.sh
zit comes from its GitHub release rather than a source build so the host needs no Zig toolchain, and the pin keeps quarry and zit from drifting (0001 §2: the zit CLI is the whole store interface).
With compose
export NOTIFY_SRC=../notify # a notify checkout; it builds dagnats too
export ZIT_REF=<commit> # pin the store implementation
docker compose up --build
Then bootstrap notify once (docker compose run --rm notify bootstrap …),
put the key in QUARRY_NOTIFY_KEY, and create the workflows and topics
as above.
docker run <image> cat /etc/quarry-build reports which quarry and which
zit an image carries, without starting it.
Unverified.
Dockerfileandcompose.ymlhave not been built — no container runtime is installed on the development machine. The two build recipes inside them are verified natively: zit compiles from a clean clone of the GitHub mirror withzig build --release=fast, and quarry builds fully static withCGO_ENABLED=0(its SQLite is WASM-based and Pikchr runs on wazero, so nothing needs a C toolchain). What remains untested is the image assembly and the compose wiring.
Ports
| port | what |
|---|---|
| 4222 | dagnats NATS |
| 8080 | dagnats HTTP bridge (and its console — unauthenticated, keep it on loopback) |
| 3000 | notify API |
| 8950 | quarry |
quarry's own embedded NATS binds nothing (DontListen), so it never
collides.
Working with several agents on one store
Several people — or several agents — committing to one .zit is the
ordinary shape here, and 2026-08-29 taught its failure modes in one
afternoon. The rules that keep it sane:
| rule | why |
|---|---|
Land through the forge — tools/quarry-land, the landing page, or the API; never zit landing accept |
required checks, override-with-reason, auto-land and supersede exist only in the forge's write path. A store-level accept walks past all of them, and the store cannot refuse it — twice in one day, red-checked and unformatted work reached trunk that way |
Arm auto-land at open (quarry-land <repo> <id> --when-green) |
nothing merges red out of impatience, and nothing waits on a human who is actually a busy agent |
Prefix branches per effort (attention-*, ci-*, resilience-*) |
supersede-cancel, filters and the eye can all scope to whose work is whose |
| Commit or stash before leaving a shared checkout | an uncommitted edit left in a serving tree turned a routine zit update into a merge conflict mid-deploy |
| Deploy only via the sanctioned script, and never build from a tree another agent may be editing | the deploy script's job is to be the one honest path; a second, casual path is where the conflicts and truncated logs came from. The script now opens trunk into a scratch tree from the STORE and builds there, so no shared checkout is touched and nobody's uncommitted work can collide with a deploy |
| Announce a deploy window; hold restarts while another session's CI is in flight | every restart kills the clones its own workers are mid-way through. A quiet restart cost five runs across three sessions in one night |
| Only the session that opened a landing settles it | an armed landing whose branch someone else closes as "abandoned" reverts work the owner is still driving, and a rejection is unattributable — every session commits as the same user |
| Treat the zit binary as part of quarry's behaviour (see above) | two zit releases in two weeks each silently broke shell-out seams. quarry's CI runner runs a pinned zit (quarry runner --zit); the TestZitContract canary names the next drift |
The forge holds the gates; the store holds the truth; the agreement is that nobody asks the store to do the gate's job.
What must survive a reboot
Three long-running things back this forge, and on 2026-08-30 a reboot
proved which of them were actually supervised. /tmp does not survive,
so nothing whose life depends on it does either.
| process | supervision | notes |
|---|---|---|
| the dagnats engine | launchd, com.dmestas.dagnats |
came back on its own |
| the CI worker | launchd, com.dmestas.quarry-ci-worker |
did NOT, before this: it was hand-run with a /tmp work directory, so the wipe took it and CI was dead six hours. A dead worker is invisible from the outside — every run simply reads running forever — which is why supervision matters more here than anywhere else. Its work directory now lives beside the binary, not in /tmp |
| the forge itself | the deploy script, by hand | the one still unsupervised; a reboot leaves it down until somebody looks |
The worker's log is quarry-demo/quarry-ci-worker.log; the forge's is
quarry-demo/logs/quarry-<date>.log, appended rather than truncated so
a deploy can no longer destroy the evidence of the outage before it.