@norskvideo/ctl-sdk 0.1.41 → 0.1.43

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.
@@ -9,6 +9,19 @@ declare const DefaultProductTemplateSchema: z.ZodObject<{
9
9
  url: z.ZodString;
10
10
  }, z.core.$strip>;
11
11
  export type DefaultProductTemplate = z.infer<typeof DefaultProductTemplateSchema>;
12
+ /** One product role (RFC 0003 D5a). The name rule is foundation's, so the
13
+ * runner that stamps it and the manifest that declares it cannot disagree. */
14
+ declare const ProductRoleSchema: z.ZodObject<{
15
+ name: z.ZodString;
16
+ description: z.ZodString;
17
+ defaultFor: z.ZodOptional<z.ZodEnum<{
18
+ viewer: "viewer";
19
+ operator: "operator";
20
+ builder: "builder";
21
+ admin: "admin";
22
+ }>>;
23
+ }, z.core.$strip>;
24
+ export type ProductRoleDeclaration = z.infer<typeof ProductRoleSchema>;
12
25
  export declare const ManifestSchema: z.ZodObject<{
13
26
  manifestSchemaVersion: z.ZodLiteral<1>;
14
27
  name: z.ZodString;
@@ -16,6 +29,18 @@ export declare const ManifestSchema: z.ZodObject<{
16
29
  minRunnerVersion: z.ZodOptional<z.ZodString>;
17
30
  requires: z.ZodOptional<z.ZodArray<z.ZodString>>;
18
31
  accessModes: z.ZodOptional<z.ZodArray<z.ZodString>>;
32
+ access: z.ZodOptional<z.ZodObject<{
33
+ roles: z.ZodDefault<z.ZodArray<z.ZodObject<{
34
+ name: z.ZodString;
35
+ description: z.ZodString;
36
+ defaultFor: z.ZodOptional<z.ZodEnum<{
37
+ viewer: "viewer";
38
+ operator: "operator";
39
+ builder: "builder";
40
+ admin: "admin";
41
+ }>>;
42
+ }, z.core.$strip>>>;
43
+ }, z.core.$strip>>;
19
44
  api: z.ZodObject<{
20
45
  basePath: z.ZodString;
21
46
  proxyPaths: z.ZodArray<z.ZodString>;
@@ -73,9 +98,9 @@ export declare const ManifestSchema: z.ZodObject<{
73
98
  }, z.core.$strip>>>;
74
99
  request: z.ZodOptional<z.ZodObject<{
75
100
  method: z.ZodEnum<{
101
+ PUT: "PUT";
76
102
  GET: "GET";
77
103
  POST: "POST";
78
- PUT: "PUT";
79
104
  DELETE: "DELETE";
80
105
  }>;
81
106
  path: z.ZodString;
@@ -83,9 +108,9 @@ export declare const ManifestSchema: z.ZodObject<{
83
108
  }, z.core.$strip>>>;
84
109
  request: z.ZodOptional<z.ZodObject<{
85
110
  method: z.ZodEnum<{
111
+ PUT: "PUT";
86
112
  GET: "GET";
87
113
  POST: "POST";
88
- PUT: "PUT";
89
114
  DELETE: "DELETE";
90
115
  }>;
91
116
  path: z.ZodString;
@@ -169,6 +194,9 @@ export declare const ManifestSchema: z.ZodObject<{
169
194
  }>;
170
195
  value: z.ZodOptional<z.ZodArray<z.ZodString>>;
171
196
  }, z.core.$strip>>;
197
+ st2110Nics: z.ZodOptional<z.ZodObject<{
198
+ count: z.ZodNumber;
199
+ }, z.core.$strip>>;
172
200
  }, z.core.$strip>>;
173
201
  defaultProductTemplates: z.ZodDefault<z.ZodArray<z.ZodObject<{
174
202
  name: z.ZodString;
@@ -1,3 +1,4 @@
1
+ import { PLATFORM_ROLE_LADDER, parseProductRoleName } from "@norskvideo/ctl-foundation";
1
2
  import { ProductTemplateRequirementsSchema } from "@norskvideo/ctl-product-template-schema";
2
3
  import { z } from "zod";
3
4
  const TargetSchema = z.enum(["norsk-ctl", "docker-compose"]);
@@ -91,6 +92,30 @@ const ExportSchema = z.object({
91
92
  include: z.array(z.string()).default([]),
92
93
  exclude: z.array(z.string()).default([]),
93
94
  });
95
+ /** One product role (RFC 0003 D5a). The name rule is foundation's, so the
96
+ * runner that stamps it and the manifest that declares it cannot disagree. */
97
+ const ProductRoleSchema = z.object({
98
+ name: z
99
+ .string()
100
+ .refine((name) => parseProductRoleName(name).status === "ok", {
101
+ message: "a product role is lower-kebab and never a platform role's name",
102
+ })
103
+ .meta({
104
+ description: "Bare role name, as Studio's access policy names it (`mcr-operator`). Lower-kebab, starting with a letter, never a platform role's name. Travels bare in `X-Norsk-Roles`, so two products on one host may share a name only if they agree on its `defaultFor`.",
105
+ }),
106
+ description: z.string().meta({ description: "What holding this role is for, for the operator assigning it." }),
107
+ defaultFor: z.enum(PLATFORM_ROLE_LADDER).optional().meta({
108
+ description: "A platform rung whose holders get this role implicitly on this product's instances (that rung or stronger). An implicit assignment a product release could widen, so the runner records it on add and reload, and an operator can switch implicit defaults off (`acceptProductDefaults: false`).",
109
+ }),
110
+ });
111
+ const AccessSchema = z.object({
112
+ roles: z
113
+ .array(ProductRoleSchema)
114
+ .default([])
115
+ .refine((roles) => new Set(roles.map((r) => r.name)).size === roles.length, {
116
+ message: "a product role is declared at most once",
117
+ }),
118
+ });
94
119
  export const ManifestSchema = z.object({
95
120
  manifestSchemaVersion: z.literal(1),
96
121
  name: z.string(),
@@ -102,6 +127,9 @@ export const ManifestSchema = z.object({
102
127
  accessModes: z.array(z.string().min(1)).optional().meta({
103
128
  description: "The NORSK_PRODUCT_ACCESS values this product's control plane understands. The runner sets that variable only when the mode it is in appears here; absent, it sets nothing and the control plane runs with its gate dormant. This is the POSITIVE signal RFC 0001's 2026-09-21 amendment requires before a product may decline to serve: a product must never infer that it was promised enforcement from a missing variable, because a developer running it directly and an older runner launching it are indistinguishable from the inside. Declared on the product manifest rather than a product template because the control plane is started by `docker run` at `product add`, before any template for it exists -- templates are fetched FROM it -- and because this manifest is served by the very image that enforces, so the two cannot drift. A list rather than a boolean: the runner must never send a word this image predates.",
104
129
  }),
130
+ access: AccessSchema.optional().meta({
131
+ description: "The product roles this product defines (RFC 0003 D5a/D6). Additive: a manifest without it declares none, and every principal reaches its instances on platform roles alone, as before.",
132
+ }),
105
133
  api: z.object({
106
134
  basePath: z.string(),
107
135
  proxyPaths: z.array(z.string()),
@@ -28,7 +28,7 @@
28
28
  // (1 s doubling to 30 s) after any drop, re-sends hello, and holds at most
29
29
  // the LATEST unsent report while disconnected — a newer position supersedes
30
30
  // an older one, and the worker acts on the first it hears.
31
- import { JOB_SOCKET_ENV, parseWorkerToJobFrame, } from "@norskvideo/ctl-product-template-schema/migration-socket";
31
+ import { JOB_SOCKET_ENV, JOB_SOCKET_MAX_FRAME_BYTES, parseWorkerToJobFrame, } from "@norskvideo/ctl-product-template-schema/migration-socket";
32
32
  export function stateRefusal(reason) {
33
33
  return { refused: true, reason };
34
34
  }
@@ -199,8 +199,20 @@ class SocketMigrationClient {
199
199
  this.pending = msg;
200
200
  }
201
201
  send(msg) {
202
+ let frame = JSON.stringify(msg);
203
+ const bytes = new TextEncoder().encode(frame).length;
204
+ if (bytes > JOB_SOCKET_MAX_FRAME_BYTES) {
205
+ // The worker would close the socket on it (frameTooLarge) — for a
206
+ // `source-stopped-output`, with the source's output already stopped —
207
+ // and the migration would wait out its deadline. Say why instead: a
208
+ // `state` becomes a refusal (the source carries on, nothing moved),
209
+ // anything else an abort naming the size.
210
+ const why = `'${msg.type}' is ${bytes} bytes, over the job socket's ${JOB_SOCKET_MAX_FRAME_BYTES}-byte frame limit`;
211
+ this.log(`${why}; not sent`);
212
+ frame = JSON.stringify(msg.type === "state" ? { type: "state-refused", reason: why } : { type: "abort", reason: why });
213
+ }
202
214
  try {
203
- this.socket?.send(JSON.stringify(msg));
215
+ this.socket?.send(frame);
204
216
  }
205
217
  catch (e) {
206
218
  this.log(`send of '${msg.type}' failed: ${String(e)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-sdk",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/parsing.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { parsePlatformRole } from "@norskvideo/ctl-foundation";
1
2
  import { ManifestSchema } from "./manifest-schema.js";
2
3
  export class ProductsParseError extends Error {
3
4
  constructor(path, reason) {
@@ -100,6 +101,19 @@ export function parseProductsFile(raw, path) {
100
101
  reg.reachHost = entry.reachHost;
101
102
  if (entry.license !== undefined)
102
103
  reg.license = parseLicense(entry.license, where, path);
104
+ if (Array.isArray(entry.acceptedRoleDefaults)) {
105
+ // Anything malformed is dropped rather than refused: this is an
106
+ // acceptance record, and the safe reading of a damaged one is "accepted
107
+ // less", which only ever withholds a default.
108
+ reg.acceptedRoleDefaults = entry.acceptedRoleDefaults.flatMap((d) => {
109
+ const { name, defaultFor } = (d ?? {});
110
+ return typeof name === "string" &&
111
+ typeof defaultFor === "string" &&
112
+ parsePlatformRole(defaultFor).status === "ok"
113
+ ? [{ name, defaultFor: defaultFor }]
114
+ : [];
115
+ });
116
+ }
103
117
  validated.push(reg);
104
118
  }
105
119
  return { products: validated };
@@ -1,4 +1,8 @@
1
1
  export declare class ProductError extends Error {
2
- code: "DEV_URL_INVALID" | "DEV_URL_NOT_LOCALHOST" | "PORT_EXHAUSTED" | "DOCKER_PULL_FAILED" | "DOCKER_RUN_FAILED" | "READINESS_TIMEOUT" | "MANIFEST_FETCH_FAILED" | "MANIFEST_INVALID" | "CONFIG_SCREEN_UNREACHABLE" | "CONFIG_SCREEN_DEV_SERVER" | "LICENSE_INVALID" | "NAME_CONFLICT" | "NOT_FOUND" | "NOT_RESTARTABLE" | "RUNNER_TOO_OLD" | "PRODUCT_TEMPLATE_FETCH_FAILED";
3
- constructor(code: "DEV_URL_INVALID" | "DEV_URL_NOT_LOCALHOST" | "PORT_EXHAUSTED" | "DOCKER_PULL_FAILED" | "DOCKER_RUN_FAILED" | "READINESS_TIMEOUT" | "MANIFEST_FETCH_FAILED" | "MANIFEST_INVALID" | "CONFIG_SCREEN_UNREACHABLE" | "CONFIG_SCREEN_DEV_SERVER" | "LICENSE_INVALID" | "NAME_CONFLICT" | "NOT_FOUND" | "NOT_RESTARTABLE" | "RUNNER_TOO_OLD" | "PRODUCT_TEMPLATE_FETCH_FAILED", message: string);
2
+ code: "DEV_URL_INVALID" | "DEV_URL_NOT_LOCALHOST" | "PORT_EXHAUSTED" | "DOCKER_PULL_FAILED" | "DOCKER_RUN_FAILED" | "READINESS_TIMEOUT" | "MANIFEST_FETCH_FAILED" | "MANIFEST_INVALID" | "CONFIG_SCREEN_UNREACHABLE" | "CONFIG_SCREEN_DEV_SERVER" | "LICENSE_INVALID" | "NAME_CONFLICT" | "NOT_FOUND" | "NOT_RESTARTABLE" | "RUNNER_TOO_OLD" | "PRODUCT_TEMPLATE_FETCH_FAILED"
3
+ /** Two products declare one bare role name with different `defaultFor`. */
4
+ | "ROLE_CONFLICT";
5
+ constructor(code: "DEV_URL_INVALID" | "DEV_URL_NOT_LOCALHOST" | "PORT_EXHAUSTED" | "DOCKER_PULL_FAILED" | "DOCKER_RUN_FAILED" | "READINESS_TIMEOUT" | "MANIFEST_FETCH_FAILED" | "MANIFEST_INVALID" | "CONFIG_SCREEN_UNREACHABLE" | "CONFIG_SCREEN_DEV_SERVER" | "LICENSE_INVALID" | "NAME_CONFLICT" | "NOT_FOUND" | "NOT_RESTARTABLE" | "RUNNER_TOO_OLD" | "PRODUCT_TEMPLATE_FETCH_FAILED"
6
+ /** Two products declare one bare role name with different `defaultFor`. */
7
+ | "ROLE_CONFLICT", message: string);
4
8
  }
@@ -1,9 +1,9 @@
1
1
  import { type ProductPlacement } from "./docker-runner.js";
2
2
  import { type LicenseStager } from "./license-registration.js";
3
- import type { Manifest } from "./manifest-schema.js";
3
+ import type { Manifest, ProductRoleDeclaration } from "./manifest-schema.js";
4
4
  import { type ProductReach } from "./product-reach.js";
5
5
  import type { ProductTemplateSource } from "./product-template-record.js";
6
- import type { ProductLicense, ProductRegistration, ProductSpec } from "./product-types.js";
6
+ import type { ProductLicense, ProductRegistration, ProductSpec, RoleDefault } from "./product-types.js";
7
7
  /**
8
8
  * Result of registering a new product. `warnings` reserved for future
9
9
  * non-fatal advisories surfaced alongside the success message; currently
@@ -150,6 +150,18 @@ export interface ProductServiceOptions {
150
150
  /** Drop whatever `identityEnvFor` minted, once the registration holding it
151
151
  * has gone. */
152
152
  forgetIdentity?: (productName: string) => void;
153
+ /**
154
+ * Told the product roles a manifest declares (RFC 0003 D5a), once per add
155
+ * and per reload that stores it. `defaultFor` is an implicit assignment a
156
+ * product release can widen, so the host records every declaration rather
157
+ * than taking it silently. Omitted by hosts that assign no product roles.
158
+ */
159
+ onRolesDeclared?: (productName: string, roles: readonly ProductRoleDeclaration[], acceptance: {
160
+ pending: readonly RoleDefault[];
161
+ }) => void;
162
+ /** Told once a registration is removed, so the host can drop whatever it
163
+ * holds against the product (role assignments on `product/<name>`). */
164
+ onRemoved?: (productName: string) => void;
153
165
  /**
154
166
  * What the daemon asserts when it dials a REGISTERED product's own routes —
155
167
  * the config-screen probe and the product-template fetches (RFC 0003 D7's
@@ -205,6 +217,16 @@ export declare class ProductService {
205
217
  /** The address to record for a freshly started container: its own, when
206
218
  * this daemon cannot dial the host loopback; otherwise nothing. */
207
219
  private reachHostFor;
220
+ private readonly onRolesDeclared?;
221
+ private readonly onRemoved?;
222
+ /**
223
+ * Product roles travel bare, so a name means one thing on a host. Two
224
+ * products may share one — a common vocabulary is the point — but not with
225
+ * different `defaultFor`, which would make "who implicitly holds it" depend
226
+ * on which manifest was read last. Named in the refusal: the operator has to
227
+ * know which two products to talk to.
228
+ */
229
+ private refuseRoleConflicts;
208
230
  private refuseUnknownCapabilities;
209
231
  list(): ProductRegistration[];
210
232
  /** Docker's default `--pull=missing` keeps whatever a moving tag resolved to
@@ -257,3 +279,13 @@ export declare class ProductService {
257
279
  restart(name: string): Promise<void>;
258
280
  private restartSerialised;
259
281
  }
282
+ /**
283
+ * A registered product's roles as they take effect (RFC 0003 D5): every
284
+ * declared role, carrying its `defaultFor` only where the operator accepted
285
+ * that exact pair. What the host reads, so an unaccepted widening grants
286
+ * nobody anything.
287
+ */
288
+ export declare function effectiveRoleDeclarations(registration: Pick<ProductRegistration, "manifest" | "acceptedRoleDefaults">): {
289
+ name: string;
290
+ defaultFor?: RoleDefault["defaultFor"];
291
+ }[];
@@ -73,6 +73,8 @@ export class ProductService {
73
73
  this.reach = opts.reach ?? detectProductReach({});
74
74
  this.knownCapabilities = opts.knownCapabilities ?? [];
75
75
  this.identityEnvFor = opts.identityEnvFor;
76
+ this.onRolesDeclared = opts.onRolesDeclared;
77
+ this.onRemoved = opts.onRemoved;
76
78
  this.forgetIdentity = opts.forgetIdentity;
77
79
  this.probeHeadersFor = opts.probeHeadersFor;
78
80
  }
@@ -94,6 +96,30 @@ export class ProductService {
94
96
  return undefined;
95
97
  return this.containerOps.address(containerId);
96
98
  }
99
+ onRolesDeclared;
100
+ onRemoved;
101
+ /**
102
+ * Product roles travel bare, so a name means one thing on a host. Two
103
+ * products may share one — a common vocabulary is the point — but not with
104
+ * different `defaultFor`, which would make "who implicitly holds it" depend
105
+ * on which manifest was read last. Named in the refusal: the operator has to
106
+ * know which two products to talk to.
107
+ */
108
+ refuseRoleConflicts(manifest, others) {
109
+ for (const role of manifest.access?.roles ?? []) {
110
+ for (const other of others) {
111
+ if (other.name === manifest.name)
112
+ continue;
113
+ const theirs = other.manifest.access?.roles.find((r) => r.name === role.name);
114
+ if (theirs !== undefined && theirs.defaultFor !== role.defaultFor) {
115
+ return new ProductError("ROLE_CONFLICT", `product role '${role.name}' is declared by '${other.name}' with defaultFor ${theirs.defaultFor ?? "none"} ` +
116
+ `and by '${manifest.name}' with defaultFor ${role.defaultFor ?? "none"}; product roles are bare names, ` +
117
+ "so two products must agree on one before both can be registered");
118
+ }
119
+ }
120
+ }
121
+ return undefined;
122
+ }
97
123
  refuseUnknownCapabilities(manifest) {
98
124
  const check = checkRequiredCapabilities({ product: manifest.name, version: manifest.version, requires: manifest.requires }, this.knownCapabilities);
99
125
  return check.status === "error" ? new ProductError("RUNNER_TOO_OLD", check.error.message) : undefined;
@@ -172,6 +198,12 @@ export class ProductService {
172
198
  await this.containerOps.remove(containerId);
173
199
  throw new ProductError("NAME_CONFLICT", `product '${manifest.name}' already registered`);
174
200
  }
201
+ const roleConflict = this.refuseRoleConflicts(manifest, existing);
202
+ if (roleConflict) {
203
+ if (containerId)
204
+ await this.containerOps.remove(containerId);
205
+ throw roleConflict;
206
+ }
175
207
  const warnings = [];
176
208
  // Licensing V2: a per-product license file must actually entitle this
177
209
  // product (name + image ref/version + expiry, root signature). Anything
@@ -280,6 +312,8 @@ export class ProductService {
280
312
  spec,
281
313
  addedAt: new Date().toISOString(),
282
314
  manifest,
315
+ // What the operator saw at add is what is accepted (RFC 0003 D5).
316
+ acceptedRoleDefaults: roleDefaultsOf(manifest),
283
317
  ...(port !== undefined ? { port } : {}),
284
318
  ...(containerId !== undefined ? { containerId } : {}),
285
319
  ...(reachHost !== undefined ? { reachHost } : {}),
@@ -293,6 +327,7 @@ export class ProductService {
293
327
  };
294
328
  await this.store.update((products) => [...products, registration]);
295
329
  logger.info(`Product '${manifest.name}' registered (${spec.kind})`);
330
+ this.onRolesDeclared?.(manifest.name, manifest.access?.roles ?? [], { pending: [] });
296
331
  // defaultProductTemplates: fetch each declared URL relative to the
297
332
  // product's base and hand the bytes to the host's product-template store.
298
333
  // Failures here are downgraded to warnings rather than rolling back
@@ -339,6 +374,7 @@ export class ProductService {
339
374
  // for a product nothing routes to. Safe because every launch mints afresh,
340
375
  // so a re-added product of the same name is stamped anew.
341
376
  this.forgetIdentity?.(name);
377
+ this.onRemoved?.(name);
342
378
  logger.info(`Product '${name}' removed`);
343
379
  }
344
380
  /** Bring a registration up to date with what its image now serves. For a
@@ -383,12 +419,21 @@ export class ProductService {
383
419
  const refusal = this.refuseUnknownCapabilities(manifest);
384
420
  if (refusal)
385
421
  throw refusal;
422
+ const roleConflict = this.refuseRoleConflicts(manifest, this.store.read());
423
+ if (roleConflict)
424
+ throw roleConflict;
386
425
  if (manifest.name !== target.name) {
387
426
  throw new ProductError("NAME_CONFLICT", `manifest now reports name '${manifest.name}', was '${target.name}' — use remove + add`);
388
427
  }
389
- const updated = await this.store.update((products) => products.map((p) => (p.name === name ? { ...p, manifest } : p)));
428
+ // A reload may narrow what is accepted, never widen it: a default the
429
+ // operator did not see at add is pending until the product is re-added.
430
+ const declaredDefaults = roleDefaultsOf(manifest);
431
+ const accepted = declaredDefaults.filter((d) => sameDefault(target.acceptedRoleDefaults ?? [], d));
432
+ const pending = declaredDefaults.filter((d) => !sameDefault(accepted, d));
433
+ const updated = await this.store.update((products) => products.map((p) => (p.name === name ? { ...p, manifest, acceptedRoleDefaults: accepted } : p)));
390
434
  const registration = updated.find((p) => p.name === name);
391
435
  logger.info(`Product '${name}' reloaded${containerRestarted ? " (container re-run)" : ""}`);
436
+ this.onRolesDeclared?.(name, manifest.access?.roles ?? [], { pending });
392
437
  // Same downgrade as add's default-template import: the product itself is
393
438
  // healthy, so a template the host refuses (in use, foreign) or a fetch
394
439
  // that fails is a warning the operator acts on, not a failed reload.
@@ -537,3 +582,22 @@ function withContainerId(reg, containerId, reachHost) {
537
582
  function withPlacement(reg, containerId, hostPort, reachHost) {
538
583
  return { ...withContainerId(reg, containerId, reachHost), port: hostPort };
539
584
  }
585
+ /** The `defaultFor` pairs a manifest declares. */
586
+ function roleDefaultsOf(manifest) {
587
+ return (manifest.access?.roles ?? []).flatMap((r) => r.defaultFor === undefined ? [] : [{ name: r.name, defaultFor: r.defaultFor }]);
588
+ }
589
+ function sameDefault(set, d) {
590
+ return set.some((a) => a.name === d.name && a.defaultFor === d.defaultFor);
591
+ }
592
+ /**
593
+ * A registered product's roles as they take effect (RFC 0003 D5): every
594
+ * declared role, carrying its `defaultFor` only where the operator accepted
595
+ * that exact pair. What the host reads, so an unaccepted widening grants
596
+ * nobody anything.
597
+ */
598
+ export function effectiveRoleDeclarations(registration) {
599
+ const accepted = registration.acceptedRoleDefaults ?? [];
600
+ return (registration.manifest.access?.roles ?? []).map((r) => r.defaultFor !== undefined && sameDefault(accepted, { name: r.name, defaultFor: r.defaultFor })
601
+ ? { name: r.name, defaultFor: r.defaultFor }
602
+ : { name: r.name });
603
+ }
@@ -1,3 +1,4 @@
1
+ import type { PlatformRole } from "@norskvideo/ctl-foundation";
1
2
  import type { Manifest } from "./manifest-schema.js";
2
3
  export type ProductSpec =
3
4
  /** `env` is the deploy-time environment for the control-plane container,
@@ -38,4 +39,17 @@ export interface ProductRegistration {
38
39
  /** Absent on registrations that predate per-product licenses — launches
39
40
  * fall back to the host's global license setting. */
40
41
  license?: ProductLicense;
42
+ /**
43
+ * The `defaultFor` pairs an operator has accepted for this product (RFC 0003
44
+ * D5). Set to the declared defaults at add — the declaration the operator
45
+ * saw — and only ever narrowed by a reload: a reload that widens a default
46
+ * leaves the widened pair out, reported as pending. Absent on registrations
47
+ * from before acceptance existed, which accepted nothing.
48
+ */
49
+ acceptedRoleDefaults?: RoleDefault[];
50
+ }
51
+ /** One accepted `defaultFor`: the role and the rung it defaults to. */
52
+ export interface RoleDefault {
53
+ name: string;
54
+ defaultFor: PlatformRole;
41
55
  }