Provider Router — High-Level Design
Reading guide: The current package architecture defines the binder boundary. Sections below describe reference designs unless an implementation note says otherwise. Standalone facade, cache and provenance services are dropped; their integration responsibilities belong to the consumer.
Status: Draft for review · Part of: Astro Binder · Updated: 2026-10-24
The router turns a canonical request into exactly one provider decision: a provider, a reason and a cache key, or a typed error when nothing can serve. It holds no results and calls no provider.
The decision boundary is implemented in the standalone
@astrology/provider-router package.
It uses injected capability snapshots, a host-resolved TT instant, explicit coverage-fallback
policy and read-only cache/breaker/quota hooks. Concrete cache storage, breaker state machines,
quota counters and the calculation facade remain future modules. The architecture below remains
the broader reference design.
1. Purpose
Two providers with different strengths force one decision, and the product must not make it per feature. Otherwise “the same chart” means different numbers in a natal and a transit view, with no record of which engine answered.
The router decides once:
- capability — can any provider serve this feature, body and zodiac?
- eligibility — do coverage, breaker state and quota allow it now?
- preference — Swiss Ephemeris by default; JPL Horizons only by declared fallback.
- gates — is the answer cached, and is the chosen provider healthy?
- decision — one provider, a reason, a cache key, or a typed refusal.
2. Where it sits
canonical request ──▶ provider router ──▶ provider decision
runtime state ──────▶ │
▼
facade calls the chosen adapter
- Upstream: the calculation facade, with a validated canonical request.
- Reads: the capability matrix, breaker registry, cache and engine versions.
- Downstream: the facade calls the adapter the router names.
- Never: calls an adapter, normalises values, caches results or derives astrology.
3. Responsibilities
| Stage | Owns | Refuses to do |
|---|---|---|
| Match capability | Feature × body × zodiac from the capability matrix | Route a feature no provider declares |
| Filter eligibility | Per-provider coverage, breaker state and quota | Allow a provider whose files do not cover the instant |
| Pick primary | Swiss first when eligible; Horizons only where a coverage fallback is declared | Mix providers inside one response |
| Check runtime gates | Cache key (provider + request + engine version), final breaker check | Hold or return results |
| Return decision | Provider id, reason, cache key | Call the adapter or hide the reason |
4. Architecture
Diagram: reference design, not deployed behaviour.
The router is a policy component, hence no provider cards in the diagram: the facade calls adapters after the decision, never the router.
5. Rules
- One provider per decision. A response carries one provenance block, so the router returns one provider or an error, never a partial or merged answer.
- Swiss is the default. Horizons is reachable only through the declared coverage fallback, for
a body or date Swiss files cannot serve. A Swiss outage is
PROVIDER_UNAVAILABLE, not a silent substitution. - Narrow in order. Capability, eligibility, preference, runtime gates: the diagram’s stage order, so an early failure never touches later state.
- Coverage is checked, not assumed. A provider that cannot serve the instant leaves the candidate set, even when healthy and preferred.
- The cache key is part of the decision. Provider id, full canonical request and engine version: a hit needs no adapter call, and an upgrade invalidates old entries by construction.
- Deterministic. The same request and runtime state give the same decision and reason string.
- Stateless apart from policy. No results or provider payloads are stored; only breakers and counters survive a request.
- Houses are Swiss-only. The matrix encodes that, so the router refuses rather than approximating a house system on Horizons.
6. Edge cases
| Case | Behaviour |
|---|---|
| Body served by both, instant outside Swiss files | Horizons if the fallback is declared for that body; otherwise OUT_OF_EPHEMERIS_RANGE |
| Instant inside Swiss files, Moshier fallback in use | Still Swiss, flagged degraded by the adapter and normalizer |
| Horizons breaker open | Validation and fallback cases are marked unavailable; user requests are unaffected |
| Cache entry from an older engine version | Treated as a miss; the key includes the engine version |
| Unknown feature or house system | Typed error before any capability lookup |
| Both providers ineligible | PROVIDER_UNAVAILABLE with per-provider reasons in the error detail |
| Breaker trips between selection and call | The gate re-checks; the decision fails closed rather than calling a known-bad provider |
| Request for a body outside every provider’s range | OUT_OF_EPHEMERIS_RANGE, not an empty position list |
7. Out of scope
- Calling adapters, parsing provider responses and retrying transport failures.
- Unit, frame and zodiac normalisation and provenance assembly (normalizer).
- Astrology semantics and chart composition.
- Handling a degraded answer beyond declaring it (the facade owns the response).
8. Planned code
| Piece | Responsibility |
|---|---|
router/ProviderRouter |
Stage orchestration and the decision object |
router/CapabilityMatrix |
Feature × body × zodiac × range per provider |
router/EligibilityFilter |
Coverage, breaker and quota filtering |
router/PrimarySelector |
Swiss-first preference and declared fallback rules |
router/CacheKeyFactory |
Provider + canonical request + engine version |
router/BreakerRegistry |
Per-provider and per-endpoint breaker state |