Skip to content

Commit 023d2ca

Browse files
committed
docs(sheaves): rename LIFT.md to POLICY.md
1 parent d5db5f1 commit 023d2ca

3 files changed

Lines changed: 146 additions & 146 deletions

File tree

packages/sheaves/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Runtime capability routing adapted from sheaf theory in algebraic topology.
66
over a collection of capability providers. The sheaf produces dispatch handlers via
77
`getSection`, each of which routes invocations through the provider set.
88

9-
See [USAGE.md](./USAGE.md) for annotated examples and [LIFT.md](./LIFT.md) for
9+
See [USAGE.md](./USAGE.md) for annotated examples and [POLICY.md](./POLICY.md) for
1010
the policy coroutine protocol and semantic equivalence assumption.
1111

1212
## Install

packages/sheaves/documents/LIFT.md

Lines changed: 0 additions & 145 deletions
This file was deleted.
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
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

Comments
 (0)