Skip to content

Honor authenticated delivery-suspension notices #1372

Description

@dahlia

Motivation

A remote server may announce planned maintenance before it starts failing. Fedify currently has a queued circuit breaker (#620, #778), but a signed notice needs different authority, scope, expiry and cancellation rules. Applications should not have to intercept every send, forward, redirect and signature retry to enforce those rules themselves.

Implement FEP-f799 delivery behavior as an opt-in, authenticated receiver with complete baseline holding and recovery. The first release must work without Link discovery, stronger unknown-Undo retention or the optional durable backlog.

Proposed API

Add these registrations to Federatable<TData>:

setDeliverySuspension(options: {
  mode: "observe" | "apply";
  policy: DeliverySuspensionPolicyOptions;
  store?: AtomicKvStore;
  keyPrefix?: KvKey;
  observers?: DeliverySuspensionObservers<TData>;
}): DeliverySuspensionReceiver<TData>;
setDeliverySuspension(options: false): void;

Omitted registration and explicit false mean off. observe verifies/records without holding. Start with pinned baseline-v1 and explicit development-only development-v1; baseline must already resolve to finite usable notice/control/replay ages, retention, recheck and resume limits. Profile tuning/default-on are separate work.

The handle takes Context<TData> first for getStatus(), getNotice(), listNotices(), getPublicationCutoff(), evaluate(), setOverride(), revalidate() and bounded runOnce(). evaluate() returns {decision: "deliver" | "hold", noticeIds, reconsiderAt?} for diagnostics, not a permit replacing the actual POST gate. An override ignores one notice until an optional deadline without deleting its replay/terminal facts.

Keep sendActivity()/forwardActivity() returning Promise<void>. Add ignoreDeliverySuspension?: boolean to their applicable options. For direct partial delivery, propose:

declare const deferredRetryBrand: unique symbol;
interface DeferredDeliveryRetry { readonly [deferredRetryBrand]: true }
class DeferredDeliveryError extends Error {
  readonly outcomes: readonly DeliveryOutcome[];
  readonly retry: DeferredDeliveryRetry;
}
function retryDeferredDelivery<TData>(
  ctx: Context<TData>, token: DeferredDeliveryRetry,
  options?: { ignoreDeliverySuspension?: boolean; signal?: AbortSignal },
): Promise<readonly DeliveryOutcome[]>;

Each outcome is sent/deferred/failed, identifies the resolved inbox/actors, and preserves actual POST count, HTTP trace and existing classified Error instances, including transport errors. Deferred outcomes include suspension/state-unavailable cause and notice IDs. Sent means HTTP acceptance, not remote processing.

Usage

const receiver = federation.setDeliverySuspension({
  mode: "apply",
  policy: { profile: "baseline-v1" },
});
try {
  await ctx.sendActivity({ identifier: "alice" }, peers, activity, {
    immediate: true,
  });
} catch (error) {
  if (!(error instanceof DeferredDeliveryError)) throw error;
  displayOutcomes(error.outcomes);
  // Return the token to the caller; retry later at an application-chosen time.
  return error.retry;
}

The proposed retry helper reuses the prepared activity and exact resolved candidate plan, excludes known-sent destinations, and rejects another process/federation's token. It does not re-resolve actors or create a hidden queue. The same send can explicitly set ignoreDeliverySuspension: true, which bypasses only the notice hold. Existing breaker, Retry-After, rate/concurrency and retention constraints remain.

Receiving, authority and replay

Run internal authenticated intake before application listener selection, so an app need not register a competing Create/Undo listener. Preserve normal app observation. Storage failure must occur before inbox idempotency marking so a valid retry remains possible. Authenticate the principal/actor and validate raw shape, strict target origin, independently fetched mutual control links, freshness, immutable identity, revision and terminal state. Embedded reverse metadata is not authority evidence.

A valid cancelled revision 7 can terminate retained highest revision 9 without reducing that high-water mark; active revision 10 cannot revive it. Expired control evidence admits no new active revision, even one shortening the interval. Recorded-controller cancellation can end a retained grant. Confirmed removal stops dependent grants. A 304 Not Modified, proof refresh, 404 Not Found/410 Gone or replay does not renew notice/control age or stand in for cancellation.

For local freshness and replay, use snapshot reads plus versioned scope/controller membership roots, and commit their changes together with the notice records, authority state and replay cutoff through casMany(). A snapshot of known keys does not prove no other notice exists. Reject a verification result if the state it checked has changed before its write commits, using the version read in its snapshot. Atomically deleting an eligible record and advancing an inclusive monotone publication cutoff prevents replay after policy expansion; document conservative refusal of unseen old notices. Never use storage time to live (TTL) or a resettable floor as a substitute. Keep finite retries on conflicts and explicit format-mismatch and state-unavailable errors.

Baseline unknown Undo uses bounded fetch-or-decline. A permanent admitted-pair guarantee is deliberately deferred to its own child, not claimed here. Basic known cancellation, expiry, recheck and bounded queue termination must already work.

Transport and circuit-breaker integration

Apply the gate at every actual returned PublicFetchSession.fetch(), including same-session Accept-Signature/spec retries, redirects, immediate sends, forwarding and gateways. An openHop()-only gate misses reused-session requests. Preserve the typed internal hold through send.ts error wrapping.

No-POST hold is not an attempt, success or failure. A 401 Unauthorized negotiation or intermediate 5xx followed by a held retry retains the trace without inventing a failure the existing classifier would not count. A completed failed gateway request followed by another held candidate preserves the existing failure/Retry-After/host attribution. A redirect followed by a held hop is not automatically a failure.

Check independent retention/circuit-held TTL and the notice gate before consuming a side-effecting half-open probe. Keep legacy circuit coverage queued-only; this feature does not silently enable the breaker for legacy immediate sends. Scope notices to actual HTTPS endpoints, not decentralized identifier (DID) identity or every gateway. Permit an already-legitimate unheld alternative, but do not reselect shared/individual inboxes solely to evade a hold. Verify genuine FEP-f799 control exemptions rather than exempting every Create/Update/Undo.

Persist bounded deferred retention from the immutable queued/owned start time. Known FEP-f799 hold intervals can be excluded from ordinary retry-age accounting, but never extend this hard retention bound or a circuit-held TTL. Recheck/resume and local storage-outage handling must terminate without optional backlog support. Do not clear existing breaker state on cancellation.

Lifecycle, alternatives and shipping

Register configuration synchronously and defer idempotent internal preparation to the first async operation. No public initialize() call is required. Keep state separate per build and bind handles through the supplied ctx. Existing startQueue() ownership or bounded runOnce() must recover due work; startup/cron is required for no-traffic recovery after restart. Observers run after commit and cannot change its result by throwing.

Backend-specific stores, single-use preconstructed components and explicit initialization for each registration were rejected in favor of existing AtomicKvStore adapters and Fedify's registration style. A boolean success for partial immediate delivery would hide pending destinations; re-sending the entire activity could duplicate known successes. Preserve Promise<void> compatibility with a typed partial-result error and explicit prepared retry instead.

Test raw/authentication/control/replay races, absent membership rows, repeated policy expansion, storage failure/format mismatch, restart, every transport case above and unchanged legacy behavior. Include generic-context consumer tests and actual integration with the existing breaker. Run against at least one persistent adapter that passes the shared conformance suite, in addition to Memory tests.

Add or revise docs/manual/delivery-suspension.md and update kv.md, federation.md, send.md, inbox.md, circuit-breaker.md, tasks.md, mq.md and deployment/diagnostic guidance where behavior changes. Document only implemented observability, update inbox/framework skills, JSDoc, examples, package fragment and FEDERATION.md with the shipped receiver scope.

Metadata

Metadata

Assignees

No one assigned

    Fields

    Priority

    None yet

    Effort

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions