experimental

pod-redirect

One signed 'this moved, go there' record for pods, store tombstones and manifest re-points — followable pre-auth, verified before every hop.
Subpath@noy-db/hub/pod
Factorynone — direct named imports
ClusterF — Snapshot & Portability
Since0.6.0

Four situations need the same shape: a redirect pod that points at another pod, a decommissioned store's "moved to…" tombstone, a manifest re-point, and a fleet-member reference. Rather than four ad-hoc pointer formats, @noy-db/hub/pod ships one Redirect record, plus signRedirect / verifyRedirect to mint and check it, readPodRedirect to pull it off a pod header, and followRedirects to walk a chain.

The record

interface Redirect {
  readonly v: 1
  readonly target: string
  readonly reason: 'moved' | 'release' | 'tombstone' | 'repoint'
  readonly issuedBy: string  // keyId (16-hex) of the signer
  readonly sig: string       // base64url Ed25519 over the record minus `sig`
}

Five fields, deliberately. No timestamps, no identities beyond the signer's fingerprint. A redirect target is disclosure-appropriate by definition — pointing somewhere is the whole job — so there is nothing else worth withholding.

Fail-closed, not "unverified"

signRedirect signs through the same canonical-JSON Ed25519 convention as the pod header. verifyRedirect(record, trustedKeys) checks it against a caller-supplied keyId → publicKeyB64 map.

The trust semantics differ from the pod header on purpose. A pod may legitimately be unsigned — legacy v1 pods exist and { sign: false } is supported. A Redirect has no legacy install base, so:

  • An absent or malformed sig is invalid. Not "unverified", not a degraded state to pass through.
  • An issuedBy outside trustedKeys is treated exactly like a forged signature. There is no "untrusted but structurally fine" outcome, unlike verifyPodHeader's untrusted.

Why it lives in the plaintext header

The Redirect rides in the pod's plaintext header, not the encrypted body — the same allowlisted-header-key mechanism as species and engineRange.

The reason is structural. Every consumer this record exists for must follow it before authentication:

  • A dispatcher or landing page routes a request to the right pod before any unlock happens.
  • A connection-pod open flow must know it has been redirected before it has a key to decrypt with.
  • A static page — no store, no enclave, no vault — resolves a chain using only the bytes it was handed and WebCrypto.

None of those can decrypt a body. A Redirect in the encrypted body would require exactly the credential the redirect exists to route around.

readPodRedirect(bytes) is a pure, synchronous, secret-free read. It does not verify — it extracts and structurally validates the shape, because a parser has no trustedKeys to check against. Any caller that intends to act on a Redirect must call verifyRedirect first, or use followRedirects, which does it for you.

followRedirects

async function followRedirects(
  start: Uint8Array,
  fetcher: (target: string) => Promise<Uint8Array | null>,
  opts: { trustedKeys: Record<string, string>; maxDepth?: number },
): Promise<FollowRedirectsResult>

Walks the chain HTTP-redirect style, with four protections:

  1. Verify before follow. Each hop is verified against trustedKeys before it advances the walk. An untrusted or forged hop never moves you.
  2. Capped depth. maxDepth defaults to 8.
  3. Loop detection. A target seen twice is a loop, not a longer chain.
  4. Hop provenance. Each hop's { target, reason, issuedBy } accumulates into an ordered hops list, so a UI can render "moved from X via Y".

Four typed failures:

Errorcode
RedirectBadSignatureErrorREDIRECT_BAD_SIGNATURE
RedirectLoopErrorREDIRECT_LOOP
RedirectDepthExceededErrorREDIRECT_DEPTH_EXCEEDED
RedirectUnreachableErrorREDIRECT_UNREACHABLE

Success returns { terminal, hops }. A start with no redirect header returns { terminal: start, hops: [] } immediately.

Tombstones — failing forward

A route tombstone is a Redirect with reason: 'tombstone': what a decommissioned store leaves behind so stale connection pods fail forward instead of with a bare connection error.

  • Writing one is store wiring, not hub behaviour. The hub defines the record, the resolver, and these semantics. A store being decommissioned is expected to leave a species: 'redirect' pod — or a redirect header field on its existing connection pod — at the location clients already fetch.
  • No new discovery path. It occupies the same location the connection pod already did, so a client that knows how to fetch that connection pod will now fetch the tombstone instead.
  • Offline clients show the last-known re-point. A client offline when the tombstone is written cannot learn of it in real time; it shows whatever hop list it cached from its last successful reach. This is precisely why followRedirects surfaces the whole hops list rather than only the terminal — the cached list is the "last known re-point", and re-resolving on reconnect confirms or updates it.

Redirect is not a Locator

A Redirect says "go elsewhere once." A Locator says "where cargo lives."

A Redirect is a signed, single-hop pointer documenting an event — hence the reason. Following one is meant to end the redirect relationship, not establish an ongoing one; chains compose transiently.

A Locator is the steady-state answer to "where does this cargo actually live" — an address, not an event. target is a plain string today specifically because the Locator type does not exist yet; that is a forward seam, noted rather than blurred.

Keep them apart:

  • Don't use a Redirect as a long-lived address.
  • Don't expect a Locator to carry a signature or a reason.
  • A snapshot pod's "newer version at…" hint is a Redirect — an event — never a live route. That line is what keeps static artifacts honest about what they claim.

See also

  • pod-signature — the signing convention this shares
  • manifest — where a manifest re-point uses this record