Skip to content

feat: multi-authority space host with dynamic discovery and projection syncer - #233

Open
rishibalakrishnan wants to merge 56 commits into
mainfrom
feat/space-host-main-port
Open

feat: multi-authority space host with dynamic discovery and projection syncer#233
rishibalakrishnan wants to merge 56 commits into
mainfrom
feat/space-host-main-port

Conversation

@rishibalakrishnan

Copy link
Copy Markdown
Contributor

Summary

Ports the standalone space host and syncer daemon onto the merged spaces interop surface. The host serves permissioned space repos for multiple authorities in one process, resolving each authority's signing key from the PDS actor store, with DPoP-bound credentials (ES256/ES256K) minted for an allowlisted set of service identities. The daemon discovers spaces dynamically from the managing app, syncs and verifies their oplogs, and projects records to the feed service and appview ingress with journaled, per-projector cursors.

Test plan

  • cargo test -p rsky-space-host (121 tests)
  • cargo test -p rsky-daemon --lib (92 tests)
  • cargo test -p rsky-pds --lib
  • End-to-end against a local dev stack: register → discover → mint → write → sync → project, single and multi-authority

Adds an IndexMutation journal written alongside every synced batch,
independent per-projector cursors with dead-lettering, the Projector
trait, and typed routing from index mutations to sync events.
DAEMON_FEEDS_URL / DAEMON_APPVIEW_URL (with their service DIDs) give every
discovered space a worker that drains each destination on its own cursor;
an empty URL leaves the daemon index-only.
Implements RepoStore over per-account store.sqlite files carrying the
space_* tables, transcribing the pinned oracle's apply-writes semantics:
server-minted revisions, per-write swap checks, record-exists and
record-not-found refusals, oplog append and compaction.

The harness now drives both sides through per-batch scripts, compares
refusal reasons as well as contents, and adds S6.
Adds a second comparator over the space_* rows of both store files, with
server-minted revisions, oplog ids and creation timestamps replaced by
first-appearance placeholders, plus assertions that every revision is a
valid TID and that oplog order agrees with revision order. Paged reads
are now walked page by page on both sides.

S7 (unicode and prefix-colliding keys), S8 (large record), S10 (two
spaces, one author) and S11 (pagination) join the scoreboard: 10/10.
S9 drives both stores past a three-row oplog window, so the shim now has
to match the oracle's compaction, its oplog floor, and its refusal to
serve history that fell out of the window. Every scenario additionally
reopens the shim's own file with the oracle's SpaceStore and compares
what it reads (S13), and probes history since every revision each side
minted.

S12 records the one accepted divergence: the oracle indexes blob
references, the shim's storage keeps the bytes without indexing them and
its write path refuses blob-bearing records outright.
Converts a deployed multi-tenant store into per-account actor stores,
carrying the LtHash state and every oplog row id across verbatim so
readers see the same digest and syncers resume on the cursors they
already hold. Record revisions, which the source schema does not carry,
come from the newest operation that left the record at its current CID.

S14 builds a fixture in the frozen source schema, converts it, and reads
the result back with the oracle: heads, records, oplog ids and
resume-from-cursor all verified. Scoreboard: 13/13 (+1 documented
divergence).
The in-memory store no longer no-ops a delete of an absent record, and
an update of an absent record is refused rather than upserted, matching
what the actor-store backing and the pinned oracle do. WriteOutcome::Noop
goes with it, so deleteRecord always records the write.
The two-process gate found three response-level divergences from rsky-pds
that the in-process suite could not see:

- RecordExists, RecordNotFound, InvalidSwap and HistoryUnavailable now
  answer with their own names and the oracle's status instead of being
  collapsed into InvalidRequest or answered with 409/410.
- listRepoOps hydrates an op's value only when the op's cid still names
  the stored record, so a superseded op no longer carries the current
  record's value.
- The local write path records the commit digest in the writer set, so
  listRepos returns the hash the oracle returns.
One command runs both real binaries and prints per-endpoint parity:
run.sh builds rsky-pds from a detached build-only worktree at the pinned
oracle revision, builds the space host from the working tree, then fires
the same record script at each over XRPC and points the existing Layer 1
stored-row comparator at the two store files.

The gate supplies the only support service itself: a stub DID directory
on a loopback port, and the access tokens and space credentials each
server demands. No database, container or network is involved.

The four methods only rsky-pds routes are probed against it alone and
reported as a surface difference rather than a parity failure; the probe
is deliberately invalid so it cannot write to one side only.
18/18 checks equal (+1 documented divergence) with both real binaries
running. Records the surface difference a client gains when it moves from
the space host to rsky-pds, the three divergences the gate found, and the
falsification evidence for each of its three comparison layers.
The converged space host deploys over data the legacy host wrote. This runs
that upgrade with real processes — legacy host, real daemon, converter,
converged host, same daemon — and asserts the daemon resumes on its existing
cursors: exactly the operations after the cursor, once each, with no cursor
refusal, divergence or full-state recovery. A cold daemon on the converted
store is compared against it.
The managing app resolves an inbound service JWT against the issuer's
#atproto verification method, so a call signed with the space key is
rejected. Mint checkUserAccess and ackHostRegistered through
PdsServiceJwtIssuer instead, and refuse at boot when a pinned authority
under the managing-app policy has no actor-store key.
Service auth and delegation tokens are keyed on #atproto, but the shared
resolver preferred #atproto_space and so selected the wrong key for any
DID publishing both. Credential verification keeps the space-first order.
SPACEHOST_ACTOR_STORE_DIR was both the signing-key source and the
ActorStoreRepos write target, which made the shim write space tables
into the PDS's own actor files — the rejected Option B, and impossible
where that directory is mounted read-only. Writes now go to
SPACEHOST_SPACE_STORE_DIR, the same path is refused at boot, and a store
that already carries the base schema is adopted rather than re-migrated.

Both gates only ever ran the two directories as one path and never
opened a store the PDS created, so neither could see this: Layer 1 gains
those two cases and Layer 2 now runs them separated and asserts the key
directory is never written to.
WebCrypto emits a high-S ECDSA signature about half the time, so
verifying a DPoP proof or access token under the repo-commit signature
policy failed roughly every other request from a legitimate client.
Verify those through shim-local helpers that pass allow_malleable_sig,
and keep the strict verifiers for repo commits, where a malleable
signature would be a second valid signature over the same content.
SpaceError::BadSignature reads "commit signature verification failed",
which is accurate for a repo commit and misleading on a DPoP proof —
where it kept surfacing and kept sending readers to look at commits.
allow_malleable_sig only waives the encoding check on this curve, and
libsecp256k1 refuses a high-S signature regardless, so roughly every
other DPoP proof from a client whose key is secp256k1 was rejected. The
DER path is left as it was. Tests now pin the asymmetry on both curves:
a client's high-S proof verifies, and the commit path still refuses it.
A projectRecords 401 means the author is not admitted to the space, which
is state, not a bad batch: it flips when a membership write propagates.
Three fast attempts then dead-lettered the batch permanently, so a moment
of FGA lag left a record unprojected for good.

Denials now get their own error class, their own durable counter beside
the poison one, and a slow lane: re-attempted once per sweep, parked
after DAEMON_DENIAL_PARK_AFTER sweeps so a legitimate refusal still stops.
A denied author's later batches wait behind the denied one, or the cursor
would advance past it. Poison keeps its fast dead-letter unchanged.
The converged space host has required SPACEHOST_SPACE_STORE_DIR since the
two directories were separated; the gate still passed one path and so
failed at boot. The legacy era predates the split and keeps one directory.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant