- diff view gains a Packages tab with added/removed/changed/unchanged package tables and purl-derived type/license/upstream links - captain records verified against the DID's atcr_hold service before caching (processor + batch backfill), preventing forged holds - fix empty-handle updates clobbering cached handles and colliding on the UNIQUE constraint - move fillPrevCIDs into repo.go; DirectRepoOperator is now canonical, repomgr kept as a test oracle - surface read-only crew status in hold selector - reconcile docs
12 KiB
Hold Discovery
Status: implemented. This document describes the hold discovery system as built. It was originally written as a design proposal; the design has since shipped with some deliberate divergences, noted in Divergences from the original proposal. Remaining gaps are listed in Remaining work.
TL;DR
AppView discovers holds by consuming io.atcr.hold.captain and io.atcr.hold.crew
records from the ATProto network (Jetstream live tail + relay backfill), caches them in
SQLite, and presents them to users as a grouped dropdown in Settings → Storage. Users
select a default hold by DID; the selection is validated against the cache, written to the
sailor profile, and mirrored into the local users table.
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Hold Service │────▶│ Relay │────▶│ Jetstream │
│ (embedded PDS) │ │ (BGS/bigsky) │ │ │
└─────────────────┘ └─────────────────┘ └────────┬────────┘
│
▼
┌─────────────────┐
│ AppView │
│ (subscriber) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ SQLite │
│ (cache) │
└─────────────────┘
Record Types
Both records live in the hold's embedded PDS, so the repo DID of the event is the hold
DID. Go structs are in pkg/atproto/lexicon.go; lexicon JSON in
lexicons/io/atcr/hold/captain.json and lexicons/io/atcr/hold/crew.json.
io.atcr.hold.captain
Singleton record (rkey self) describing the hold:
{
"$type": "io.atcr.hold.captain",
"owner": "did:plc:abc123",
"public": false,
"allowAllCrew": true,
"enableBlueskyPosts": false,
"deployedAt": "2025-01-07T12:00:00Z",
"region": "us-east-1",
"successor": "did:web:hold02.example.com"
}
| Field | Type | Description |
|---|---|---|
owner |
string (DID) | DID of the hold owner (captain) |
public |
boolean | Anyone can read (pull) blobs without authentication |
allowAllCrew |
boolean | Any authenticated user can self-register as crew |
enableBlueskyPosts |
boolean | Post to Bluesky when manifests are pushed |
deployedAt |
string | RFC3339 deployment timestamp |
region |
string | Optional S3 region where blobs are stored |
successor |
string (DID) | Optional successor hold (migration redirect, single-hop) |
io.atcr.hold.crew
One record per crew member:
{
"$type": "io.atcr.hold.crew",
"member": "did:plc:xyz789",
"role": "write",
"permissions": ["blob:read", "blob:write"],
"tier": "deckhand",
"addedAt": "2025-01-07T12:00:00Z"
}
| Field | Type | Description |
|---|---|---|
member |
string (DID) | DID of the crew member |
role |
string | Role name (owner, admin, write, read) |
permissions |
string[] | blob:read, blob:write, crew:admin (blob:write implies read) |
tier |
string | Optional quota tier (deckhand, bosun, quartermaster) |
plankowner |
boolean | Optional early-adopter flag |
addedAt |
string | RFC3339 timestamp when added |
Record key: deterministic hash of the member DID, enabling O(1) lookup
(CrewRecordKey() in pkg/atproto/lexicon.go — SHA-256, first 16 bytes, lowercase
base32 without padding).
Hold Identity
Every hold publishes two services in its DID document (HoldServices() in
pkg/hold/pds/hold_pds.go):
| Service ID | Type | Endpoint |
|---|---|---|
#atproto_pds |
AtprotoPersonalDataServer |
hold public URL |
#atcr_hold |
AtcrHoldService |
hold public URL |
The #atcr_hold service is what distinguishes a real hold from an ordinary ATProto
account. DID → endpoint resolution goes through the shared indigo identity directory
(24h TTL cache) via ResolveHoldURL() / ResolveHoldDIDToURL() in
pkg/atproto/resolver.go; there is no endpoint column in the cache tables.
Captain record verification
Because any ATProto account can publish an io.atcr.hold.captain record, the discovery
pipeline verifies the publishing DID before caching: HasHoldService()
(pkg/atproto/resolver.go) resolves the DID and requires an #atcr_hold service
endpoint. Records from non-hold DIDs are skipped (logged, not errored); unresolvable
DIDs are skipped and retried by the periodic backfill. This gating happens in both the
live processor (ProcessCaptain, pkg/appview/jetstream/processor.go) and relay
backfill (batchCaptains, pkg/appview/jetstream/backfill_batch.go).
Crew records are not independently verified: they only become visible through joins
against a verified captain record, so gating captains gates both. This prevents two
abuse cases: spam holds appearing in every user's picker (allowAllCrew: true), and
forged crew records placing a fake hold in targeted users' member lists. It does not
defend against a malicious actor running a real hold service — that is inherent to open
federation, same as any open-registration hold.
In test mode (SetTestMode(true)), local did:web identifiers that the indigo
directory cannot resolve (HTTP, IP:port) are trusted, matching the
ResolveHoldDIDToURL fallback.
Data Model
Cache tables in pkg/appview/db/schema.sql (SQLite/libsql, source of truth is the
network — the cache is rebuilt by backfill):
CREATE TABLE IF NOT EXISTS hold_captain_records (
hold_did TEXT PRIMARY KEY,
owner_did TEXT NOT NULL,
public BOOLEAN NOT NULL,
allow_all_crew BOOLEAN NOT NULL,
deployed_at TEXT,
region TEXT,
successor TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS hold_crew_members (
hold_did TEXT NOT NULL,
member_did TEXT NOT NULL,
rkey TEXT NOT NULL, -- for delete-event handling
role TEXT,
permissions TEXT, -- JSON array
tier TEXT,
added_at TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (hold_did, member_did)
);
Related tables hold_crew_approvals / hold_crew_denials support the crew
self-registration flow and are not part of discovery itself.
The user's selected default hold is mirrored in users.default_hold_did (the
authoritative copy lives in the sailor profile record on the user's PDS).
Ingestion
Live tail (Jetstream)
The worker (pkg/appview/jetstream/worker.go) subscribes with a wildcard:
wantedCollections: []string{
"io.atcr.*", // includes io.atcr.hold.captain / io.atcr.hold.crew
"app.bsky.actor.profile", // avatar sync
}
ProcessRecord (pkg/appview/jetstream/processor.go) routes captain/crew events to
ProcessCaptain / ProcessCrew, which upsert into the cache tables; delete events call
DeleteCaptainRecord / DeleteCrewMemberByRkey (crew deletes are matched by the stored
rkey, since delete events carry no record body).
Relay backfill
BackfillWorker (pkg/appview/jetstream/backfill.go) runs on startup and periodically
when jetstream.backfill_enabled is set. It lists repos per collection from the
configured relay endpoints and batch-upserts records
(batchCaptains / batchCrew in backfill_batch.go — single batched writes to avoid
long transactions against remote libsql). Captain and crew are in the standard backfill
collection list.
Direct bootstrap (primary hold)
Holds not yet crawled by a relay are invisible to both paths above, so backfill also
queries the AppView's primary managed hold (server.managed_holds[0]) directly via
queryCaptainRecord / queryCrewRecords (XRPC getRecord/listRecords against the
hold's own PDS, with startup retries and a freshness memo).
Queries
pkg/appview/db/hold_store.go:
| Function | Purpose |
|---|---|
GetAvailableHolds(db, userDID) |
Holds the user can select, with Membership: owner / crew / eligible |
GetAccessibleHoldDIDs(db, viewerDID) |
Hold DIDs whose content the viewer may see in listings (includes public) |
GetCrewMemberships(db, memberDID) |
Reverse lookup: holds where the user is crew |
GetCrewHoldDID(db, memberDID) |
Most recent crew hold, fallback when no default is cached |
GetCaptainRecord(db, holdDID) / UpsertCaptainRecord / ListHoldDIDs |
Cache plumbing |
GetAvailableHolds excludes holds that declare a successor (they are mid-migration)
and excludes pure-public holds (see divergences below).
Settings UI
Settings → Storage (pkg/appview/handlers/settings.go):
buildHoldsData()callsGetAvailableHolds, resolves display names, attaches health status, and computesReadOnly(crew withoutblob:write— pushes would be rejected).hold_selector.htmlrenders a<select name="hold_did">with optgroups Your Holds (owner + crew, with(Crew, read-only)markers) and Available Holds (eligible,allowAllCrewholds the user can join).hold_card.htmlshows the active hold with membership badge, online/offline status, and storage stats.- The form posts the DID to
POST /api/profile/default-hold(UpdateDefaultHoldHandler), which validates the user actually has access viaGetAvailableHolds, writesdefaultHoldto the sailor profile on the user's PDS, mirrors it intousers.default_hold_did, and kicks a background refresh of the captain/crew cache for the chosen hold.
Cache freshness: Jetstream gives near-real-time updates; the periodic backfill reconciles anything missed. There is no manual refresh button — saving a selection refreshes the relevant hold's records.
Divergences from the original proposal
This doc previously proposed a design that differs from what shipped:
- Field names: the proposal used
ownerDid/memberDid; the real fields areowner/member. It also omittedsuccessor,enableBlueskyPosts, andplankowner. - No "Public Holds" group in the picker: a default hold is a push target and
pushes require crew membership, so listing public-read-only holds would offer broken
options. Public holds still matter for read-side visibility
(
GetAccessibleHoldDIDs). - No
providerfield / column: never added to the lexicon or schema. - No cached
endpointcolumn: DID → URL resolution goes through the identity directory cache instead.
Remaining work
- Bootstrap beyond the primary hold: direct bootstrap only queries
managed_holds[0]. A deployment with multiple managed holds (or a need to seed un-crawled third-party holds, e.g. anATCR_BOOTSTRAP_HOLDSlist) would need the bootstrap loop extended. Deferred until such a deployment exists; relay backfill covers crawled holds.