@byollm/control-plane 0.1.0-alpha.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Of Tomorrow, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # @byollm/control-plane
2
+
3
+ > **Alpha (`0.1.0-alpha.100`) — under active development. Don't use this yet.**
4
+
5
+ The reference control plane: it resolves a person's mapping and authors one
6
+ signed grant per job, against a policy store it does not own and with a key it
7
+ never sees.
8
+
9
+ ```ts
10
+ import { ControlPlane, MemoryPolicyStore, keypairSigner } from "@byollm/control-plane";
11
+ import { Relay } from "@byollm/relay";
12
+
13
+ const engine = new ControlPlane({
14
+ store: new MemoryPolicyStore(),
15
+ signer: keypairSigner(keys),
16
+ });
17
+
18
+ const relay = new Relay({
19
+ fixture,
20
+ controlPlanePublic: engine.publicKey,
21
+ authorGrant: (input) => engine.authorGrant(input),
22
+ });
23
+ ```
24
+
25
+ ## What this is for
26
+
27
+ A byollm device runs nothing on somebody else's behalf without a **grant**: a
28
+ short-lived, single-use, signed statement that this job, for this person, on
29
+ this device, may run — and which of the owner's services should answer. The
30
+ device verifies it against a key it pinned when it paired, so the party
31
+ routing the job can withhold a grant and cannot forge one.
32
+
33
+ This package is what writes them.
34
+
35
+ ## Two things it deliberately does not hold
36
+
37
+ **Your data.** Accounts, consents, memberships and mappings live behind
38
+ [`PolicyStore`](src/store.ts). This package reads; it never owns. That is the
39
+ split that lets a hosted product keep its database while the *rule* over that
40
+ database stays readable — because a rule nobody can read is a rule nobody can
41
+ check.
42
+
43
+ **Your key.** [`GrantSigner`](src/signer.ts) is a function, not a keypair, so
44
+ custody can sit behind a KMS and this code cannot tell.
45
+
46
+ ## The contract
47
+
48
+ `@byollm/control-plane/store-contract` is a suite any `PolicyStore` can run
49
+ against itself:
50
+
51
+ ```ts
52
+ import { describePolicyStoreContract } from "@byollm/control-plane/store-contract";
53
+
54
+ describePolicyStoreContract("my store", { make: async () => ({ /* … */ }) });
55
+ ```
56
+
57
+ It ships in the package because the implementation that matters most is in
58
+ somebody else's repository, and a contract only its author can run is a
59
+ description.
60
+
61
+ ## The law it applies
62
+
63
+ In order, and the order is by whose fact each step is:
64
+
65
+ 1. **A device always runs its own owner's work**, and no store is asked. It is
66
+ a law rather than an optimisation — routing it through a store would put it
67
+ somewhere an implementation could get wrong.
68
+ 2. The person has **consented** to this site, and has not paused it.
69
+ 3. They are a **member** of this device owner's team — asked only about other
70
+ people.
71
+ 4. Their **mapping** for this (purpose, kind) names a service.
72
+ 5. This device **actually offers** it, for this kind.
73
+
74
+ Any step failing stops the rest, so the reason returned names the first thing
75
+ that was wrong rather than whichever check ran last.
76
+
77
+ ## Declining says whether it is forever
78
+
79
+ A relay releases a declined job, and a release can be permanent — never offer
80
+ this job to this device again. Getting that wrong permissively is a
81
+ claim-refuse loop, which announces itself. Getting it wrong strictly is a job
82
+ that can never reach the machine it was always meant for, and no error
83
+ anywhere. So only two reasons are permanent: a person removed from a team, and
84
+ consent withdrawn. An unfilled slot, a mapping that resolved to another
85
+ machine, and a policy store that was briefly unreachable are all states that
86
+ can change, and the job goes back in the queue.
87
+
88
+ ## Licence
89
+
90
+ MIT.
91
+
92
+ <!-- family:start -->
93
+
94
+ ## The rest of byollm
95
+
96
+ Six packages, and they are only interesting together:
97
+
98
+ - [`byollm`](https://www.npmjs.com/package/byollm) — the daemon — runs models on your own machine and answers for it
99
+ - [`@byollm/protocol`](https://www.npmjs.com/package/@byollm/protocol) — the wire: envelopes, signatures and the closed vocabularies both ends validate against
100
+ - [`@byollm/server`](https://www.npmjs.com/package/@byollm/server) — the SDK a site uses to ask a device for work
101
+ - [`@byollm/relay`](https://www.npmjs.com/package/@byollm/relay) — the broker that holds jobs between a site and a device, and can read neither
102
+ - [`@byollm/conformance`](https://www.npmjs.com/package/@byollm/conformance) — the kit that proves an implementation is one — including a posture audit that holds nothing but a URL
103
+
104
+ <!-- family:end -->
@@ -0,0 +1,274 @@
1
+ import { GrantClaims, SignedGrant, StoredKeys, ClaimedStub, CapabilityMatrix } from '@byollm/protocol';
2
+ import { P as PolicyStore, M as Mapping, a as PolicySnapshot } from './store-0clah5RQ.js';
3
+
4
+ /**
5
+ * Whatever holds the key that grants are signed with.
6
+ *
7
+ * A function rather than a key, so custody never enters this package. A
8
+ * self-hoster hands over a keypair; a hosted deployment can put the private
9
+ * half behind a KMS and pass a signer that calls it, and the engine cannot
10
+ * tell the difference — which is the point. Custody of the signing key is one
11
+ * of the two things byollm.cloud keeps, and an engine that had to be given
12
+ * the key could not be run any other way.
13
+ *
14
+ * `publicKey` sits on the same object deliberately. It is the value a relay
15
+ * hands a device at pairing, and it is the value that must verify what
16
+ * `sign` produces. Two separate configuration items would be two things a
17
+ * deployment could set inconsistently — and the failure mode is a fleet that
18
+ * refuses every job while every process reports itself healthy.
19
+ */
20
+ interface GrantSigner {
21
+ /** The public half, for a relay to hand devices at pairing. */
22
+ readonly publicKey: string;
23
+ sign(claims: GrantClaims): Promise<SignedGrant> | SignedGrant;
24
+ }
25
+ /**
26
+ * Sign with a keypair this process holds.
27
+ *
28
+ * The implementation a self-hoster wants, and the one the tests use. A hosted
29
+ * deployment writes its own against whatever holds its key.
30
+ */
31
+ declare function keypairSigner(keys: Pick<StoredKeys, "identityPrivate" | "identityPublic">): GrantSigner;
32
+
33
+ /**
34
+ * The control plane: one signed grant per job, or a reason there is none.
35
+ *
36
+ * byollm_016 Amendment J and L. This is the open engine behind a relay's
37
+ * `authorGrant` seam. It reads policy from a {@link PolicyStore} it does not
38
+ * own and signs with a {@link GrantSigner} whose key it never sees, so the
39
+ * two things byollm.cloud keeps — the data and the key — sit outside it, and
40
+ * the rule over them is readable by anybody.
41
+ *
42
+ * ## One routes, one authorises, only one signs
43
+ *
44
+ * A relay already filters what it offers a device, by consent and by
45
+ * membership. **That filter is an optimisation and this is the authority.**
46
+ * Everything is checked again here, because the relay's projection can be
47
+ * stale and because a grant asserting "consented, member, admitted" must be
48
+ * true when it is signed rather than when something upstream last looked. If
49
+ * the two disagree, this one wins and refuses.
50
+ */
51
+ declare class ControlPlane {
52
+ #private;
53
+ constructor(options: {
54
+ readonly store: PolicyStore;
55
+ readonly signer: GrantSigner;
56
+ /** Injectable clock, so tests move time instead of sleeping. */
57
+ readonly now?: () => number;
58
+ /** Injectable id source, so a test can assert what was signed. */
59
+ readonly newGrantId?: () => string;
60
+ });
61
+ /** The key devices pin at pairing, from the same object that signs. */
62
+ get publicKey(): string;
63
+ /**
64
+ * Author a grant for one claimed job, or decline with a reason.
65
+ *
66
+ * The order below is the law, and it is ordered by *whose* fact each step
67
+ * is: the device's own owner first, then the person's consent, then their
68
+ * membership, then their mapping, then this machine's ability to honour it.
69
+ * A step that fails stops the rest, so the reason returned names the first
70
+ * thing that was actually wrong rather than whichever check ran last.
71
+ */
72
+ /**
73
+ * Can this purpose be satisfied for this person, asked before a job exists.
74
+ *
75
+ * The two answers a site can act on, each decided where it is knowable and
76
+ * nowhere else. A purpose the manifest does not declare will not appear in
77
+ * it by waiting. A purpose nobody has mapped is the person's own dashboard,
78
+ * and somebody who maps it thirty seconds from now is served by the next
79
+ * job — the same thirty seconds, and it avoids the thing that must never
80
+ * happen: a job the site has already fallen back on being served afterwards.
81
+ *
82
+ * Everything else is `ok`, including every case the transient path was
83
+ * always for — declared, mapped, and nothing able to claim right now.
84
+ *
85
+ * Deliberately not a second gate at claim. This answers at enqueue, claim
86
+ * answers at claim, and a mapping revoked between the two falls to the
87
+ * transient path exactly as it does today.
88
+ */
89
+ satisfiable(input: {
90
+ readonly siteId: string;
91
+ readonly user: string;
92
+ readonly purpose: string | undefined;
93
+ readonly kind: string;
94
+ }): Promise<{
95
+ verdict: "ok" | "not-declared" | "unmapped" | "waiting";
96
+ }>;
97
+ authorGrant(input: {
98
+ readonly job: ClaimedStub;
99
+ /** The site's id in this control plane's namespace, for the policy read. */
100
+ readonly siteId: string;
101
+ /**
102
+ * The same site, as the key id the device pinned — the value that gets
103
+ * signed.
104
+ *
105
+ * Two ids for one site, and both are needed: the store is keyed by the
106
+ * control plane's own id, and the device knows sites only by what it
107
+ * pinned. The grant carries the one the device can check without asking
108
+ * anybody, which is the whole point of a signed document.
109
+ */
110
+ readonly siteKey: string;
111
+ /** The device owner asking. */
112
+ readonly owner: string;
113
+ /**
114
+ * Which of the site's declared purposes this job serves.
115
+ *
116
+ * Supplied by the caller rather than read off the job, because in the
117
+ * release that built this engine the stub does not carry one yet — every
118
+ * job is the site's {@link RESERVED_PURPOSE}. When purposes reach the
119
+ * wire, the caller passes the job's own and nothing here changes.
120
+ */
121
+ readonly purpose?: string;
122
+ /**
123
+ * What this device advertised it can serve.
124
+ *
125
+ * The control plane resolves a mapping to a service **from what the
126
+ * device said**, never from a name it invented. A mapping naming
127
+ * something this device does not offer is not honoured here — see
128
+ * `resolved-elsewhere`.
129
+ */
130
+ readonly capabilities: CapabilityMatrix;
131
+ }): Promise<GrantOutcome>;
132
+ }
133
+ /**
134
+ * Why no grant was authored — and, load-bearing, whether that is forever.
135
+ *
136
+ * The distinction exists because a relay releases a declined job, and a
137
+ * release can carry `refused`, which means *never offer this job to this
138
+ * device again*. Getting that wrong in the permissive direction is a
139
+ * claim-refuse loop; getting it wrong in the strict direction is a job that
140
+ * can never reach the machine it was always meant for, and no error anywhere.
141
+ */
142
+ type DeclineReason =
143
+ /** This person may not use this owner's devices. */
144
+ "not-a-member"
145
+ /** This person has not authorised this site. */
146
+ | "not-consented"
147
+ /**
148
+ * Authorised, and temporarily not routing — byollm-review 2026-08-27.
149
+ *
150
+ * Transient by the same test every reason here is judged by: the person can
151
+ * lift it themselves, so the job must still be waiting when they do.
152
+ */
153
+ | "consent-paused"
154
+ /** They authorised it and left this slot empty. */
155
+ | "unmapped"
156
+ /** Their mapping names a service this device does not offer. */
157
+ | "resolved-elsewhere"
158
+ /** The policy store could not answer. Says nothing about the job. */
159
+ | "store-unavailable";
160
+ interface Decline {
161
+ readonly reason: DeclineReason;
162
+ /**
163
+ * Never offer this job to this device again.
164
+ *
165
+ * True only for the two facts that a queued job cannot outlive: a person
166
+ * removed from a team, and consent withdrawn. Hole 1 ruled that removal
167
+ * stops future claims *including queued ones*, and this is where that is
168
+ * enforced.
169
+ *
170
+ * Everything else is a state the user can change, or a fault of ours. A
171
+ * permanent mark on those would outlive the condition that caused it.
172
+ */
173
+ readonly permanent: boolean;
174
+ }
175
+ type GrantOutcome = {
176
+ readonly granted: SignedGrant;
177
+ readonly declined?: undefined;
178
+ } | {
179
+ readonly granted?: undefined;
180
+ readonly declined: Decline;
181
+ };
182
+
183
+ /**
184
+ * A policy store in a `Map`, for tests and for a single-process self-hoster.
185
+ *
186
+ * It is the reference implementation in the sense that matters: the contract
187
+ * suite runs against it, so it is the thing anybody else's store is compared
188
+ * to. It is not a suggestion about how to build one — a real store is a
189
+ * database, and this one forgets everything when the process ends.
190
+ */
191
+ declare class MemoryPolicyStore implements PolicyStore {
192
+ #private;
193
+ /**
194
+ * Record a consent, with the mapping it carries.
195
+ *
196
+ * One call because they are one act: the mapping **is** the consent
197
+ * (Amendment L), and a store that let them be written separately would
198
+ * allow a state — consented, no mappings, no way to have got there — that
199
+ * the product cannot produce.
200
+ *
201
+ * Consenting with an empty list is the real signup state, though: a person
202
+ * whose slots all had two candidates and who has not chosen yet.
203
+ */
204
+ consent(input: {
205
+ siteId: string;
206
+ user: string;
207
+ mappings?: readonly Mapping[];
208
+ }): void;
209
+ /**
210
+ * Pause a consent without withdrawing it.
211
+ *
212
+ * A real product state and not the same as revoking: the mapping survives,
213
+ * so resuming does not ask somebody to author their choices again. What it
214
+ * must not do is let work through — a relay's projection can lag by
215
+ * seconds, and a grant authored in that window would run work whose owner
216
+ * had just stopped it.
217
+ */
218
+ pause(input: {
219
+ siteId: string;
220
+ user: string;
221
+ }): void;
222
+ /** Let it move again. */
223
+ resume(input: {
224
+ siteId: string;
225
+ user: string;
226
+ }): void;
227
+ /**
228
+ * Withdraw consent, which deletes the mapping with it.
229
+ *
230
+ * Not two operations. "Revoking consent deletes the mapping — the mapping
231
+ * is the consent, so un-consenting unmaps" (hole 2), and a store that could
232
+ * leave one behind would leave a resolution pointing at a service the
233
+ * person no longer authorises anybody to reach.
234
+ */
235
+ revoke(input: {
236
+ siteId: string;
237
+ user: string;
238
+ }): void;
239
+ /** Add somebody to an owner's team. */
240
+ addMember(input: {
241
+ owner: string;
242
+ user: string;
243
+ }): void;
244
+ /**
245
+ * Remove somebody from an owner's team.
246
+ *
247
+ * There is no "blocked but still a member": a team **is** access to its
248
+ * owner's devices and nothing else, so member-but-blocked is not a deferred
249
+ * feature, it is an empty state (Amendment J).
250
+ */
251
+ removeMember(input: {
252
+ owner: string;
253
+ user: string;
254
+ }): void;
255
+ /**
256
+ * Say which services are up, for the tests that are about that.
257
+ *
258
+ * Calling it at all moves this store out of "cannot say" — including with
259
+ * an empty set, which is the real state of a person whose only device is
260
+ * asleep. That distinction is the whole point of the optional field, so it
261
+ * is reachable here rather than inferred from emptiness.
262
+ */
263
+ advertising(services: readonly {
264
+ owner: string;
265
+ service: string;
266
+ }[]): void;
267
+ read(input: {
268
+ siteId: string;
269
+ user: string;
270
+ owner: string;
271
+ }): Promise<PolicySnapshot>;
272
+ }
273
+
274
+ export { ControlPlane, type Decline, type DeclineReason, type GrantOutcome, type GrantSigner, Mapping, MemoryPolicyStore, PolicySnapshot, PolicyStore, keypairSigner };
package/dist/index.js ADDED
@@ -0,0 +1,251 @@
1
+ // src/engine.ts
2
+ import { randomUUID } from "crypto";
3
+ import {
4
+ RESERVED_PURPOSE
5
+ } from "@byollm/protocol";
6
+ var WIDENS = {
7
+ private: false,
8
+ team: true
9
+ };
10
+ var WIDENING_SCOPES = new Set(
11
+ Object.entries(WIDENS).filter(([, widens]) => widens).map(([scope]) => scope)
12
+ );
13
+ var ControlPlane = class {
14
+ #store;
15
+ #signer;
16
+ #now;
17
+ #newId;
18
+ constructor(options) {
19
+ this.#store = options.store;
20
+ this.#signer = options.signer;
21
+ this.#now = options.now ?? Date.now;
22
+ this.#newId = options.newGrantId ?? (() => `grant_${randomUUID()}`);
23
+ }
24
+ /** The key devices pin at pairing, from the same object that signs. */
25
+ get publicKey() {
26
+ return this.#signer.publicKey;
27
+ }
28
+ /**
29
+ * Author a grant for one claimed job, or decline with a reason.
30
+ *
31
+ * The order below is the law, and it is ordered by *whose* fact each step
32
+ * is: the device's own owner first, then the person's consent, then their
33
+ * membership, then their mapping, then this machine's ability to honour it.
34
+ * A step that fails stops the rest, so the reason returned names the first
35
+ * thing that was actually wrong rather than whichever check ran last.
36
+ */
37
+ /**
38
+ * Can this purpose be satisfied for this person, asked before a job exists.
39
+ *
40
+ * The two answers a site can act on, each decided where it is knowable and
41
+ * nowhere else. A purpose the manifest does not declare will not appear in
42
+ * it by waiting. A purpose nobody has mapped is the person's own dashboard,
43
+ * and somebody who maps it thirty seconds from now is served by the next
44
+ * job — the same thirty seconds, and it avoids the thing that must never
45
+ * happen: a job the site has already fallen back on being served afterwards.
46
+ *
47
+ * Everything else is `ok`, including every case the transient path was
48
+ * always for — declared, mapped, and nothing able to claim right now.
49
+ *
50
+ * Deliberately not a second gate at claim. This answers at enqueue, claim
51
+ * answers at claim, and a mapping revoked between the two falls to the
52
+ * transient path exactly as it does today.
53
+ */
54
+ async satisfiable(input) {
55
+ const snapshot = await this.#store.read({
56
+ siteId: input.siteId,
57
+ user: input.user,
58
+ // No device is involved yet, so the reader is the person themselves.
59
+ // `member` is a claim-time question about somebody else's machine.
60
+ owner: input.user
61
+ });
62
+ const purpose = input.purpose ?? RESERVED_PURPOSE;
63
+ if (snapshot.declares !== void 0 && !snapshot.declares.has(purpose)) {
64
+ return { verdict: "not-declared" };
65
+ }
66
+ const forSlot = snapshot.mappings.filter(
67
+ (mapping) => mapping.purpose === purpose && mapping.kind === input.kind
68
+ );
69
+ if (forSlot.length === 0) return { verdict: "unmapped" };
70
+ const advertised = snapshot.advertised;
71
+ if (advertised === void 0) return { verdict: "ok" };
72
+ const answerable = forSlot.some(
73
+ (mapping) => advertised.has(`${mapping.owner ?? input.user}\0${mapping.service}`)
74
+ );
75
+ return { verdict: answerable ? "ok" : "waiting" };
76
+ }
77
+ async authorGrant(input) {
78
+ const { job, owner } = input;
79
+ const purpose = input.purpose ?? RESERVED_PURPOSE;
80
+ let snapshot;
81
+ try {
82
+ snapshot = await this.#store.read({
83
+ // The control plane's own id: the store is keyed by it, and the key
84
+ // id below is for the device. Two ids, two readers, neither
85
+ // substitutable for the other.
86
+ siteId: input.siteId,
87
+ user: job.owner,
88
+ owner
89
+ });
90
+ } catch {
91
+ return decline("store-unavailable");
92
+ }
93
+ if (snapshot.consented === "no") return decline("not-consented");
94
+ if (snapshot.consented === "paused") return decline("consent-paused");
95
+ if (job.owner !== owner && !snapshot.member) return decline("not-a-member");
96
+ const mapped = snapshot.mappings.find(
97
+ (mapping) => mapping.purpose === purpose && mapping.kind === job.kind
98
+ );
99
+ if (!mapped) return decline("unmapped");
100
+ if ((mapped.owner ?? job.owner) !== owner) {
101
+ return decline("resolved-elsewhere");
102
+ }
103
+ const offered = input.capabilities.some(
104
+ (capability) => capability.kind === job.kind && capability.service === mapped.service && (job.owner === owner || WIDENING_SCOPES.has(capability.offerScope))
105
+ );
106
+ if (!offered) return decline("resolved-elsewhere");
107
+ return {
108
+ granted: await this.#signer.sign({
109
+ grantId: this.#newId(),
110
+ jobId: job.id,
111
+ site: input.siteKey,
112
+ user: job.owner,
113
+ owner,
114
+ purpose,
115
+ kind: job.kind,
116
+ service: mapped.service,
117
+ issuedAt: this.#now()
118
+ })
119
+ };
120
+ }
121
+ };
122
+ var PERMANENT = /* @__PURE__ */ new Set([
123
+ "not-a-member",
124
+ "not-consented"
125
+ ]);
126
+ function decline(reason) {
127
+ return { declined: { reason, permanent: PERMANENT.has(reason) } };
128
+ }
129
+
130
+ // src/memory.ts
131
+ var MemoryPolicyStore = class {
132
+ /** (siteId, user) to what that person authorised for that site. */
133
+ #consents = /* @__PURE__ */ new Map();
134
+ /** (siteId, user) pairs whose owner has paused them. */
135
+ #paused = /* @__PURE__ */ new Set();
136
+ /** owner to the people who may use their devices. */
137
+ #members = /* @__PURE__ */ new Map();
138
+ /**
139
+ * Services being advertised right now, as `owner\u0000service`.
140
+ *
141
+ * `undefined` until somebody says otherwise, which is the reference store's
142
+ * way of saying it has no presence to read — the state every store was in
143
+ * before 019, and the one that must leave every mapped slot satisfiable.
144
+ */
145
+ #advertised;
146
+ /**
147
+ * Record a consent, with the mapping it carries.
148
+ *
149
+ * One call because they are one act: the mapping **is** the consent
150
+ * (Amendment L), and a store that let them be written separately would
151
+ * allow a state — consented, no mappings, no way to have got there — that
152
+ * the product cannot produce.
153
+ *
154
+ * Consenting with an empty list is the real signup state, though: a person
155
+ * whose slots all had two candidates and who has not chosen yet.
156
+ */
157
+ consent(input) {
158
+ this.#consents.set(key(input.siteId, input.user), [
159
+ ...input.mappings ?? []
160
+ ]);
161
+ this.#paused.delete(key(input.siteId, input.user));
162
+ }
163
+ /**
164
+ * Pause a consent without withdrawing it.
165
+ *
166
+ * A real product state and not the same as revoking: the mapping survives,
167
+ * so resuming does not ask somebody to author their choices again. What it
168
+ * must not do is let work through — a relay's projection can lag by
169
+ * seconds, and a grant authored in that window would run work whose owner
170
+ * had just stopped it.
171
+ */
172
+ pause(input) {
173
+ this.#paused.add(key(input.siteId, input.user));
174
+ }
175
+ /** Let it move again. */
176
+ resume(input) {
177
+ this.#paused.delete(key(input.siteId, input.user));
178
+ }
179
+ /**
180
+ * Withdraw consent, which deletes the mapping with it.
181
+ *
182
+ * Not two operations. "Revoking consent deletes the mapping — the mapping
183
+ * is the consent, so un-consenting unmaps" (hole 2), and a store that could
184
+ * leave one behind would leave a resolution pointing at a service the
185
+ * person no longer authorises anybody to reach.
186
+ */
187
+ revoke(input) {
188
+ this.#consents.delete(key(input.siteId, input.user));
189
+ this.#paused.delete(key(input.siteId, input.user));
190
+ }
191
+ /** Add somebody to an owner's team. */
192
+ addMember(input) {
193
+ const members = this.#members.get(input.owner) ?? /* @__PURE__ */ new Set();
194
+ members.add(input.user);
195
+ this.#members.set(input.owner, members);
196
+ }
197
+ /**
198
+ * Remove somebody from an owner's team.
199
+ *
200
+ * There is no "blocked but still a member": a team **is** access to its
201
+ * owner's devices and nothing else, so member-but-blocked is not a deferred
202
+ * feature, it is an empty state (Amendment J).
203
+ */
204
+ removeMember(input) {
205
+ this.#members.get(input.owner)?.delete(input.user);
206
+ }
207
+ /**
208
+ * Say which services are up, for the tests that are about that.
209
+ *
210
+ * Calling it at all moves this store out of "cannot say" — including with
211
+ * an empty set, which is the real state of a person whose only device is
212
+ * asleep. That distinction is the whole point of the optional field, so it
213
+ * is reachable here rather than inferred from emptiness.
214
+ */
215
+ advertising(services) {
216
+ this.#advertised = new Set(
217
+ services.map((s) => `${s.owner}\0${s.service}`)
218
+ );
219
+ }
220
+ read(input) {
221
+ const id = key(input.siteId, input.user);
222
+ const mappings = this.#consents.get(id);
223
+ return Promise.resolve({
224
+ // The three states the reference store can be in, told apart rather
225
+ // than collapsed: a pause is somebody's own to lift, and the engine
226
+ // needs to know that before it decides whether a refusal is forever.
227
+ consented: mappings === void 0 ? "no" : this.#paused.has(id) ? "paused" : "yes",
228
+ member: this.#members.get(input.owner)?.has(input.user) ?? false,
229
+ mappings: mappings ?? [],
230
+ ...this.#advertised === void 0 ? {} : { advertised: this.#advertised }
231
+ });
232
+ }
233
+ };
234
+ var key = (siteId, user) => `${siteId}\0${user}`;
235
+
236
+ // src/signer.ts
237
+ import {
238
+ signGrant
239
+ } from "@byollm/protocol";
240
+ function keypairSigner(keys) {
241
+ return {
242
+ publicKey: keys.identityPublic,
243
+ sign: (claims) => signGrant(keys, claims)
244
+ };
245
+ }
246
+ export {
247
+ ControlPlane,
248
+ MemoryPolicyStore,
249
+ keypairSigner
250
+ };
251
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/engine.ts","../src/memory.ts","../src/signer.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport {\n RESERVED_PURPOSE,\n type CapabilityMatrix,\n type ClaimedStub,\n type OfferScope,\n type SignedGrant,\n} from \"@byollm/protocol\";\nimport type { GrantSigner } from \"./signer.js\";\nimport type { PolicyStore } from \"./store.js\";\n\n/**\n * Which offer scopes reach somebody other than the owner.\n *\n * An allowlist, never a denylist. A filter written against the excluded case\n * fails **open** the moment somebody renames the excluded value — which is\n * exactly what happened when `self` became `private` and every owner-locked\n * route was listed to a whole team. A scope this build has not been taught\n * stays narrow.\n *\n * Exhaustive over the protocol's vocabulary, so a scope added upstream fails\n * this file to compile until somebody decides whether it widens.\n */\nconst WIDENS: Readonly<Record<OfferScope, boolean>> = {\n private: false,\n team: true,\n};\nconst WIDENING_SCOPES: ReadonlySet<string> = new Set(\n Object.entries(WIDENS)\n .filter(([, widens]) => widens)\n .map(([scope]) => scope),\n);\n\n/**\n * The control plane: one signed grant per job, or a reason there is none.\n *\n * byollm_016 Amendment J and L. This is the open engine behind a relay's\n * `authorGrant` seam. It reads policy from a {@link PolicyStore} it does not\n * own and signs with a {@link GrantSigner} whose key it never sees, so the\n * two things byollm.cloud keeps — the data and the key — sit outside it, and\n * the rule over them is readable by anybody.\n *\n * ## One routes, one authorises, only one signs\n *\n * A relay already filters what it offers a device, by consent and by\n * membership. **That filter is an optimisation and this is the authority.**\n * Everything is checked again here, because the relay's projection can be\n * stale and because a grant asserting \"consented, member, admitted\" must be\n * true when it is signed rather than when something upstream last looked. If\n * the two disagree, this one wins and refuses.\n */\nexport class ControlPlane {\n readonly #store: PolicyStore;\n readonly #signer: GrantSigner;\n readonly #now: () => number;\n readonly #newId: () => string;\n\n constructor(options: {\n readonly store: PolicyStore;\n readonly signer: GrantSigner;\n /** Injectable clock, so tests move time instead of sleeping. */\n readonly now?: () => number;\n /** Injectable id source, so a test can assert what was signed. */\n readonly newGrantId?: () => string;\n }) {\n this.#store = options.store;\n this.#signer = options.signer;\n this.#now = options.now ?? Date.now;\n this.#newId = options.newGrantId ?? (() => `grant_${randomUUID()}`);\n }\n\n /** The key devices pin at pairing, from the same object that signs. */\n get publicKey(): string {\n return this.#signer.publicKey;\n }\n\n /**\n * Author a grant for one claimed job, or decline with a reason.\n *\n * The order below is the law, and it is ordered by *whose* fact each step\n * is: the device's own owner first, then the person's consent, then their\n * membership, then their mapping, then this machine's ability to honour it.\n * A step that fails stops the rest, so the reason returned names the first\n * thing that was actually wrong rather than whichever check ran last.\n */\n /**\n * Can this purpose be satisfied for this person, asked before a job exists.\n *\n * The two answers a site can act on, each decided where it is knowable and\n * nowhere else. A purpose the manifest does not declare will not appear in\n * it by waiting. A purpose nobody has mapped is the person's own dashboard,\n * and somebody who maps it thirty seconds from now is served by the next\n * job — the same thirty seconds, and it avoids the thing that must never\n * happen: a job the site has already fallen back on being served afterwards.\n *\n * Everything else is `ok`, including every case the transient path was\n * always for — declared, mapped, and nothing able to claim right now.\n *\n * Deliberately not a second gate at claim. This answers at enqueue, claim\n * answers at claim, and a mapping revoked between the two falls to the\n * transient path exactly as it does today.\n */\n async satisfiable(input: {\n readonly siteId: string;\n readonly user: string;\n readonly purpose: string | undefined;\n readonly kind: string;\n }): Promise<{ verdict: \"ok\" | \"not-declared\" | \"unmapped\" | \"waiting\" }> {\n const snapshot = await this.#store.read({\n siteId: input.siteId,\n user: input.user,\n // No device is involved yet, so the reader is the person themselves.\n // `member` is a claim-time question about somebody else's machine.\n owner: input.user,\n });\n\n const purpose = input.purpose ?? RESERVED_PURPOSE;\n\n // A store that cannot say what a site declares must not make every purpose\n // undeclared, and a site with no manifest declares everything — the\n // implicit-`default` reading the consent screen already uses, and the one\n // a stricter reading once broke by refusing a write with no surface.\n if (snapshot.declares !== undefined && !snapshot.declares.has(purpose)) {\n return { verdict: \"not-declared\" };\n }\n\n const forSlot = snapshot.mappings.filter(\n (mapping) => mapping.purpose === purpose && mapping.kind === input.kind,\n );\n if (forSlot.length === 0) return { verdict: \"unmapped\" };\n\n /**\n * Mapped, and nothing that could answer it is there — 019 §3.3.\n *\n * The gap this closes: a service healthy this morning and quota-blocked\n * at 2pm was still mapped, so the slot read satisfiable, the job was\n * queued, and the site learned nothing until the TTL expired. The\n * fallback could not fire because nothing had told the site to fall back.\n *\n * A daemon withdraws a blocked service, so it stops being advertised, and\n * that is the whole mechanism — no new field on the wire, and device\n * offline, service unhealthy and account blocked all arrive as the same\n * absence, which is exactly the bit a site is allowed to learn.\n *\n * **Absent means unknown, and unknown is not a refusal.** A store that\n * cannot see presence leaves this undefined and every mapped slot stays\n * satisfiable — the status quo, which is what this must fail toward. The\n * projection also refreshes on its own schedule, so a service blocked\n * thirty seconds ago may still read advertised; that job takes the old\n * slow path, and closing the common case without the edge is the trade\n * §6.2 rules for.\n */\n const advertised = snapshot.advertised;\n if (advertised === undefined) return { verdict: \"ok\" };\n const answerable = forSlot.some((mapping) =>\n advertised.has(`${mapping.owner ?? input.user}\\u0000${mapping.service}`),\n );\n return { verdict: answerable ? \"ok\" : \"waiting\" };\n }\n\n async authorGrant(input: {\n readonly job: ClaimedStub;\n /** The site's id in this control plane's namespace, for the policy read. */\n readonly siteId: string;\n /**\n * The same site, as the key id the device pinned — the value that gets\n * signed.\n *\n * Two ids for one site, and both are needed: the store is keyed by the\n * control plane's own id, and the device knows sites only by what it\n * pinned. The grant carries the one the device can check without asking\n * anybody, which is the whole point of a signed document.\n */\n readonly siteKey: string;\n /** The device owner asking. */\n readonly owner: string;\n /**\n * Which of the site's declared purposes this job serves.\n *\n * Supplied by the caller rather than read off the job, because in the\n * release that built this engine the stub does not carry one yet — every\n * job is the site's {@link RESERVED_PURPOSE}. When purposes reach the\n * wire, the caller passes the job's own and nothing here changes.\n */\n readonly purpose?: string;\n /**\n * What this device advertised it can serve.\n *\n * The control plane resolves a mapping to a service **from what the\n * device said**, never from a name it invented. A mapping naming\n * something this device does not offer is not honoured here — see\n * `resolved-elsewhere`.\n */\n readonly capabilities: CapabilityMatrix;\n }): Promise<GrantOutcome> {\n const { job, owner } = input;\n const purpose = input.purpose ?? RESERVED_PURPOSE;\n\n let snapshot;\n try {\n snapshot = await this.#store.read({\n // The control plane's own id: the store is keyed by it, and the key\n // id below is for the device. Two ids, two readers, neither\n // substitutable for the other.\n siteId: input.siteId,\n user: job.owner,\n owner,\n });\n } catch {\n /**\n * A store that could not answer is not a refusal.\n *\n * Failing closed is right — nothing is signed — but the *shape* of the\n * failure matters more than it looks. A permanent refusal here would\n * mean a database blip permanently unpicked a job from a device, and\n * nothing would ever put it back. Transient, therefore, and the job\n * goes back in the queue.\n */\n return decline(\"store-unavailable\");\n }\n\n /**\n * Refused, or asked again later — and the difference is the whole point.\n *\n * `not-consented` is permanent: never offer this job to this device\n * again. That is right for revoked and for never-authorised, because\n * somebody decided it and a queued job cannot outlive the decision.\n *\n * A **pause** is not a decision of that kind. The hosted product pauses a\n * consent the moment its author joins a team — no row changes — and the\n * remedy is theirs: read the sentence they have not read. Marking those\n * jobs permanently refused meant that by the time they re-consented,\n * every device that had claimed during the window would never offer them\n * again, and nothing anywhere said so.\n */\n if (snapshot.consented === \"no\") return decline(\"not-consented\");\n if (snapshot.consented === \"paused\") return decline(\"consent-paused\");\n\n /**\n * A device always runs its own owner's work, and no store is asked.\n *\n * Not an optimisation. Routing this through the store would put a law\n * somewhere an implementation could get wrong — including by answering\n * `member: false` for somebody's own account and silently stopping their\n * own device. There is no answer a store could give that should change\n * this, so it is not asked the question.\n */\n if (job.owner !== owner && !snapshot.member) return decline(\"not-a-member\");\n\n const mapped = snapshot.mappings.find(\n (mapping) => mapping.purpose === purpose && mapping.kind === job.kind,\n );\n /**\n * An unmapped slot makes a purpose unavailable, never a site broken.\n *\n * Transient, because the remedy is the user's and they may take it: a\n * mapping authored a minute from now should let this job run, and a\n * permanent refusal would have quietly excluded the one device it was\n * about to point at.\n */\n if (!mapped) return decline(\"unmapped\");\n\n /**\n * The device has to actually offer it, for this kind.\n *\n * Two different situations arrive here and this vantage cannot tell them\n * apart: the mapping resolved to a *different* device of this owner's, or\n * its referent is gone entirely — a service renamed or deleted. From one\n * device's capabilities both look identical, so the name says only what\n * is knowable here.\n *\n * Whether it is *no* device is a question for a sweep across the owner's\n * services, which is where the \"your mapping needs updating\" notification\n * belongs — not on the claim path, which sees one machine at a time.\n */\n /**\n * Whose machine, before which service.\n *\n * A service id means something only inside its owner's namespace, so a\n * mapping naming a teammate's `qwen` must not be satisfied by a different\n * teammate's `qwen` — or by the mapper's own. Checked before the\n * capability list, because a device that merely shares a name is not\n * \"the wrong service\" but the wrong machine, and the capabilities of the\n * wrong machine say nothing either way.\n */\n if ((mapped.owner ?? job.owner) !== owner) {\n return decline(\"resolved-elsewhere\");\n }\n\n /**\n * Offered to *this person*, not merely present on the machine.\n *\n * byollm-review 2026-08-27. This asked only whether a capability row with\n * that kind and service existed, and every row carries an `offerScope` it\n * never looked at. So a mapping naming a service its owner has since\n * narrowed to `private` was granted: the engine signed a document\n * asserting a teammate's admission onto a service offered to nobody.\n *\n * Nothing widened — the device's private-is-absolute check is structural\n * and refuses it. But the *shape* of the failure was wrong, and that is\n * the real defect. A device's refusal releases the job as `refused`,\n * which the upstream remembers permanently; the engine declining\n * `resolved-elsewhere` is a thirty-second wait. So an owner flipping\n * `qwen` to private for a minute permanently unpicked a teammate's queued\n * job from the one device it was always meant for, with nothing anywhere\n * reporting why — precisely what the transient-decline machinery exists\n * to prevent.\n *\n * The owner's own work is exempt structurally rather than by scope: a\n * device always runs its owner's jobs, `private` is a statement about\n * other people, and consulting the scope here would let the narrowest\n * setting stop somebody's own machine from serving them.\n */\n const offered = input.capabilities.some(\n (capability) =>\n capability.kind === job.kind &&\n capability.service === mapped.service &&\n (job.owner === owner || WIDENING_SCOPES.has(capability.offerScope)),\n );\n if (!offered) return decline(\"resolved-elsewhere\");\n\n return {\n granted: await this.#signer.sign({\n grantId: this.#newId(),\n jobId: job.id,\n site: input.siteKey,\n user: job.owner,\n owner,\n purpose,\n kind: job.kind,\n service: mapped.service,\n issuedAt: this.#now(),\n }),\n };\n }\n}\n\n/**\n * Why no grant was authored — and, load-bearing, whether that is forever.\n *\n * The distinction exists because a relay releases a declined job, and a\n * release can carry `refused`, which means *never offer this job to this\n * device again*. Getting that wrong in the permissive direction is a\n * claim-refuse loop; getting it wrong in the strict direction is a job that\n * can never reach the machine it was always meant for, and no error anywhere.\n */\nexport type DeclineReason =\n /** This person may not use this owner's devices. */\n | \"not-a-member\"\n /** This person has not authorised this site. */\n | \"not-consented\"\n /**\n * Authorised, and temporarily not routing — byollm-review 2026-08-27.\n *\n * Transient by the same test every reason here is judged by: the person can\n * lift it themselves, so the job must still be waiting when they do.\n */\n | \"consent-paused\"\n /** They authorised it and left this slot empty. */\n | \"unmapped\"\n /** Their mapping names a service this device does not offer. */\n | \"resolved-elsewhere\"\n /** The policy store could not answer. Says nothing about the job. */\n | \"store-unavailable\";\n\nexport interface Decline {\n readonly reason: DeclineReason;\n /**\n * Never offer this job to this device again.\n *\n * True only for the two facts that a queued job cannot outlive: a person\n * removed from a team, and consent withdrawn. Hole 1 ruled that removal\n * stops future claims *including queued ones*, and this is where that is\n * enforced.\n *\n * Everything else is a state the user can change, or a fault of ours. A\n * permanent mark on those would outlive the condition that caused it.\n */\n readonly permanent: boolean;\n}\n\nexport type GrantOutcome =\n | { readonly granted: SignedGrant; readonly declined?: undefined }\n | { readonly granted?: undefined; readonly declined: Decline };\n\nconst PERMANENT: ReadonlySet<DeclineReason> = new Set([\n \"not-a-member\",\n \"not-consented\",\n]);\n\nfunction decline(reason: DeclineReason): GrantOutcome {\n return { declined: { reason, permanent: PERMANENT.has(reason) } };\n}\n","import type { Mapping, PolicySnapshot, PolicyStore } from \"./store.js\";\n\n/**\n * A policy store in a `Map`, for tests and for a single-process self-hoster.\n *\n * It is the reference implementation in the sense that matters: the contract\n * suite runs against it, so it is the thing anybody else's store is compared\n * to. It is not a suggestion about how to build one — a real store is a\n * database, and this one forgets everything when the process ends.\n */\nexport class MemoryPolicyStore implements PolicyStore {\n /** (siteId, user) to what that person authorised for that site. */\n readonly #consents = new Map<string, Mapping[]>();\n /** (siteId, user) pairs whose owner has paused them. */\n readonly #paused = new Set<string>();\n /** owner to the people who may use their devices. */\n readonly #members = new Map<string, Set<string>>();\n /**\n * Services being advertised right now, as `owner\\u0000service`.\n *\n * `undefined` until somebody says otherwise, which is the reference store's\n * way of saying it has no presence to read — the state every store was in\n * before 019, and the one that must leave every mapped slot satisfiable.\n */\n #advertised: Set<string> | undefined;\n\n /**\n * Record a consent, with the mapping it carries.\n *\n * One call because they are one act: the mapping **is** the consent\n * (Amendment L), and a store that let them be written separately would\n * allow a state — consented, no mappings, no way to have got there — that\n * the product cannot produce.\n *\n * Consenting with an empty list is the real signup state, though: a person\n * whose slots all had two candidates and who has not chosen yet.\n */\n consent(input: {\n siteId: string;\n user: string;\n mappings?: readonly Mapping[];\n }): void {\n this.#consents.set(key(input.siteId, input.user), [\n ...(input.mappings ?? []),\n ]);\n this.#paused.delete(key(input.siteId, input.user));\n }\n\n /**\n * Pause a consent without withdrawing it.\n *\n * A real product state and not the same as revoking: the mapping survives,\n * so resuming does not ask somebody to author their choices again. What it\n * must not do is let work through — a relay's projection can lag by\n * seconds, and a grant authored in that window would run work whose owner\n * had just stopped it.\n */\n pause(input: { siteId: string; user: string }): void {\n this.#paused.add(key(input.siteId, input.user));\n }\n\n /** Let it move again. */\n resume(input: { siteId: string; user: string }): void {\n this.#paused.delete(key(input.siteId, input.user));\n }\n\n /**\n * Withdraw consent, which deletes the mapping with it.\n *\n * Not two operations. \"Revoking consent deletes the mapping — the mapping\n * is the consent, so un-consenting unmaps\" (hole 2), and a store that could\n * leave one behind would leave a resolution pointing at a service the\n * person no longer authorises anybody to reach.\n */\n revoke(input: { siteId: string; user: string }): void {\n this.#consents.delete(key(input.siteId, input.user));\n this.#paused.delete(key(input.siteId, input.user));\n }\n\n /** Add somebody to an owner's team. */\n addMember(input: { owner: string; user: string }): void {\n const members = this.#members.get(input.owner) ?? new Set<string>();\n members.add(input.user);\n this.#members.set(input.owner, members);\n }\n\n /**\n * Remove somebody from an owner's team.\n *\n * There is no \"blocked but still a member\": a team **is** access to its\n * owner's devices and nothing else, so member-but-blocked is not a deferred\n * feature, it is an empty state (Amendment J).\n */\n removeMember(input: { owner: string; user: string }): void {\n this.#members.get(input.owner)?.delete(input.user);\n }\n\n /**\n * Say which services are up, for the tests that are about that.\n *\n * Calling it at all moves this store out of \"cannot say\" — including with\n * an empty set, which is the real state of a person whose only device is\n * asleep. That distinction is the whole point of the optional field, so it\n * is reachable here rather than inferred from emptiness.\n */\n advertising(services: readonly { owner: string; service: string }[]): void {\n this.#advertised = new Set(\n services.map((s) => `${s.owner}\\u0000${s.service}`),\n );\n }\n\n read(input: {\n siteId: string;\n user: string;\n owner: string;\n }): Promise<PolicySnapshot> {\n const id = key(input.siteId, input.user);\n const mappings = this.#consents.get(id);\n return Promise.resolve({\n // The three states the reference store can be in, told apart rather\n // than collapsed: a pause is somebody's own to lift, and the engine\n // needs to know that before it decides whether a refusal is forever.\n consented:\n mappings === undefined ? \"no\" : this.#paused.has(id) ? \"paused\" : \"yes\",\n member: this.#members.get(input.owner)?.has(input.user) ?? false,\n mappings: mappings ?? [],\n ...(this.#advertised === undefined\n ? {}\n : { advertised: this.#advertised }),\n });\n }\n}\n\n/**\n * NUL-joined, for the reason `routeKey` is.\n *\n * A composite key is a parser waiting to meet an id containing its separator.\n * NUL cannot appear in either half, so no arrangement of a site id and a user\n * id can spell a different pair.\n */\nconst key = (siteId: string, user: string): string => `${siteId}\\u0000${user}`;\n","import {\n signGrant,\n type GrantClaims,\n type SignedGrant,\n type StoredKeys,\n} from \"@byollm/protocol\";\n\n/**\n * Whatever holds the key that grants are signed with.\n *\n * A function rather than a key, so custody never enters this package. A\n * self-hoster hands over a keypair; a hosted deployment can put the private\n * half behind a KMS and pass a signer that calls it, and the engine cannot\n * tell the difference — which is the point. Custody of the signing key is one\n * of the two things byollm.cloud keeps, and an engine that had to be given\n * the key could not be run any other way.\n *\n * `publicKey` sits on the same object deliberately. It is the value a relay\n * hands a device at pairing, and it is the value that must verify what\n * `sign` produces. Two separate configuration items would be two things a\n * deployment could set inconsistently — and the failure mode is a fleet that\n * refuses every job while every process reports itself healthy.\n */\nexport interface GrantSigner {\n /** The public half, for a relay to hand devices at pairing. */\n readonly publicKey: string;\n sign(claims: GrantClaims): Promise<SignedGrant> | SignedGrant;\n}\n\n/**\n * Sign with a keypair this process holds.\n *\n * The implementation a self-hoster wants, and the one the tests use. A hosted\n * deployment writes its own against whatever holds its key.\n */\nexport function keypairSigner(\n keys: Pick<StoredKeys, \"identityPrivate\" | \"identityPublic\">,\n): GrantSigner {\n return {\n publicKey: keys.identityPublic,\n sign: (claims) => signGrant(keys, claims),\n };\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,171 @@
1
+ /**
2
+ * What the control plane reads, and never owns.
3
+ *
4
+ * byollm_016 Amendment L. Every fact the engine needs to author one grant —
5
+ * whether a person consented to a site, whether they may use somebody's
6
+ * devices, and which of that owner's services their mapping resolves to —
7
+ * comes from here. The engine holds none of it.
8
+ *
9
+ * ## Why this is an interface rather than a table
10
+ *
11
+ * The policy store is where accounts, consents, memberships, mappings and
12
+ * billing live, and it is the part of byollm.cloud that stays proprietary.
13
+ * The *law* over that data is not: it is the engine, and it is open, so a
14
+ * self-hoster runs the same resolution against their own store and a reader
15
+ * can check what the hosted product does with theirs.
16
+ *
17
+ * The split is drawn where it is because these are genuinely different kinds
18
+ * of thing. Who is on whose team is somebody's data. What that membership
19
+ * *entitles* is a rule, and a rule nobody can read is a rule nobody can
20
+ * check.
21
+ *
22
+ * ## The contract
23
+ *
24
+ * `@byollm/control-plane/store-contract` is a suite any implementation can
25
+ * run against itself. It ships in the package for the reason the routing
26
+ * store's does: the implementation that matters most lives in another
27
+ * repository, and a contract only its author can run is a description.
28
+ */
29
+ /**
30
+ * One slot a user has filled: this site's purpose, for this kind, runs there.
31
+ *
32
+ * Keyed by (purpose, kind) because a purpose may span kinds — "writing
33
+ * assistant" might use both `llm.chat` and `llm.generate` — and a person may
34
+ * reasonably want different services behind them.
35
+ *
36
+ * `service` is a service id in the **device owner's** namespace, which is why
37
+ * a mapping is only meaningful alongside the owner it was authored against.
38
+ */
39
+ interface Mapping {
40
+ readonly purpose: string;
41
+ readonly kind: string;
42
+ /** The service id, in {@link Mapping.owner}'s namespace. */
43
+ readonly service: string;
44
+ /**
45
+ * Whose service it is. `null` means the mapper's own.
46
+ *
47
+ * **Not optional decoration.** Service ids are namespace-local: alice may
48
+ * be on two teams that both have a service called `qwen`, and the consent
49
+ * screen shows them as the two different choices they are. A mapping that
50
+ * stored only the name could not tell them apart — so her work would be
51
+ * admitted on whichever of those machines claimed it first, which is
52
+ * exactly the substitution this design exists to forbid.
53
+ *
54
+ * The first draft of this interface had only the name, and the consent
55
+ * screen's own test — "keeps a teammate's identical service name separate
56
+ * from your own" — was already asserting the distinction that the storage
57
+ * could not keep.
58
+ */
59
+ readonly owner: string | null;
60
+ }
61
+ /**
62
+ * Everything the engine needs about one (site, user, owner) triple.
63
+ *
64
+ * One read rather than three, because this happens on every claim and a
65
+ * control plane that made three round trips per job would be the reason
66
+ * somebody ran their own.
67
+ */
68
+ interface PolicySnapshot {
69
+ /**
70
+ * May this user's work move for this site **right now**, and if not, is
71
+ * that reversible?
72
+ *
73
+ * This was a boolean, and the collapse was defended: never authorised,
74
+ * revoked, and *paused* are different to a person and identical to a grant.
75
+ * True of the grant, and the engine does not only decide grant-or-not — it
76
+ * decides whether the refusal is **permanent**, and the three states are
77
+ * emphatically not identical there.
78
+ *
79
+ * byollm-review 2026-08-27. `decline("not-consented")` is in the permanent
80
+ * set, and the relay releases a permanent decline as `refused`: never offer
81
+ * this job to this device again. A pause is the hosted product's own
82
+ * everyday path — a consent auto-pauses the moment its author joins a team,
83
+ * with no row changing — so somebody's queued work was permanently unpicked
84
+ * from every device that claimed during the window, and re-consenting
85
+ * minutes later did not bring it back. The engine's own law, stated two
86
+ * lines above its permanence table: a permanent mark must not outlive the
87
+ * condition that caused it.
88
+ *
89
+ * So the states that differ in remedy are told apart:
90
+ *
91
+ * - `"yes"` — authorised, and nothing is in the way.
92
+ * - `"paused"` — authorised, and temporarily not routing. The person can
93
+ * lift it themselves by reading the sentence they have not read; the job
94
+ * must still be there when they do.
95
+ * - `"no"` — never authorised, or revoked. Somebody decided this, and a
96
+ * queued job cannot outlive that decision.
97
+ *
98
+ * Distinct from having mappings, which is a different real state: a person
99
+ * can consent and leave a slot unmapped, because it had two candidates and
100
+ * they have not chosen. "Not authorised" and "authorised, this slot empty"
101
+ * send them to different places, so the engine is told which.
102
+ */
103
+ readonly consented: "yes" | "paused" | "no";
104
+ /**
105
+ * May this user's work run on this owner's devices?
106
+ *
107
+ * The store answers for other people. It is **not** consulted for the
108
+ * owner's own work — see {@link PolicyStore.read}.
109
+ */
110
+ readonly member: boolean;
111
+ /** This user's mappings for this site. Order is not meaningful. */
112
+ readonly mappings: readonly Mapping[];
113
+ /**
114
+ * The purpose keys this site declares, when the store can say.
115
+ *
116
+ * `undefined` means "this store does not answer that", which is not the
117
+ * same as "declares nothing" — a store that could not tell us must not make
118
+ * every purpose undeclared. A site with no manifest declares everything
119
+ * implicitly, which is the reading the consent screen and the engine
120
+ * already share.
121
+ */
122
+ readonly declares?: ReadonlySet<string> | undefined;
123
+ /**
124
+ * Which of this person's services are being advertised right now — 019 §3.3.
125
+ *
126
+ * Keys are `owner\u0000service`, matching {@link Mapping.owner} and
127
+ * {@link Mapping.service}, with the mapper's own services under their own
128
+ * user id rather than `null` — a set cannot hold "whoever asked".
129
+ *
130
+ * This is how a quota block reaches the enqueue question without a new wire
131
+ * field. A daemon withdraws a blocked service, so it simply stops appearing
132
+ * in what the device advertises, and the fact arrives through the presence
133
+ * the hub already keeps. Device offline, service unhealthy and account
134
+ * blocked all look identical here, which is deliberate: they are one bit
135
+ * about the slot's future, not three facts about somebody's day.
136
+ *
137
+ * **`undefined` means "cannot say", and must never brake.** A store with no
138
+ * presence to read — and every store that predates this — leaves it absent,
139
+ * and a slot with a mapping stays satisfiable exactly as it does today. The
140
+ * same discipline {@link PolicyStoreSnapshot.declares} states, for the same
141
+ * reason: a fact we could not read is not a negative answer.
142
+ */
143
+ readonly advertised?: ReadonlySet<string> | undefined;
144
+ }
145
+ interface PolicyStore {
146
+ /**
147
+ * Read the policy for one job's worth of question.
148
+ *
149
+ * **`owner === user` is not asked about here.** A device always runs its
150
+ * own owner's work, and routing that through a store would put a law in a
151
+ * place any implementation could get wrong — including by returning
152
+ * `member: false` for somebody's own account and quietly stopping their
153
+ * device. The engine short-circuits it; this method is only ever asked
154
+ * about other people.
155
+ *
156
+ * Returning a snapshot with `consented: "no"` is the ordinary answer for a
157
+ * site this user has never authorised. Throwing is for a store that could
158
+ * not answer, which is a different thing and must not be turned into a
159
+ * refusal — see the engine's failure handling.
160
+ */
161
+ read(input: {
162
+ /** The site's id in the control plane's namespace, not its key id. */
163
+ readonly siteId: string;
164
+ /** Whose job it is. */
165
+ readonly user: string;
166
+ /** Whose device is asking. */
167
+ readonly owner: string;
168
+ }): Promise<PolicySnapshot>;
169
+ }
170
+
171
+ export type { Mapping as M, PolicyStore as P, PolicySnapshot as a };
@@ -0,0 +1,81 @@
1
+ import { P as PolicyStore, M as Mapping } from './store-0clah5RQ.js';
2
+
3
+ /**
4
+ * The policy store's behaviour, written once and run against every
5
+ * implementation.
6
+ *
7
+ * The same reasoning as `@byollm/relay/store-contract`, and more of it. The
8
+ * implementation that matters most is byollm.cloud's, in another repository
9
+ * and not open — so this is the only way a reader can know that the hosted
10
+ * product's store answers the same questions the same way the reference one
11
+ * does. A contract only its author can run is a description.
12
+ *
13
+ * ## What a store has to do to be tested
14
+ *
15
+ * `PolicyStore` has one method, and it reads. Everything that *writes* —
16
+ * consenting, mapping, adding somebody to a team — is the deployment's own
17
+ * surface, and no two will agree on it. So the contract takes a small harness
18
+ * that performs those acts however the implementation performs them, and
19
+ * asserts only what `read` must then answer.
20
+ *
21
+ * That is the honest boundary: this proves the store's *answers*, not its
22
+ * admin API. A store that passes has the semantics the engine relies on.
23
+ */
24
+ interface PolicyStoreContractOptions {
25
+ /**
26
+ * A fresh, empty store, its write surface, and how to dispose of it.
27
+ *
28
+ * Arrow-typed rather than methods, for the reason the routing store's
29
+ * contract gives: a method signature lets `this` travel with a call read
30
+ * off an options object, which is exactly where that goes wrong.
31
+ */
32
+ readonly make: () => Promise<{
33
+ store: PolicyStore;
34
+ consent: (input: {
35
+ siteId: string;
36
+ user: string;
37
+ mappings?: readonly Mapping[];
38
+ }) => Promise<void> | void;
39
+ revoke: (input: {
40
+ siteId: string;
41
+ user: string;
42
+ }) => Promise<void> | void;
43
+ /**
44
+ * Pause a consent without withdrawing it.
45
+ *
46
+ * Optional because not every deployment offers pausing. A store that does
47
+ * not is not asked to prove it — but one that does must prove a paused
48
+ * consent reads as not-consented, which is the case below.
49
+ */
50
+ pause?: (input: {
51
+ siteId: string;
52
+ user: string;
53
+ }) => Promise<void> | void;
54
+ addMember: (input: {
55
+ owner: string;
56
+ user: string;
57
+ }) => Promise<void> | void;
58
+ removeMember: (input: {
59
+ owner: string;
60
+ user: string;
61
+ }) => Promise<void> | void;
62
+ done: () => Promise<void>;
63
+ }>;
64
+ /**
65
+ * Whether this store accepts arbitrary strings as ids.
66
+ *
67
+ * True for a store that keys by concatenation, where two ids differing only
68
+ * in where a separator falls is a real hazard and the case below is the
69
+ * point. False for one with typed columns — a Postgres `uuid` cannot hold
70
+ * `"b:c"`, so the confusion is structurally impossible and demanding the
71
+ * case would only stop that store running the contract at all.
72
+ *
73
+ * Named rather than skipped silently: an implementation that opts out is
74
+ * saying its ids are typed, which is a claim about its schema.
75
+ */
76
+ readonly opaqueIds?: boolean;
77
+ }
78
+ /** Run the contract. Call inside a suite; it declares its own `describe`. */
79
+ declare function describePolicyStoreContract(name: string, options: PolicyStoreContractOptions): void;
80
+
81
+ export { type PolicyStoreContractOptions, describePolicyStoreContract };
@@ -0,0 +1,220 @@
1
+ // src/store-contract.ts
2
+ import { describe, expect, it } from "vitest";
3
+ var SITE = "5cbc8f4c-96ab-4c1e-b5b6-9d4b2a1f0e01";
4
+ var OWNER = "5cbc8f4c-96ab-4c1e-b5b6-9d4b2a1f0e02";
5
+ var USER = "5cbc8f4c-96ab-4c1e-b5b6-9d4b2a1f0e03";
6
+ var CAROL = "5cbc8f4c-96ab-4c1e-b5b6-9d4b2a1f0e05";
7
+ var OTHER_SITE = "5cbc8f4c-96ab-4c1e-b5b6-9d4b2a1f0e06";
8
+ var MAPPING = {
9
+ purpose: "writing_assistant",
10
+ kind: "llm.generate",
11
+ service: "qwen",
12
+ owner: null
13
+ };
14
+ var TEAMMATE_MAPPING = {
15
+ purpose: "writing_assistant",
16
+ kind: "llm.generate",
17
+ // Deliberately the same name as the mapper's own service above: a store
18
+ // keyed on the name alone would pass a case that used a distinct one.
19
+ service: "qwen",
20
+ // carol — a second teammate, whose qwen is not bob's.
21
+ owner: "5cbc8f4c-96ab-4c1e-b5b6-9d4b2a1f0e04"
22
+ };
23
+ function describePolicyStoreContract(name, options) {
24
+ describe(`the policy store \u2014 ${name}`, () => {
25
+ it("says a site nobody authorised is not consented", async () => {
26
+ const { store, done } = await options.make();
27
+ const snapshot = await store.read({
28
+ siteId: SITE,
29
+ user: USER,
30
+ owner: OWNER
31
+ });
32
+ expect(snapshot.consented).toBe("no");
33
+ expect(snapshot.mappings).toEqual([]);
34
+ await done();
35
+ });
36
+ it("returns the mapping the consent carried", async () => {
37
+ const s = await options.make();
38
+ await s.consent({ siteId: SITE, user: USER, mappings: [MAPPING] });
39
+ const snapshot = await s.store.read({
40
+ siteId: SITE,
41
+ user: USER,
42
+ owner: OWNER
43
+ });
44
+ expect(snapshot.consented).toBe("yes");
45
+ expect([...snapshot.mappings]).toEqual([MAPPING]);
46
+ await s.done();
47
+ });
48
+ it("round-trips whose service a mapping names", async () => {
49
+ const s = await options.make();
50
+ await s.consent({
51
+ siteId: SITE,
52
+ user: USER,
53
+ mappings: [TEAMMATE_MAPPING]
54
+ });
55
+ await s.addMember({ owner: OWNER, user: USER });
56
+ const snapshot = await s.store.read({
57
+ siteId: SITE,
58
+ user: USER,
59
+ owner: OWNER
60
+ });
61
+ expect(snapshot.mappings).toEqual([TEAMMATE_MAPPING]);
62
+ expect(snapshot.mappings[0]?.owner).toBe(TEAMMATE_MAPPING.owner);
63
+ await s.done();
64
+ });
65
+ it("keeps two same-named services apart by whose they are", async () => {
66
+ const s = await options.make();
67
+ await s.consent({
68
+ siteId: SITE,
69
+ user: USER,
70
+ mappings: [
71
+ { ...MAPPING, purpose: "mine" },
72
+ { ...TEAMMATE_MAPPING, purpose: "theirs" }
73
+ ]
74
+ });
75
+ await s.addMember({ owner: OWNER, user: USER });
76
+ const snapshot = await s.store.read({
77
+ siteId: SITE,
78
+ user: USER,
79
+ owner: OWNER
80
+ });
81
+ const byPurpose = new Map(
82
+ snapshot.mappings.map((m) => [m.purpose, m.owner])
83
+ );
84
+ expect(byPurpose.get("mine")).toBeNull();
85
+ expect(byPurpose.get("theirs")).toBe(TEAMMATE_MAPPING.owner);
86
+ await s.done();
87
+ });
88
+ it("distinguishes consented-and-unmapped from never-consented", async () => {
89
+ const s = await options.make();
90
+ await s.consent({ siteId: SITE, user: USER, mappings: [] });
91
+ const snapshot = await s.store.read({
92
+ siteId: SITE,
93
+ user: USER,
94
+ owner: OWNER
95
+ });
96
+ expect(snapshot.consented).toBe("yes");
97
+ expect(snapshot.mappings).toEqual([]);
98
+ await s.done();
99
+ });
100
+ it("deletes the mapping when consent is withdrawn", async () => {
101
+ const s = await options.make();
102
+ await s.consent({ siteId: SITE, user: USER, mappings: [MAPPING] });
103
+ await s.revoke({ siteId: SITE, user: USER });
104
+ const snapshot = await s.store.read({
105
+ siteId: SITE,
106
+ user: USER,
107
+ owner: OWNER
108
+ });
109
+ expect(snapshot.consented).toBe("no");
110
+ expect(snapshot.mappings).toEqual([]);
111
+ await s.done();
112
+ });
113
+ it("reads a paused consent as not consented", async () => {
114
+ const s = await options.make();
115
+ if (!s.pause) {
116
+ await s.done();
117
+ return;
118
+ }
119
+ await s.consent({ siteId: SITE, user: USER, mappings: [MAPPING] });
120
+ await s.pause({ siteId: SITE, user: USER });
121
+ const snapshot = await s.store.read({
122
+ siteId: SITE,
123
+ user: USER,
124
+ owner: OWNER
125
+ });
126
+ expect(snapshot.consented).toBe("paused");
127
+ await s.done();
128
+ });
129
+ it("keeps one person's consent out of another's", async () => {
130
+ const s = await options.make();
131
+ await s.consent({ siteId: SITE, user: USER, mappings: [MAPPING] });
132
+ const other = await s.store.read({
133
+ siteId: SITE,
134
+ user: CAROL,
135
+ owner: OWNER
136
+ });
137
+ expect(other.consented).toBe("no");
138
+ await s.done();
139
+ });
140
+ it("keeps one site's consent out of another's", async () => {
141
+ const s = await options.make();
142
+ await s.consent({ siteId: SITE, user: USER, mappings: [MAPPING] });
143
+ const other = await s.store.read({
144
+ siteId: OTHER_SITE,
145
+ user: USER,
146
+ owner: OWNER
147
+ });
148
+ expect(other.consented).toBe("no");
149
+ await s.done();
150
+ });
151
+ it("reports membership per owner, not per person", async () => {
152
+ const s = await options.make();
153
+ await s.addMember({ owner: OWNER, user: USER });
154
+ const mine = await s.store.read({
155
+ siteId: SITE,
156
+ user: USER,
157
+ owner: OWNER
158
+ });
159
+ const theirs = await s.store.read({
160
+ siteId: SITE,
161
+ user: USER,
162
+ // carol — a second teammate, whose qwen is not bob's.
163
+ owner: "5cbc8f4c-96ab-4c1e-b5b6-9d4b2a1f0e04"
164
+ });
165
+ expect(mine.member).toBe(true);
166
+ expect(theirs.member).toBe(false);
167
+ await s.done();
168
+ });
169
+ it("stops reporting membership once somebody is removed", async () => {
170
+ const s = await options.make();
171
+ await s.addMember({ owner: OWNER, user: USER });
172
+ await s.removeMember({ owner: OWNER, user: USER });
173
+ const snapshot = await s.store.read({
174
+ siteId: SITE,
175
+ user: USER,
176
+ owner: OWNER
177
+ });
178
+ expect(snapshot.member).toBe(false);
179
+ await s.done();
180
+ });
181
+ it("keeps membership and consent independent", async () => {
182
+ const s = await options.make();
183
+ await s.addMember({ owner: OWNER, user: USER });
184
+ const noConsent = await s.store.read({
185
+ siteId: SITE,
186
+ user: USER,
187
+ owner: OWNER
188
+ });
189
+ expect(noConsent.member).toBe(true);
190
+ expect(noConsent.consented).toBe("no");
191
+ await s.consent({ siteId: SITE, user: CAROL, mappings: [MAPPING] });
192
+ const noMembership = await s.store.read({
193
+ siteId: SITE,
194
+ user: CAROL,
195
+ owner: OWNER
196
+ });
197
+ expect(noMembership.consented).toBe("yes");
198
+ expect(noMembership.member).toBe(false);
199
+ await s.done();
200
+ });
201
+ it.runIf(options.opaqueIds !== false)(
202
+ "does not let a site id and a user id be confused for each other",
203
+ async () => {
204
+ const s = await options.make();
205
+ await s.consent({ siteId: "a", user: "b:c", mappings: [MAPPING] });
206
+ const confusable = await s.store.read({
207
+ siteId: "a:b",
208
+ user: "c",
209
+ owner: OWNER
210
+ });
211
+ expect(confusable.consented).toBe("no");
212
+ await s.done();
213
+ }
214
+ );
215
+ });
216
+ }
217
+ export {
218
+ describePolicyStoreContract
219
+ };
220
+ //# sourceMappingURL=store-contract.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/store-contract.ts"],"sourcesContent":["import { describe, expect, it } from \"vitest\";\nimport type { Mapping, PolicyStore } from \"./store.js\";\n\n/**\n * The policy store's behaviour, written once and run against every\n * implementation.\n *\n * The same reasoning as `@byollm/relay/store-contract`, and more of it. The\n * implementation that matters most is byollm.cloud's, in another repository\n * and not open — so this is the only way a reader can know that the hosted\n * product's store answers the same questions the same way the reference one\n * does. A contract only its author can run is a description.\n *\n * ## What a store has to do to be tested\n *\n * `PolicyStore` has one method, and it reads. Everything that *writes* —\n * consenting, mapping, adding somebody to a team — is the deployment's own\n * surface, and no two will agree on it. So the contract takes a small harness\n * that performs those acts however the implementation performs them, and\n * asserts only what `read` must then answer.\n *\n * That is the honest boundary: this proves the store's *answers*, not its\n * admin API. A store that passes has the semantics the engine relies on.\n */\nexport interface PolicyStoreContractOptions {\n /**\n * A fresh, empty store, its write surface, and how to dispose of it.\n *\n * Arrow-typed rather than methods, for the reason the routing store's\n * contract gives: a method signature lets `this` travel with a call read\n * off an options object, which is exactly where that goes wrong.\n */\n readonly make: () => Promise<{\n store: PolicyStore;\n consent: (input: {\n siteId: string;\n user: string;\n mappings?: readonly Mapping[];\n }) => Promise<void> | void;\n revoke: (input: { siteId: string; user: string }) => Promise<void> | void;\n /**\n * Pause a consent without withdrawing it.\n *\n * Optional because not every deployment offers pausing. A store that does\n * not is not asked to prove it — but one that does must prove a paused\n * consent reads as not-consented, which is the case below.\n */\n pause?: (input: { siteId: string; user: string }) => Promise<void> | void;\n addMember: (input: { owner: string; user: string }) => Promise<void> | void;\n removeMember: (input: {\n owner: string;\n user: string;\n }) => Promise<void> | void;\n done: () => Promise<void>;\n }>;\n /**\n * Whether this store accepts arbitrary strings as ids.\n *\n * True for a store that keys by concatenation, where two ids differing only\n * in where a separator falls is a real hazard and the case below is the\n * point. False for one with typed columns — a Postgres `uuid` cannot hold\n * `\"b:c\"`, so the confusion is structurally impossible and demanding the\n * case would only stop that store running the contract at all.\n *\n * Named rather than skipped silently: an implementation that opts out is\n * saying its ids are typed, which is a claim about its schema.\n */\n readonly opaqueIds?: boolean;\n}\n\n/**\n * Ids as **uuids**, so a real control plane can run this — byollm-review\n * 2026-08-27.\n *\n * These were `\"site_demo\"`, `\"bob\"` and `\"alice\"`: fine for a store that\n * treats an id as an opaque string, and rejected outright by the Postgres\n * `uuid` columns of the implementation this contract exists for. A suite the\n * hosted store could not execute is the \"contract only its author can run\"\n * this file's own header calls a description rather than a contract.\n *\n * A uuid is an opaque string too, so nothing is lost for stores that do not\n * care — and the names stay in the comments, where they are for people.\n */\nconst SITE = \"5cbc8f4c-96ab-4c1e-b5b6-9d4b2a1f0e01\";\n/** bob — the device owner. */\nconst OWNER = \"5cbc8f4c-96ab-4c1e-b5b6-9d4b2a1f0e02\";\n/** alice — whose work it is. */\nconst USER = \"5cbc8f4c-96ab-4c1e-b5b6-9d4b2a1f0e03\";\n/** carol — somebody else entirely, for the cases about keeping people apart. */\nconst CAROL = \"5cbc8f4c-96ab-4c1e-b5b6-9d4b2a1f0e05\";\n/**\n * A second site, for the twin of the case that keeps two people apart.\n *\n * Its id was spelled inline until a store with `uuid` columns was finally\n * pointed at this contract and threw on it — the one id the sweep to real\n * uuids missed, because every other case had a named constant and this one\n * did not. The person-isolation case passed throughout, so the gap read as\n * \"isolation is covered\" when only half of it was.\n */\nconst OTHER_SITE = \"5cbc8f4c-96ab-4c1e-b5b6-9d4b2a1f0e06\";\nconst MAPPING: Mapping = {\n purpose: \"writing_assistant\",\n kind: \"llm.generate\",\n service: \"qwen\",\n owner: null,\n};\n\n/**\n * The same service name, on a teammate's machine.\n *\n * byollm-review 2026-08-27. Every case here used `owner: null`, so a store\n * that dropped `owner`, nulled it, or mis-joined it passed the whole suite —\n * while this contract claimed \"a store that passes has the semantics the\n * engine relies on\". It did not.\n *\n * The engine's wrong-machine check is `(mapped.owner ?? job.owner) !== owner`,\n * and it is the enforcement of the one substitution this design exists to\n * forbid: a mapping naming *carol's* qwen must not be satisfied by *bob's*\n * qwen. It depends entirely on a store round-tripping this field, and nothing\n * asked it to.\n */\nconst TEAMMATE_MAPPING: Mapping = {\n purpose: \"writing_assistant\",\n kind: \"llm.generate\",\n // Deliberately the same name as the mapper's own service above: a store\n // keyed on the name alone would pass a case that used a distinct one.\n service: \"qwen\",\n // carol — a second teammate, whose qwen is not bob's.\n owner: \"5cbc8f4c-96ab-4c1e-b5b6-9d4b2a1f0e04\",\n};\n\n/** Run the contract. Call inside a suite; it declares its own `describe`. */\nexport function describePolicyStoreContract(\n name: string,\n options: PolicyStoreContractOptions,\n): void {\n describe(`the policy store — ${name}`, () => {\n it(\"says a site nobody authorised is not consented\", async () => {\n // The ordinary answer, not an error. A person who has never heard of a\n // site is the common case, and a store that threw here would make the\n // engine treat \"no\" as \"broken\".\n const { store, done } = await options.make();\n const snapshot = await store.read({\n siteId: SITE,\n user: USER,\n owner: OWNER,\n });\n expect(snapshot.consented).toBe(\"no\");\n expect(snapshot.mappings).toEqual([]);\n await done();\n });\n\n it(\"returns the mapping the consent carried\", async () => {\n const s = await options.make();\n await s.consent({ siteId: SITE, user: USER, mappings: [MAPPING] });\n const snapshot = await s.store.read({\n siteId: SITE,\n user: USER,\n owner: OWNER,\n });\n expect(snapshot.consented).toBe(\"yes\");\n expect([...snapshot.mappings]).toEqual([MAPPING]);\n await s.done();\n });\n\n it(\"round-trips whose service a mapping names\", async () => {\n /**\n * The field the anti-substitution check is made of.\n *\n * A store that answers `null` here — or the querying device's owner —\n * makes `(mapped.owner ?? job.owner) !== owner` pass for the wrong\n * machine, and a grant gets signed putting somebody's work on a device\n * they never chose. Device-side checks do not save it: the wrong\n * machine's service is team-scoped and the person is a legitimate\n * member, so admission succeeds.\n *\n * `null` and a real owner are both asserted, because the two are read\n * through the same column and a store that swapped them would satisfy\n * either case alone.\n */\n const s = await options.make();\n await s.consent({\n siteId: SITE,\n user: USER,\n mappings: [TEAMMATE_MAPPING],\n });\n await s.addMember({ owner: OWNER, user: USER });\n\n const snapshot = await s.store.read({\n siteId: SITE,\n user: USER,\n owner: OWNER,\n });\n\n expect(snapshot.mappings).toEqual([TEAMMATE_MAPPING]);\n expect(snapshot.mappings[0]?.owner).toBe(TEAMMATE_MAPPING.owner);\n await s.done();\n });\n\n it(\"keeps two same-named services apart by whose they are\", async () => {\n /**\n * The substitution itself, as a stored fact.\n *\n * Alice is on two teams that both run a `qwen`. Those are two different\n * machines and the consent screen shows them as two options; a store\n * that collapsed them would let either claim the work. Two mappings\n * under one consent, which no case here previously exercised either.\n */\n const s = await options.make();\n await s.consent({\n siteId: SITE,\n user: USER,\n mappings: [\n { ...MAPPING, purpose: \"mine\" },\n { ...TEAMMATE_MAPPING, purpose: \"theirs\" },\n ],\n });\n await s.addMember({ owner: OWNER, user: USER });\n\n const snapshot = await s.store.read({\n siteId: SITE,\n user: USER,\n owner: OWNER,\n });\n\n const byPurpose = new Map(\n snapshot.mappings.map((m) => [m.purpose, m.owner]),\n );\n expect(byPurpose.get(\"mine\")).toBeNull();\n expect(byPurpose.get(\"theirs\")).toBe(TEAMMATE_MAPPING.owner);\n await s.done();\n });\n\n it(\"distinguishes consented-and-unmapped from never-consented\", async () => {\n // A real signup state: every slot had two candidates, so nothing\n // auto-mapped and the person has not chosen. It is not the same as\n // never having authorised the site, and it sends them somewhere else.\n const s = await options.make();\n await s.consent({ siteId: SITE, user: USER, mappings: [] });\n const snapshot = await s.store.read({\n siteId: SITE,\n user: USER,\n owner: OWNER,\n });\n expect(snapshot.consented).toBe(\"yes\");\n expect(snapshot.mappings).toEqual([]);\n await s.done();\n });\n\n it(\"deletes the mapping when consent is withdrawn\", async () => {\n // Hole 2: the mapping *is* the consent, so un-consenting unmaps. A\n // store that kept the mapping would hold a resolution pointing at a\n // service its author no longer authorises anybody to reach.\n const s = await options.make();\n await s.consent({ siteId: SITE, user: USER, mappings: [MAPPING] });\n await s.revoke({ siteId: SITE, user: USER });\n const snapshot = await s.store.read({\n siteId: SITE,\n user: USER,\n owner: OWNER,\n });\n expect(snapshot.consented).toBe(\"no\");\n expect(snapshot.mappings).toEqual([]);\n await s.done();\n });\n\n it(\"reads a paused consent as not consented\", async () => {\n // Three states, one boolean, on purpose: never authorised, revoked and\n // paused are different to a person and identical to a grant. A relay's\n // projection can lag by seconds, so a store that reported a paused\n // consent as live would let a grant be authored for work its owner had\n // just stopped.\n const s = await options.make();\n if (!s.pause) {\n await s.done();\n return;\n }\n await s.consent({ siteId: SITE, user: USER, mappings: [MAPPING] });\n await s.pause({ siteId: SITE, user: USER });\n const snapshot = await s.store.read({\n siteId: SITE,\n user: USER,\n owner: OWNER,\n });\n expect(snapshot.consented).toBe(\"paused\");\n\n /**\n * `\"paused\"`, not `\"no\"` — byollm-review 2026-08-27.\n *\n * The distinction is the whole reason this stopped being a boolean. A\n * store reporting a pause as \"no\" is not merely imprecise: the engine\n * declines `not-consented`, which is **permanent**, and the relay never\n * offers that job to that device again. The hosted product pauses a\n * consent the moment its author joins a team, so this is an everyday\n * path, and re-consenting minutes later did not bring the work back.\n *\n * A pause is somebody's own to lift, so the job has to still be there\n * when they do.\n */\n await s.done();\n });\n\n it(\"keeps one person's consent out of another's\", async () => {\n const s = await options.make();\n await s.consent({ siteId: SITE, user: USER, mappings: [MAPPING] });\n const other = await s.store.read({\n siteId: SITE,\n user: CAROL,\n owner: OWNER,\n });\n expect(other.consented).toBe(\"no\");\n await s.done();\n });\n\n it(\"keeps one site's consent out of another's\", async () => {\n const s = await options.make();\n await s.consent({ siteId: SITE, user: USER, mappings: [MAPPING] });\n const other = await s.store.read({\n siteId: OTHER_SITE,\n user: USER,\n owner: OWNER,\n });\n expect(other.consented).toBe(\"no\");\n await s.done();\n });\n\n it(\"reports membership per owner, not per person\", async () => {\n // A team is one owner's, and being on one confers nothing about\n // anybody else's devices. A store that kept a global \"alice is a team\n // member\" flag would share every owner's hardware with her.\n const s = await options.make();\n await s.addMember({ owner: OWNER, user: USER });\n const mine = await s.store.read({\n siteId: SITE,\n user: USER,\n owner: OWNER,\n });\n const theirs = await s.store.read({\n siteId: SITE,\n user: USER,\n // carol — a second teammate, whose qwen is not bob's.\n owner: \"5cbc8f4c-96ab-4c1e-b5b6-9d4b2a1f0e04\",\n });\n expect(mine.member).toBe(true);\n expect(theirs.member).toBe(false);\n await s.done();\n });\n\n it(\"stops reporting membership once somebody is removed\", async () => {\n // Removal is the whole of it — there is no blocked-but-still-a-member,\n // because a team *is* access to its owner's devices (Amendment J).\n const s = await options.make();\n await s.addMember({ owner: OWNER, user: USER });\n await s.removeMember({ owner: OWNER, user: USER });\n const snapshot = await s.store.read({\n siteId: SITE,\n user: USER,\n owner: OWNER,\n });\n expect(snapshot.member).toBe(false);\n await s.done();\n });\n\n it(\"keeps membership and consent independent\", async () => {\n // Two different questions, and both have to be yes. A store that\n // conflated them would let a team member's work run on a site they\n // never authorised, or refuse a person their own site because nobody\n // had added them to a team.\n const s = await options.make();\n await s.addMember({ owner: OWNER, user: USER });\n const noConsent = await s.store.read({\n siteId: SITE,\n user: USER,\n owner: OWNER,\n });\n expect(noConsent.member).toBe(true);\n expect(noConsent.consented).toBe(\"no\");\n\n await s.consent({ siteId: SITE, user: CAROL, mappings: [MAPPING] });\n const noMembership = await s.store.read({\n siteId: SITE,\n user: CAROL,\n owner: OWNER,\n });\n expect(noMembership.consented).toBe(\"yes\");\n expect(noMembership.member).toBe(false);\n await s.done();\n });\n\n it.runIf(options.opaqueIds !== false)(\n \"does not let a site id and a user id be confused for each other\",\n async () => {\n // Composite keys are parsers waiting to meet an id containing their\n // separator. These two consents differ only in where the boundary\n // falls, and a store that joined them naively would answer one for\n // the other.\n const s = await options.make();\n await s.consent({ siteId: \"a\", user: \"b:c\", mappings: [MAPPING] });\n const confusable = await s.store.read({\n siteId: \"a:b\",\n user: \"c\",\n owner: OWNER,\n });\n expect(confusable.consented).toBe(\"no\");\n await s.done();\n },\n );\n });\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@byollm/control-plane",
3
+ "version": "0.1.0-alpha.100",
4
+ "type": "module",
5
+ "description": "The reference control plane: resolves a user's mapping and authors one signed grant per job, against a policy store it does not own.",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/oftomorrowinc/byollm.git",
10
+ "directory": "packages/control-plane"
11
+ },
12
+ "homepage": "https://github.com/oftomorrowinc/byollm/tree/main/packages/control-plane",
13
+ "bugs": {
14
+ "url": "https://github.com/oftomorrowinc/byollm/issues"
15
+ },
16
+ "main": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ },
23
+ "./store-contract": {
24
+ "types": "./dist/store-contract.d.ts",
25
+ "import": "./dist/store-contract.js"
26
+ }
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "README.md"
31
+ ],
32
+ "dependencies": {
33
+ "@byollm/protocol": "0.1.0-alpha.100"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "devDependencies": {
39
+ "vitest": "^4.1.10"
40
+ },
41
+ "peerDependencies": {
42
+ "vitest": ">=3"
43
+ },
44
+ "peerDependenciesMeta": {
45
+ "vitest": {
46
+ "optional": true
47
+ }
48
+ },
49
+ "scripts": {
50
+ "build": "tsup"
51
+ }
52
+ }