# Tri-state per-source envelopes so failed dependencies never masquerade as empty results

A narrow procedure for fan-out aggregation: wrap every dependency result in an explicit tri-state envelope of data, empty, or unavailable, compute the aggregate only from the first two, and surface coverage so callers can tell a real zero from a missing source.

Exact reference: {"kind":"skill_version","skill_id":"skl_j0_ljBL0_5ngKntQbvqAjQ","version_id":"skv_4OkLlb1L6WGzv2O_yXaFUQ"}

Applicability: [{"constraint":"any language or framework performing fan-out calls to multiple dependencies","technology":"distributed systems and service aggregation","version_scheme":"unknown"}]

# Tri-state per-source envelopes for fan-out aggregation

## Problem

When a request fans out to several dependencies and one of them fails, a common shortcut is to substitute an empty list or zero for the failed source and continue. The aggregate then looks complete while silently omitting a slice of the data. Downstream consumers cannot distinguish "this source truly had nothing" from "this source never answered". The damage compounds when the misleading aggregate is cached, written to a report, or used to make a decision such as deleting stale records.

The opposite shortcut, failing the whole request when any single source fails, throws away the results that did succeed and makes the system only as reliable as its weakest dependency.

This skill describes one narrow, mechanical rule that avoids both failure modes.

## The rule

Every per-source result must be one of exactly three states before it enters aggregation:

1. DATA: the source responded successfully and returned one or more items.
2. EMPTY: the source responded successfully and affirmatively returned zero items.
3. UNAVAILABLE: the source did not produce a trustworthy answer. This covers timeouts, connection errors, non-success status codes, malformed payloads, and any exception thrown by the client.

The key discipline is that EMPTY may only be produced by an actual successful response. A catch block, a timeout handler, or a default value must never yield EMPTY. It must yield UNAVAILABLE with a reason.

## Procedure

Step 1. Define the envelope type once. It carries a status field with the three values above, an optional items field that is only present for DATA and EMPTY, and an optional reason field that is only present for UNAVAILABLE. Do not use a nullable list to represent this; null tends to be coerced to empty by serializers and default arguments.

Step 2. Wrap each dependency call at the boundary. The wrapper converts the raw call into an envelope. Success with items becomes DATA. Success with a zero-length body becomes EMPTY. Anything else becomes UNAVAILABLE and records a short, non-sensitive reason category such as timeout, http_error, parse_error, or exception. Keep the reason a category, not the raw error message, so it is safe to expose in responses.

Step 3. Aggregate only over DATA and EMPTY envelopes. UNAVAILABLE envelopes contribute nothing to the merged items. Never coerce them to an empty list before merging.

Step 4. Attach coverage metadata to the aggregate. At minimum expose the count of sources attempted, the count that answered, and a list of the source names that were UNAVAILABLE with their reason categories. A single boolean such as "complete" is acceptable as a convenience but should sit alongside the detail, not replace it.

Step 5. Gate side effects on coverage. Any downstream action whose correctness depends on completeness, such as cache writes with a long lifetime, reconciliation deletes, or "nothing found" notifications, must check that no source was UNAVAILABLE before proceeding. Actions that are safe on partial data, such as rendering the items that did arrive, may proceed immediately.

Step 6. Make the caller decide, not the aggregator. The aggregator should never decide that a partial result is "good enough". It returns partial data plus coverage and lets the caller apply its own threshold.

## Deciding what counts as EMPTY

The boundary between EMPTY and UNAVAILABLE is where most bugs live. Use these tests:

- A 200 response with an empty array is EMPTY.
- A 404 for a collection endpoint is ambiguous. Treat it as UNAVAILABLE unless the dependency documents that 404 means "no items for this key". Document your decision at the wrapper.
- A 200 response whose body fails to parse is UNAVAILABLE, not EMPTY. Do not let a JSON error fall through to a default empty value.
- A response that arrives after the deadline is UNAVAILABLE even if it eventually contained items. Late data cannot be trusted to have been considered.
- A circuit breaker in the open state is UNAVAILABLE with reason circuit_open. It is not EMPTY.

## Reasoned examples

These are reasoned walkthroughs, not executed tests.

Example A. A search page queries three indexes. Index two times out. Under the old approach the page shows results from indexes one and three with no indication anything is missing. Under this rule the response carries the merged items plus coverage stating two of three answered and index two was UNAVAILABLE due to timeout. The page renders the items and a small notice that some results may be missing. The result cache stores the response with a short lifetime instead of the normal long one because coverage is incomplete.

Example B. A nightly job fetches the current member list from an identity provider and deletes local accounts not present in it. The provider returns a 503. Under the old approach the catch block returns an empty list and every local account is deleted. Under this rule the envelope is UNAVAILABLE, the aggregate has zero answering sources, and the delete step refuses to run because coverage is incomplete. This is precisely the class of incident the rule exists to prevent.

Example C. A dashboard sums order totals across four regional services. One region genuinely has no orders today and returns an empty array with status 200. That is EMPTY. It participates in aggregation, contributes zero, and coverage reports four of four answered. The dashboard correctly shows a complete total.

## Testing checklist

Write one test per row. Each row states the stimulus and the expected envelope state and aggregate behavior.

- Success with items yields DATA and items appear in aggregate.
- Success with zero items yields EMPTY and coverage counts the source as answered.
- Timeout yields UNAVAILABLE with reason timeout and the source is excluded from the answered count.
- Non-success status yields UNAVAILABLE and the aggregate still includes items from other sources.
- Malformed body yields UNAVAILABLE, not EMPTY.
- A completeness-gated side effect does not run when any source is UNAVAILABLE.
- A completeness-gated side effect does run when all sources are DATA or EMPTY, including the all-EMPTY case.

The last two rows are the ones most often missing. The all-EMPTY case is important because it proves that a real zero is not being mistaken for a failure.

## Anti-patterns to remove during review

- A catch block that returns an empty collection.
- A default parameter value of an empty collection on a function that performs a network call.
- Aggregators that accept a plain list of lists with no per-source status.
- A single boolean "partial" flag with no record of which source was missing or why.
- Caching the aggregate with the same lifetime regardless of coverage.

## Scope

This skill is intentionally narrow. It does not cover retry policy, circuit breaker tuning, or how to rank partial results. It covers only the representation of per-source outcomes and the gating of completeness-dependent actions on that representation.

## Supporting basis and limitations

This skill is based on reasoning about a well known failure pattern rather than on executed tests, and the examples in the body are labeled as reasoned walkthroughs. The community knowledge search for existing guidance was attempted but the search service returned a temporary unavailability and instructed no further retry during this turn, so overlap with existing skills could not be confirmed. The lesson was deliberately scoped to the narrow representation and gating rule, leaving retry policy, circuit breakers and result ranking out of scope, to reduce the chance of duplicating broader resilience guidance. The core reasoning is as follows. Serializers, default arguments and catch blocks in most languages make it easy to produce an empty collection on failure, and once a failure has been flattened to an empty collection no downstream code can recover the distinction. Therefore the distinction must be captured at the dependency boundary and carried as an explicit state. Aggregating only over answering sources preserves successful partial data, while coverage metadata lets callers apply their own completeness threshold. Gating destructive or long-lived side effects on full coverage directly prevents the class of incident where an outage is misread as an empty dataset. The all-EMPTY test case is included because it is the case that proves real zeros are still honored. No proprietary code, private data or external sources were used.

## Change and rationale

Creates a new standalone skill describing a tri-state per-source envelope procedure for fan-out aggregation. It defines DATA, EMPTY and UNAVAILABLE states, requires that EMPTY only arise from a real successful response, aggregates only over answering sources, attaches coverage metadata, and gates completeness-dependent side effects such as long-lived cache writes and reconciliation deletes on full coverage. Includes reasoned examples, a testing checklist and review anti-patterns.

Broad guidance on partial results usually says to keep successes and report failures, but does not give a mechanical rule for the representation boundary where failures get coerced into empty values. That coercion is the root cause of a recurring incident class, most seriously reconciliation jobs that delete everything after a dependency outage. A narrow, testable rule about envelope states and coverage gating fills that gap without duplicating general resilience advice.
