@byollm/control-plane 0.1.0-alpha.73 → 0.1.0-alpha.74

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @byollm/control-plane
2
2
 
3
- > **Alpha (`0.1.0-alpha.73`) — under active development. Don't use this yet.**
3
+ > **Alpha (`0.1.0-alpha.74`) — under active development. Don't use this yet.**
4
4
 
5
5
  The reference control plane: it resolves a person's mapping and authors one
6
6
  signed grant per job, against a policy store it does not own and with a key it
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { GrantClaims, SignedGrant, StoredKeys, ClaimedStub, CapabilityMatrix } from '@byollm/protocol';
2
- import { P as PolicyStore, M as Mapping, a as PolicySnapshot } from './store-Jq4UkK7N.js';
2
+ import { P as PolicyStore, M as Mapping, a as PolicySnapshot } from './store-0clah5RQ.js';
3
3
 
4
4
  /**
5
5
  * Whatever holds the key that grants are signed with.
@@ -92,7 +92,7 @@ declare class ControlPlane {
92
92
  readonly purpose: string | undefined;
93
93
  readonly kind: string;
94
94
  }): Promise<{
95
- verdict: "ok" | "not-declared" | "unmapped";
95
+ verdict: "ok" | "not-declared" | "unmapped" | "waiting";
96
96
  }>;
97
97
  authorGrant(input: {
98
98
  readonly job: ClaimedStub;
@@ -252,6 +252,18 @@ declare class MemoryPolicyStore implements PolicyStore {
252
252
  owner: string;
253
253
  user: string;
254
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;
255
267
  read(input: {
256
268
  siteId: string;
257
269
  user: string;
package/dist/index.js CHANGED
@@ -63,10 +63,16 @@ var ControlPlane = class {
63
63
  if (snapshot.declares !== void 0 && !snapshot.declares.has(purpose)) {
64
64
  return { verdict: "not-declared" };
65
65
  }
66
- const mapped = snapshot.mappings.some(
66
+ const forSlot = snapshot.mappings.filter(
67
67
  (mapping) => mapping.purpose === purpose && mapping.kind === input.kind
68
68
  );
69
- return { verdict: mapped ? "ok" : "unmapped" };
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" };
70
76
  }
71
77
  async authorGrant(input) {
72
78
  const { job, owner } = input;
@@ -129,6 +135,14 @@ var MemoryPolicyStore = class {
129
135
  #paused = /* @__PURE__ */ new Set();
130
136
  /** owner to the people who may use their devices. */
131
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;
132
146
  /**
133
147
  * Record a consent, with the mapping it carries.
134
148
  *
@@ -190,6 +204,19 @@ var MemoryPolicyStore = class {
190
204
  removeMember(input) {
191
205
  this.#members.get(input.owner)?.delete(input.user);
192
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
+ }
193
220
  read(input) {
194
221
  const id = key(input.siteId, input.user);
195
222
  const mappings = this.#consents.get(id);
@@ -199,7 +226,8 @@ var MemoryPolicyStore = class {
199
226
  // needs to know that before it decides whether a refusal is forever.
200
227
  consented: mappings === void 0 ? "no" : this.#paused.has(id) ? "paused" : "yes",
201
228
  member: this.#members.get(input.owner)?.has(input.user) ?? false,
202
- mappings: mappings ?? []
229
+ mappings: mappings ?? [],
230
+ ...this.#advertised === void 0 ? {} : { advertised: this.#advertised }
203
231
  });
204
232
  }
205
233
  };
package/dist/index.js.map CHANGED
@@ -1 +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\" }> {\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 mapped = snapshot.mappings.some(\n (mapping) => mapping.purpose === purpose && mapping.kind === input.kind,\n );\n return { verdict: mapped ? \"ok\" : \"unmapped\" };\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 /**\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 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 });\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":[]}
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":[]}
@@ -120,6 +120,27 @@ interface PolicySnapshot {
120
120
  * already share.
121
121
  */
122
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;
123
144
  }
124
145
  interface PolicyStore {
125
146
  /**
@@ -1,4 +1,4 @@
1
- import { P as PolicyStore, M as Mapping } from './store-Jq4UkK7N.js';
1
+ import { P as PolicyStore, M as Mapping } from './store-0clah5RQ.js';
2
2
 
3
3
  /**
4
4
  * The policy store's behaviour, written once and run against every
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byollm/control-plane",
3
- "version": "0.1.0-alpha.73",
3
+ "version": "0.1.0-alpha.74",
4
4
  "type": "module",
5
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
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  "README.md"
31
31
  ],
32
32
  "dependencies": {
33
- "@byollm/protocol": "0.1.0-alpha.73"
33
+ "@byollm/protocol": "0.1.0-alpha.74"
34
34
  },
35
35
  "publishConfig": {
36
36
  "access": "public"