|
| 1 | +# Policy |
| 2 | + |
| 3 | +The policy is the caller-supplied selection coroutine in the sheaf dispatch |
| 4 | +pipeline. It runs when the stalk at an invocation point contains more than one |
| 5 | +candidate and the sheaf has no data to resolve the ambiguity on its own. The |
| 6 | +caller is responsible for writing a policy that is correct for the providers it |
| 7 | +will receive. |
| 8 | + |
| 9 | +## Coroutine protocol |
| 10 | + |
| 11 | +The policy is an `async function*` generator, not a plain async function: |
| 12 | + |
| 13 | +```ts |
| 14 | +type Policy<M> = ( |
| 15 | + candidates: Candidate<Partial<M>>[], |
| 16 | + context: PolicyContext<M>, |
| 17 | +) => AsyncGenerator<Candidate<Partial<M>>, void, unknown[]>; |
| 18 | +``` |
| 19 | + |
| 20 | +The sheaf drives it with the following protocol: |
| 21 | + |
| 22 | +1. **Prime** — `gen.next([])` starts the coroutine. The empty array is |
| 23 | + discarded; it exists only to satisfy the generator type. |
| 24 | +2. **Yield** — the coroutine yields a candidate to try next. The yielded value |
| 25 | + must be an element of the `candidates` array received on entry — the sheaf |
| 26 | + uses object identity to map it back to the original provider. Constructing a |
| 27 | + new object with the same shape will throw with a message like "Policy yielded |
| 28 | + an unrecognized candidate". Sorting with `[...candidates].sort(...)` is safe |
| 29 | + because sort preserves references; mapping to new objects is not. |
| 30 | +3. **Attempt** — the sheaf calls the candidate's handler method. |
| 31 | +4. **Success** — the result is returned; the generator is abandoned. |
| 32 | +5. **Failure** — the sheaf calls `gen.next(errors)`, passing the ordered list |
| 33 | + of every error thrown so far (cumulative, not just the last). The coroutine |
| 34 | + receives this as the resolved value of its `yield` expression. |
| 35 | +6. **Exhausted** — if the generator returns without yielding, the sheaf throws |
| 36 | + `new Error('No viable handler for <method>', { cause: errors })` where |
| 37 | + `errors` is the full accumulated list of every failure so far. |
| 38 | + |
| 39 | +Most policies express a fixed priority order and can ignore the error input: |
| 40 | + |
| 41 | +```ts |
| 42 | +const awayPolicy: Policy<AwayMeta> = async function* (candidates) { |
| 43 | + yield* candidates.filter((c) => c.metadata?.mode === 'delegation'); |
| 44 | + yield* candidates.filter((c) => c.metadata?.mode === 'call-home'); |
| 45 | +}; |
| 46 | +``` |
| 47 | + |
| 48 | +A policy that inspects failure history can read the errors from yield: |
| 49 | + |
| 50 | +```ts |
| 51 | +const cautious: Policy<Meta> = async function* (candidates) { |
| 52 | + for (const candidate of candidates) { |
| 53 | + const errors: unknown[] = yield candidate; |
| 54 | + // errors is the cumulative list of all failures so far, including the one |
| 55 | + // just returned for this candidate. Inspect to decide whether to continue. |
| 56 | + if (errors.some(isUnrecoverable)) return; |
| 57 | + } |
| 58 | +}; |
| 59 | +``` |
| 60 | + |
| 61 | +## PolicyContext |
| 62 | + |
| 63 | +The second argument to the policy is a `PolicyContext`: |
| 64 | + |
| 65 | +```ts |
| 66 | +type PolicyContext<M> = { |
| 67 | + method: string; // the method being dispatched |
| 68 | + args: unknown[]; // the invocation arguments |
| 69 | + constraints: Partial<M>; // metadata keys identical across every candidate |
| 70 | +}; |
| 71 | +``` |
| 72 | + |
| 73 | +**`constraints`** are metadata keys whose values are the same on every |
| 74 | +candidate in the stalk. Because all candidates agree on these keys, they carry |
| 75 | +no information useful for choosing between them — the sheaf strips them from |
| 76 | +each candidate and delivers them separately. A policy that needs to know, say, |
| 77 | +the agreed `protocol` version reads it from `context.constraints.protocol` |
| 78 | +rather than from any individual candidate. |
| 79 | + |
| 80 | +**`args`** is available for cases where the policy itself must inspect the |
| 81 | +call. Most of the time, however, arg-dependent selection is better expressed as |
| 82 | +`callable` metadata on the providers than as conditional logic in the policy. |
| 83 | + |
| 84 | +Consider a swap where each provider has a different cost curve over volume. |
| 85 | +Encode each provider's cost as `callable` metadata evaluated at dispatch time: |
| 86 | + |
| 87 | +```ts |
| 88 | +const providers: Provider<SwapMeta>[] = [ |
| 89 | + { |
| 90 | + handler: providerAHandler, |
| 91 | + metadata: callable((args) => ({ cost: providerACost(Number(args[0])) })), |
| 92 | + }, |
| 93 | + { |
| 94 | + handler: providerBHandler, |
| 95 | + metadata: callable((args) => ({ cost: providerBCost(Number(args[0])) })), |
| 96 | + }, |
| 97 | +]; |
| 98 | +``` |
| 99 | + |
| 100 | +By the time the policy runs, `candidate.metadata.cost` already holds the |
| 101 | +concrete cost for this specific invocation — the swap amount has been applied. |
| 102 | +A policy that sorts by cost needs no knowledge of `args` at all: |
| 103 | + |
| 104 | +```ts |
| 105 | +const cheapestFirst: Policy<SwapMeta> = async function* (candidates) { |
| 106 | + yield* [...candidates].sort( |
| 107 | + (a, b) => (a.metadata?.cost ?? 0) - (b.metadata?.cost ?? 0), |
| 108 | + ); |
| 109 | +}; |
| 110 | +``` |
| 111 | + |
| 112 | +This is why evaluable metadata exists: the arg-dependent logic lives with the |
| 113 | +providers that own it, and the policy stays a pure selection coroutine. |
| 114 | + |
| 115 | +## Semantic equivalence assumption |
| 116 | + |
| 117 | +Two providers may differ in real ways — one might use TCP and the other UDP; |
| 118 | +one might be a Rust implementation and the other JavaScript. The semantic |
| 119 | +equivalence contract does not require that two providers be identical. It |
| 120 | +requires only that **if two providers are indistinguishable by metadata, their |
| 121 | +differences are immaterial to the authority invoker**. |
| 122 | + |
| 123 | +The sheaf relies on the following separation of responsibilities: |
| 124 | + |
| 125 | +- **Provider constructors** are responsible for advertising every feature that |
| 126 | + matters to callers. If transport protocol, latency tier, cost curve, or |
| 127 | + freshness guarantee could affect the invoker's decision, it belongs in the |
| 128 | + provider's metadata. Omitting a distinguishing feature is a declaration that |
| 129 | + callers need not care about it. |
| 130 | + |
| 131 | +- **Policy constructors** are responsible for selecting among the features that |
| 132 | + provider constructors have chosen to expose. The policy cannot see what was |
| 133 | + not advertised. |
| 134 | + |
| 135 | +This is a semantic contract, not a runtime enforcement — the sheaf cannot |
| 136 | +verify it. When a provider constructor omits a feature from metadata, they are |
| 137 | +asserting: for any authority invoker using this sheaf, that feature is |
| 138 | +irrelevant. If the assertion is wrong, the collapse step may silently discard a |
| 139 | +candidate that the policy would have ranked differently. |
| 140 | + |
| 141 | +> One `getBalance` provider uses a fully-synced node; another uses a lagging |
| 142 | +> replica. If both are tagged `{ cost: 1 }` with no freshness field, the |
| 143 | +> provider constructors are asserting that freshness is immaterial to callers |
| 144 | +> of this sheaf. If that is not true, `{ cost: 1, freshness: 'lagging' }` vs |
| 145 | +> `{ cost: 1, freshness: 'live' }` would let the policy choose. |
0 commit comments