Skip to content

Archive ledger

capture-ledger keeps a ledger of the archives BrowserHive produced, and issues short-lived signed URLs for them to callers who are allowed to read them.

The parts:

Holds Where
archives Where each WACZ is — bucket, key, provenance capture-ledger’s Postgres
OpenFGA Who may read what, as relationships its own Postgres
fga_outbox Tuples waiting to be delivered to OpenFGA capture-ledger’s Postgres

There is deliberately no owner column on archives. Who may read an archive is a relationship, and keeping a second copy of that answer next to the first is how the two start disagreeing.

Inserting an archive row and writing its relationship tuples touch two different systems — Postgres and OpenFGA’s HTTP API — and no transaction spans both. Done independently, one can land without the other, and both halves are bad: an archive nobody can reach, or a permission pointing at a row that was rolled back.

So the tuple write is recorded as an outbox row inside the same transaction as the archive. Either both land or neither does. A worker delivers it afterwards and retries until OpenFGA accepts it.

Delivery is at-least-once, and a tuple that is already there counts as delivered.

Two paths, on purpose. (There used to be a third — the CLI polled GetCapture for each capture it had submitted. Both the CLI and capture-ledger’s gRPC client are gone; the Windmill flow submits now.)

Crawling — a crawl registers what it captured as soon as the flow reports a level (src/crawl/admit-level.ts). The level report carries where each capture’s .result.json was written, not the result itself, so the handler reads that manifest back — at exactly the reported key — before registering. This is the fast path: a page is in the ledger within one round trip of being taken.

Reconciling — pnpm run fga:reconcile registers captures that were reported but did not make it into the ledger: reading the manifest failed, or writing the row did. It reads the capture_submissions rows that carry a manifest_key and have no archive yet, and fetches exactly those keys. It does not walk the bucket. No key is ever reconstructed: BrowserHive (or the sink) answers each Capture with the location it wrote the manifest to, the Windmill flow relays it, and the level report records it.

--since-days <n> limits it to captures reported in the last n days:

Terminal window
pnpm run fga:reconcile --since-days 30

Each run reports pending, registered, skipped (a manifest the ledger does not admit, such as a cancelled capture with no archive) and missing (nothing at the reported key).

What it cannot pick up, by construction:

  • A capture whose level report never arrived — capture-ledger was down, or the flow failed before reporting. There is no capture_submissions row, so neither the organization nor the manifest key is known. Walking the bucket did not help either: such a manifest had no attribution and was only counted as unattributed.
  • A capture reported with manifestError. There is no key to read. If the write finished after BrowserHive stopped waiting for it, the manifest exists but is not reachable from here.

Crawling is for latency. Reconciling is for correctness. A ledger with holes nobody notices is worse than no ledger, because the holes only surface much later as “why can’t I see this archive?”.

Terminal window
pnpm run fga:reconcile # fill gaps from the recorded manifest keys
pnpm run fga:drain # deliver queued tuples (the API also does this on a timer)

Only successful captures enter the ledger. A failed one uploaded nothing, and recording it would let the API hand out a URL for an object that is not there — authorization working perfectly on a 404.

“Failed” is the flow’s word, not the bucket’s. The report relays what the flow made of BrowserHive’s answer; the manifest is what BrowserHive itself wrote next to the artifacts, and where the two disagree the manifest wins. So the level handler looks the manifest up for every page that carried a task id, and corrects the crawl_pages row when the manifest says the capture succeeded.

One disagreement never resolves that way: a cancelled capture. If the flow’s deadline runs out mid-capture, BrowserHive (since v9.1.0) tears the page down and writes a manifest of its own saying ERROR_TYPE_CANCELLED. There are no artifacts to find, so the page stays failed — correctly. A run of those in the bucket means the flow’s deadline is shorter than the pages it is being asked to capture, and the fix is on the flow’s side.

A manifest says nothing about organizations; BrowserHive has no such concept. So capture-ledger writes a capture_submissions row (task id → organization) when the level is reported, and the reconciler reads it back. That row is written for every page carrying a task id — including the ones reported as failures, for the reason just above. Encoding the organization inside correlationId instead was rejected: a convention held only by agreement is broken by the first caller who submits a capture by hand.

capture-api serves two endpoints for archives. Both require an identity; see below.

Terminal window
# One archive
curl -X POST http://localhost:7070/api/archives/<id>/url
# → { "url": "http://…?X-Amz-Signature=…", "expiresIn": 300 }
# The ones you may see, newest first
curl http://localhost:7070/api/archives

The shape of both requests is a JSON Schema on the route, so the checking is the contract rather than a description of it:

POST /api/archives/:id/url id a UUID — anything else is 400, before authorization runs
GET /api/archives before an ISO 8601 timestamp — anything else is 400. Omit it to start from the newest
GET /api/crawls state running / succeeded / failed — anything else is 400
before an ISO 8601 timestamp — anything else is 400. Omit it to start from the newest

before is a cursor: pass the capturedAt (or, for crawls, the startedAt) of the last row you were given. Unknown query parameters are dropped rather than rejected.

The crawl list is gated on maySubmit, not on a per-row can_view the way archives are: the authorization model has no crawl type, and can_submit is defined as trust that crosses organizations in the first place (submitter in fga/model.fga). A caller without the permission gets neither 403 nor 404 but an empty array, and every row carries its orgId so the caller can say what it is seeing. Rows carry both state and stopReason, because most succeeded crawls were truncated (max_depth) and the state alone cannot tell that from a full walk.

waczCount is how many WACZ that crawl produced. archives has no crawl_id, so the count goes through crawl_pages.task_id — no column had to be added.

A caller who may not read an archive gets 404, not 403. A 403 would confirm that the id names a real archive — the enumeration leak OWASP API1:2023 (Broken Object Level Authorization) warns about. “You may not see it” and “it does not exist” have to be indistinguishable.

The single-archive check runs at HIGHER_CONSISTENCY — a cached “allow” here would hand out a URL valid for its whole lifetime. The list does not: appearing in a list grants nothing, since fetching any of them still has to pass the strongly consistent check.

Deciding when to capture belongs outside capture-ledger — a scheduler does that. Deciding what to submit and how stays here. So the boundary is one endpoint that starts a crawl, and one that reports on it.

There used to be a second pair, POST /api/runs and GET /api/runs/:id, for “capture every enabled row of capture_targets once”. That is now a crawl with fromTargets and a depth of 0. One concept, one table, one set of guarantees.

Terminal window
# From an explicit list of seeds.
curl -X POST http://localhost:7070/api/crawls \
-H 'content-type: application/json' \
-d '{"seeds":["https://example.com/"],"maxDepth":2}'
# → 202 { "crawlId": "9072b625-…" }
# Or from the target list — this is what the old run did.
curl -X POST http://localhost:7070/api/crawls \
-H 'content-type: application/json' -d '{"fromTargets":{"limit":5}}'
curl http://localhost:7070/api/crawls/9072b625-…
# → { "state": "succeeded", "stopReason": "max_depth",
# "seeds": ["https://…"], "pagesCaptured": 5, "pagesDiscovered": 5, … }

A crawl runs level by level and can take tens of minutes, so there is no synchronous form of this call. 202 means accepted, not finished. The crawls row is where the outcome lives.

fromTargets seeds the crawl from the enabled rows of capture_targets, and limit takes the first n of them. Two things differ from the rest of the endpoint:

  • maxDepth defaults to 0 — do not follow anything. That is what the old run meant. An explicit value still wins, so “take the target list and follow two levels out from each” is expressible.
  • maxPages is never lower than the number of seeds. The ordinary default of 30 would silently truncate a longer target list.

The rows are filtered by the caller’s organization. A crawl carries one org_id, so mixing another organization’s targets into it would leave attribution unanswerable. A caller with no enabled targets gets 400, not an empty crawl — crawls.seeds has a CHECK (array_length(seeds, 1) >= 1) and a seedless crawl could never start.

Two consequences of folding runs into crawls, worth knowing before the first scheduled one:

  • The daily sweep is now paced. The old run submitted every target at once; a crawl honours perHostDelayMs and hostParallelism like any other. Kinder to the other end, and slower.
  • Pages from the target list now reach full-text search, because they are crawl_pages rows and the search index joins through them.

A second crawl started while one is running gets 409, and the guarantee is a partial unique index rather than an application flag:

CREATE UNIQUE INDEX crawls_single_active_idx ON crawls ((true)) WHERE state = 'running'

The reason is politeness. Per-host spacing is enforced inside one flow run, and two crawls cannot see each other’s timing, so the same host would quietly be hit at twice the rate. A flag in the process would hold only until the day a second process appears; Postgres holds it regardless. The route’s only job is to translate the constraint violation into a 409.

Exactly one of seeds (a non-empty array) or fromTargets — both is 400 and neither is 400, with the two reported differently, because they are different mistakes. Alongside them: rules, maxDepth, maxPages, perHostDelayMs, hostParallelism. An unknown key is 400, not silently dropped.

Capture formats are deliberately not accepted from the caller: they are part of what this deployment does, so they come from the environment.

Terminal window
CAPTURE_LEDGER_CAPTURE_FORMATS=wacz # comma separated: png,webp,html,links,mhtml,wacz
CAPTURE_LEDGER_CAPTURE_SIGNING=1 # require a wacz-auth signature; needs wacz

Both are read and checked at startup, so a misspelling stops the server with the bad value named. Read per-crawl instead, a typo would surface as a scheduled crawl failing at 3am with “no capture format enabled” — a message that never mentions the setting that caused it. See Capture options.

Nothing in capture-ledger does. The scheduler lives in its own repo — capture-scheduler — which runs a Windmill instance whose only job is to call this endpoint on a cron (trigger_crawl.ts). The same Windmill also runs the flow that does the capturing, which capture-ledger reaches through CAPTURE_LEDGER_CRAWL_WEBHOOK_URL.

The split is deliberate: capture-scheduler decides when, capture-ledger decides what. That is why the body takes no capture formats, and why fromTargets submits whatever capture_targets says rather than a list the caller supplies.

Two things a caller has to get right, and capture-scheduler’s script exists to encode them:

  • 409 is not a failure. It means a crawl is already going. Retrying cannot help — the answer stays the same until that crawl ends.
  • 202 is not the end. A crawl that fails still answered 202. Anything that stops at the 202 reports success for failed captures.

Running a crawl means running with a JWT — the flow reports each level back with a Bearer token, so this holds for a crawl you start by hand too. Setting CAPTURE_LEDGER_OIDC_ISSUER makes the JWT resolver take over, and the dev header stops working. JWT beating the dev header is the point (a deployment with both configured must not fall to the weaker one). The browser picker follows suit: under a JWT setup it asks for a token to paste (Browsing archives).

can_submit on the organization, checked at HIGHER_CONSISTENCY so a revoked grant takes effect immediately. A caller who may not gets 404, for the same reason the archive routes do.

The grant is stored, and that is the whole point:

Terminal window
pnpm run fga:grant submitter alice acme
# alice は acme のクロールを始められます (書いた: user:alice submitter organization:acme)
pnpm run fga:revoke submitter alice acme
# alice は acme のクロールを始められません (消した: user:alice submitter organization:acme)

The last line says whether that user may now start crawls for that organization (始められます, yes; 始められません, no). It is the answer to the same question the API asks (can_submit), asked again after the write — not inferred from what was written. Revoking submitter from someone who also holds admin still says 始められます, and adds ほかの関係で許されています (“another relation allows it”) in the parentheses, which otherwise say what was written (書いた) or deleted (消した).

To check from the caller’s side, ask GET /api/me. It answers only about the caller: the name and organizations as resolved from the token (or headers), and canSubmit, the answer to the same question the crawl routes ask:

Terminal window
curl -s -H "authorization: Bearer $TOKEN" http://127.0.0.1:7070/api/me
# → {"subject":"alice","organizations":["acme"],"canSubmit":true}

A crawl route’s 404 cannot tell “not allowed” from “no such route”. This one answers canSubmit: false instead of 404.

Membership cannot stand in for it. Organizations arrive as contextual tuples built from the caller’s own token, so a rule like can_submit: member reduces to asking a caller who claims membership whether they are a member — always yes. This was written that way first, and the round-trip caught it: a subject naming an organization it had no relationship with got a 202.

The split is about where authority sits. Who you are — and which organizations you belong to — is the identity provider’s to assert, so it is never stored. What you may do is OpenFGA’s, so it is. grant refuses to write member for exactly this reason: two homes for one fact means no answer when they disagree.

One grant is enough to start a crawl. The crawl is attributed to the first organization on the caller’s identity, and fromTargets reads only that organization’s rows — so a submitter cannot pull another tenant’s targets into a crawl of their own.

The same endpoint, with a depth above 0, walks the links out from its seeds. Windmill runs the walking; capture-ledger decides which links to take, what has been seen, and when to stop.

Terminal window
curl -X POST http://localhost:7070/api/crawls \
-H 'content-type: application/json' \
-d '{"seeds":["https://example.com/"],"maxDepth":2,"perHostDelayMs":2000}'
# → 202 { "crawlId": "9072b625-…" }
curl http://localhost:7070/api/crawls/9072b625-…
# → { "state": "succeeded", "stopReason": "max_pages",
# "pagesCaptured": 6, "pagesDiscovered": 76, … }

Nothing here is served unless CAPTURE_LEDGER_CRAWL_WEBHOOK_URL and _TOKEN are both set. One without the other stops the server at startup — a half-configured webhook fails only after someone asks for a crawl, by which time a row is already open.

Three settings, all per crawl:

default
perHostDelayMs 2000 between finishing one page on a host and submitting the next
hostParallelism 4 how many distinct hosts are touched at once
maxPages 30 total pages, seed included

The delay is measured from the finish, not the submit, and that is the whole point. How long a capture takes is not known up front — one page is two seconds, the next is two minutes — so a gap measured from the submit says nothing about the gap the other end feels: the next request can land the moment the previous capture ends. The gap has to sit after the capture completes for the other end to feel it.

One capture is also not one request. The browser fetches sub-resources, so a page is a burst of dozens from the far side. 2000 ms is chosen against that, and a Crawl-delay in robots.txt wins if it is longer — a value the other end states is not ours to shorten.

stopReason is one of completed, max_depth, max_pages, or failed. Without it a finished crawl cannot say whether it followed everything or was cut off. With the default of 30 pages, max_pages is the ordinary outcome — that is deliberate, so the limit is visible in normal use rather than a surprise.

pagesDiscovered and pagesCaptured are separate counts. The difference is what the rules, robots, and the caps threw away.

A crawl takes a list of rules. Each rule is an object with one key — the action — whose value is what it matches:

"rules": [
{ "skip": "ads.example.com" },
{ "skip": "https://www.example.com/search" },
{ "follow": "same-site" },
{ "capture": "any" }
]
Action A link it matches is
follow captured, and its own links are read
capture captured; its links are not read
skip not captured
Pattern Matches
same-origin, same-host, same-site, any Compared with the seeds: the same origin, the same host (port included), the same registrable domain, or everything
A lower-case host name (ads.example.com) That host and every subdomain under it, at a label boundary — not badads.example.com
An http(s) URL Every URL that starts with it once normalised (lower-case host, no default port). A plain prefix: /search matches /searching too; end it with / for a boundary

Rules are tried from the top, and the first one that matches decides. A link no rule matches is skipped. Rules are numbered from 1, as a screen numbers its rows. Leave rules out and the crawl gets [{ "follow": "same-origin" }] — same origin as a seed, nothing outside it. The rules are fixed on the crawl as read: a URL pattern is stored normalised.

same-site is decided by the Public Suffix List in the tldts version the lockfile pins, private section included, so foo.github.io and bar.github.io are different sites. An IP address or localhost is a site of its own.

A rule list that would do something other than what it says is refused before the crawl starts, with a 400 that names the problem:

error When Names
invalid rule patterns A pattern is none of the three kinds rules: [{ rule, pattern }]
rules that can never match An earlier rule matches every URL a later one would rules: [{ rule, coveredBy }]
seeds the rules do not follow A seed lands on capture, skip or no rule at all seeds: [url]

The shape — 1 to 32 rules, one key each, a pattern of at most 2048 characters — is the schema’s 400. “Can never match” is only claimed when it is certain: any covers everything; same-site covers same-host, which covers same-origin; a domain covers its subdomains, and same-site covers a domain on a seed’s site; a URL prefix is covered by a shorter prefix, or by a scope or domain its own URL falls in. Refusing a correct list would be worse than letting a dead rule through.

GET /api/crawl-rules/check runs the same checks without starting anything — a GET, because it writes nothing:

Terminal window
curl 'http://localhost:7070/api/crawl-rules/check?seed=https://www.example.com/&rule=follow:same-site&rule=skip:ads.example.com'
# → { "seeds": [{ "url": "https://www.example.com/", "valid": true, "site": "example.com", "followed": true }],
# "rules": [{ "rule": 1, "action": "follow", "kind": "scope", "pattern": "same-site" },
# { "rule": 2, "action": "skip", "kind": "domain", "pattern": "ads.example.com", "coveredBy": 1 }],
# "ok": false }

Before any rule is tried, rel="nofollow" is honoured and only http(s) links are considered. Links on a page that is not follow are not read at all — and a page is judged both by the URL it was asked for and by the final URL it landed on after redirects, so a link that redirects off-site cannot widen the crawl. Fragments are dropped — #section is a position inside a page, not another page — but nothing else is normalised: a reordered query string is a different URL to plenty of real servers, and taking the wrong page is worse than taking one twice.

Deduplication is a unique index on (crawl_id, url_hash), with url_hash the same generated digest(url, 'sha256') column capture_targets uses. Discovered links are inserted with ON CONFLICT DO NOTHING and the rows that actually landed become the next level — so recording and deciding cannot drift apart. BrowserHive’s rejectDuplicateUrls is not a substitute: it only knows about pending and processing tasks and forgets completed URLs.

Each level’s report records how many distinct URLs every rule took on every host — crawl_decisions, one row per depth, rule and host name. Reporting the same level again replaces its rows instead of adding to them. Links on a page that is not follow are not read, so they are not counted.

Terminal window
curl http://localhost:7070/api/crawls/9072b625-…/decisions
# → { "rules": [ …as fixed on the crawl… ],
# "decisions": [{ "depth": 1, "rule": 1, "action": "skip", "hostname": "ads.example.com",
# "links": 12, "samples": ["https://ads.example.com/…", …] }, …],
# "robots": [{ "hostname": "www.example.com", "pages": 1, "samples": ["https://www.example.com/private"] }] }
curl 'http://localhost:7070/api/decisions?domain=ads.example.com&days=90&action=skip'
# → { "decisions": [{ "crawlId": "…", "startedAt": "…", "seeds": [ … ], "rule": 1, "action": "skip",
# "pattern": "ads.example.com", "hostname": "ads.example.com", "links": 12 }, …],
# "truncated": false }

Rule 0 is a link no rule matched. robots lists the pages robots.txt kept the crawl from taking, so “why was this not captured” has one place to look. Host names drop the port. The domain query covers the host and its subdomains at a label boundary, newest crawl first; days defaults to 90 and action to skip, and truncated says when the answer was cut at 500 rows. Windmill keeps a run’s details for 30 days at most; these rows last as long as the crawl does.

Off by default. A deployment may have no index, so unless CAPTURE_LEDGER_OPENSEARCH_URL is set the endpoints are not served at all (you get a 404 — the capability genuinely is not there, so that is the right answer).

Terminal window
pnpm run stack:up --profile search
# and CAPTURE_LEDGER_OPENSEARCH_URL=http://127.0.0.1:9200 in .env
Terminal window
# Index one crawl's worth (the crawl flow calls this on its own)
curl -X POST .../api/crawls/<id>/index
# → 202 { "indexed": 6, "pages": 6 }
# Query
curl ".../api/search?q=responsive"
# → { "hits": [ { "archiveId": "…", "url": "…", "title": "responsive", … } ], "total": 3 }

BrowserHive writes title and text into the WACZ’s pages/pages.jsonl. The text is document.body.innerText — the rendered body, not the HTML. capture-ledger indexes that as-is. Re-deriving it from HTML would let the index disagree with what the archive signed for.

textWithheld (BrowserHive’s DocumentWithheld: url-policy, content-type, no-archive, deny or unattributed) travels with it. Drop it and a capture that came back empty looks identical to a body policy refused to store. The first is a fault worth investigating; the second is normal.

Search asks OpenSearch, then asks OpenFGA can_view about what came back and drops the rest — the same shape as GET /api/archives.

Copying orgs or permissions into the index would filter in one query and be faster, but then the index becomes an authority on authorization, and one fact has two homes. Fix one and both still look like they work, so an authorization bypass stays silent.

UPDATE archives SET indexed_at = NULL;

Index state is the single archives.indexed_at column and no body text is stored (everything needed is derivable from the ledger row and S3). That makes changing the analyzer or mapping a cheap decision.

The analyzer today is the built-in cjk (bigram). Kuromoji needs a plugin install and does not run on the stock image — swap in a custom image when you need it, then rebuild with the statement above.

capture-api also serves a picker at / — the list above, rendered, with each row opening the archive in replay. How to use the screen — what to enter under each way the API identifies callers, and what its messages mean — is in Browsing archives. This section covers only the design behind it.

The screen is built for the way the API identifies callers at startup (selectIdentity): two name fields under the dev header, a token field under JWT, and only a notice when neither is configured. The browser cannot tell “no name given” from “this API wants a JWT” — both are a 401 with {"error":"unauthenticated"} — so the API, which knows, decides when it renders the HTML (pickerView in src/api/picker.ts). The picker mints no tokens. The issuer mints a token in anyone’s name on request, so it lives only on 127.0.0.1; a screen reachable on 0.0.0.0 that could mint would let anyone on the network do it.

The picker hands replay the objectKey and nothing else:

http://127.0.0.1:8899/?source=/wacz/<objectKey>

Not the signed URL. A signed URL points straight at S3, which is a different origin from the viewer, so the browser would need CORS on the bucket. The object key goes through replay’s own upstream, so replay needs no change at all.

Authenticating the caller is a separate problem from authorizing them, and the identity provider has not been chosen. What the authorization layer needs is small — a subject and the organizations they belong to — so that shape is fixed and the verification behind it is swappable.

By default nobody is authenticated and every request is 401. For local development, CAPTURE_LEDGER_DEV_IDENTITY=1 enables a resolver that trusts two headers:

Terminal window
curl -X POST http://localhost:7070/api/archives/<id>/url \
-H 'X-Capture-ledger-Subject: bob' \
-H 'X-Capture-ledger-Organizations: acme'

Anyone who can reach the port can claim to be anyone. It refuses to run unless switched on explicitly, and the server warns loudly at startup.

The API is now the only route in. There used to be a second one for the CLI, which read CAPTURE_LEDGER_DEV_SUBJECT and CAPTURE_LEDGER_DEV_ORGANIZATIONS from .env instead of headers. Both variables went with it — they are no longer declared in .env.example.

What the identity is for has not changed: it becomes capture_submissions.submitted_by and the owner tuple on the capture_job, and while those are empty not even the person who asked for the archive can delete it (can_delete reads owner from parent and nothing else).

Every route ends at one place — src/api/identity.ts picking a resolver, and src/config/identity.ts turning verified claims into an Identity. That is the only thing an identity provider replaces; callers see nothing but the Identity type.

Membership is not stored in OpenFGA. It is passed per request as a contextual tuple built from the caller’s identity, so joining or leaving an organization never has to be synchronised into the authorization store. The cost is that revocation waits for the token to expire — which is why tokens should be short-lived.

Four types, with ownership flowing downwards: organization → job → archive.

Type Relations
user —
organization member / admin
capture_job owner / parent (organization) / member
archive parent (job) / viewer / can_view / can_delete

The signing endpoint asks exactly one question — can_view — and there are three ways to reach it.

define can_view: viewer or owner from parent or member from parent
Path Who Where it comes from
viewer someone outside the organization a tuple (always with an expiry)
owner from parent whoever asked for the job a tuple
member from parent anyone in the owning organization a contextual tuple from the token

Deleting belongs to the owner alone (can_delete: owner from parent). A member of the organization can look but not destroy.

Nothing outside the organization can be shared without an expiry, because the type of viewer is conditioned.

define viewer: [user with non_expired_grant, organization#member]
condition non_expired_grant(current_time: timestamp, grant_time: timestamp, grant_duration: duration) {
current_time < grant_time + grant_duration
}

current_time is supplied on every Check, so nothing has to sweep expired tuples. The tuple stays; the condition simply stops holding. A sweeper falling behind cannot leave a hole open.

Each assertion in fga/model.fga.yaml names a way someone could be let in who should not be. The viewing side asks the questions below; the starting side (only an organization’s submitters and admins can start a capture, and only for that organization) sits in the same file.

# Question
1 the owner of the job can view what it produced
2 a member of the owning organization can view but not delete
3 an unrelated user sees nothing
4 membership in another organization grants nothing
5 a direct share is visible inside its window
6 the same share has lapsed after its window
7 a shared archive still cannot be deleted by the recipient

5 and 6 use the identical tuple and differ only in current_time.

fga/model.fga is the source of truth, with assertions in fga/model.fga.yaml that run in CI:

Terminal window
pnpm run fga:test # assertions, no server needed
pnpm run fga:deploy # push the model, write the ids to pin into .env.local

fga:deploy prints CAPTURE_LEDGER_FGA_STORE_ID and CAPTURE_LEDGER_FGA_MODEL_ID, and writes them into .env.local (it never touches .env). Pin the model id. Models are immutable and every write mints a new one; a client that omits the id evaluates against whatever is newest, so editing the model would change every decision the moment it lands. Bumping the variable is what makes that switch deliberate.

Terminal window
git submodule update --init --recursive
cp -n .env.example .env # the settings template, verbatim
pnpm run stack:up
pnpm run fga:migrate # OpenFGA's schema (see below)
pnpm run db:migrate
pnpm run fga:deploy # → writes the two ids into .env.local (nothing to paste)
pnpm run api

Everything reads .env and then .env.local — the pnpm run scripts pass two --env-file-if-exists flags, and the later one wins. Of the mandatory variables (REQUIRED_ENV), CAPTURE_LEDGER_FGA_STORE_ID and CAPTURE_LEDGER_FGA_MODEL_ID do not exist until fga:deploy has run, which is why that step comes before api. scripts/check-env.mjs keeps .env.example in step with what the code actually reads.