@xemahq/xema 0.41.1 → 0.43.0

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/edition/lib.mjs CHANGED
@@ -40,6 +40,7 @@ import { isKernelCapabilityDomain } from './kernel-capability-domain.mjs';
40
40
  import {
41
41
  PRINCIPAL_PRIVILEGE_FIELDS,
42
42
  assertPrivilegesHavePrincipals,
43
+ componentInstalledOperationRequests,
43
44
  componentOperationRequirements,
44
45
  componentOptionalOperationRequirements,
45
46
  componentOrganizationSweepRequirements,
@@ -686,6 +687,64 @@ const kernelDistributionDist = () => kernelDistPath('distribution', 'index.js');
686
687
  // through. A sibling of the distribution dist, loaded beside it.
687
688
  const kernelProcessTopologyDist = () => kernelDistPath('process-topology', 'index.js');
688
689
 
690
+ /** A composed-process subject, on a kernel that cannot write image-lock schemaVersion 4. */
691
+ export class ComposedProcessNeedsLockV4Error extends Error {
692
+ constructor(message) {
693
+ super(message);
694
+ this.name = 'ComposedProcessNeedsLockV4Error';
695
+ }
696
+ }
697
+
698
+ /** The image-lock revision of a kernel that predates `IMAGE_LOCK_WRITE_SCHEMA_VERSION` (edition protocol < v14). */
699
+ const PRE_V14_IMAGE_LOCK_SCHEMA_VERSION = 3;
700
+
701
+ /**
702
+ * Refuse a composed-process subject when the loaded kernel predates image-lock
703
+ * schemaVersion 4 (it exports no `IMAGE_LOCK_WRITE_SCHEMA_VERSION`): such a
704
+ * kernel has no `members[]`, so it cannot say which services the composed
705
+ * image carries. There is no fallback.
706
+ */
707
+ export function assertKernelSealsComposedMembership(schemas, subjects, label) {
708
+ if (Number.isInteger(schemas.IMAGE_LOCK_WRITE_SCHEMA_VERSION)) {return;}
709
+ const composed = (subjects ?? []).find((subject) => subject.kind === 'composed-process');
710
+ if (composed === undefined) {return;}
711
+ throw new ComposedProcessNeedsLockV4Error(
712
+ `${label}: composed-process subject "${composed.id}" — a composed-process subject needs a kernel that ` +
713
+ 'writes image-lock schemaVersion 4 (edition protocol >= v14); the loaded kernel ' +
714
+ `(${kernelRepoDir()}) exports no IMAGE_LOCK_WRITE_SCHEMA_VERSION`,
715
+ );
716
+ }
717
+
718
+ /**
719
+ * The READ side of the same rule, for a kernel before image-lock
720
+ * schemaVersion 4 (no `ImageLockSchemaVersion`): it reads only schemaVersion
721
+ * 3, which names no composed members, so a v3 lock carrying a composed-process
722
+ * subject cannot say which services run from it and is refused (worded as the
723
+ * kernel's own `LegacyComposedImageLockError`).
724
+ */
725
+ export function assertLegacyLockHasNoComposedSubject(lock) {
726
+ const composed = (lock.subjects ?? []).find((subject) => subject.kind === 'composed-process');
727
+ if (composed === undefined) {return;}
728
+ throw new ComposedProcessNeedsLockV4Error(
729
+ `distribution "${lock.distributionId}": subjects "${composed.id}" is a schemaVersion ` +
730
+ `${String(lock.schemaVersion)} composed-process subject, which names no members: regenerate with a CLI ` +
731
+ 'that writes image-lock schemaVersion 4, against a kernel at edition protocol >= v14',
732
+ );
733
+ }
734
+
735
+ /**
736
+ * The image-lock schemaVersion a writer emits for `subjects`: the loaded
737
+ * kernel's `IMAGE_LOCK_WRITE_SCHEMA_VERSION`. A kernel before edition protocol
738
+ * v14 exports none and writes only schemaVersion 3, which is allowed only when
739
+ * there is NO composed-process subject
740
+ * ({@link ComposedProcessNeedsLockV4Error} otherwise).
741
+ */
742
+ export function imageLockWriteSchemaVersion(schemas, subjects, label = 'images.lock.json') {
743
+ assertKernelSealsComposedMembership(schemas, subjects, label);
744
+ const version = schemas.IMAGE_LOCK_WRITE_SCHEMA_VERSION;
745
+ return Number.isInteger(version) ? version : PRE_V14_IMAGE_LOCK_SCHEMA_VERSION;
746
+ }
747
+
689
748
  /**
690
749
  * Dynamically import the BUILT kernel distribution schemas. The kernel package
691
750
  * is NOT linked into the repo-root node_modules (it lives in its own sub-
@@ -737,6 +796,8 @@ export async function loadKernelDistributionSchemas() {
737
796
  parseOciDigest,
738
797
  parseGitTreeId,
739
798
  parseImageLock,
799
+ IMAGE_LOCK_WRITE_SCHEMA_VERSION,
800
+ ImageLockSchemaVersion,
740
801
  parseImageProducerStatement,
741
802
  parseDistributionImagesStatement,
742
803
  parseImageDeliveryEntitlementStatement,
@@ -789,6 +850,8 @@ export async function loadKernelDistributionSchemas() {
789
850
  admitOperationGrants,
790
851
  admitOrganizationSweeps,
791
852
  contractRequiredServiceTargets,
853
+ admitInstalledOperationCeiling,
854
+ admitStoreWorkloadCeiling,
792
855
  } = mod;
793
856
  let topology;
794
857
  try {
@@ -886,6 +949,11 @@ export async function loadKernelDistributionSchemas() {
886
949
  typeof admitOperationGrants !== 'function' ||
887
950
  typeof admitOrganizationSweeps !== 'function' ||
888
951
  typeof contractRequiredServiceTargets !== 'function' ||
952
+ // The installed-operation ceiling and the edition's store-workload ceiling,
953
+ // both admitted at the lock (`assertInstalledOperationCeilingAdmitted`,
954
+ // `assertStoreWorkloadCeilingAdmitted`).
955
+ typeof admitInstalledOperationCeiling !== 'function' ||
956
+ typeof admitStoreWorkloadCeiling !== 'function' ||
889
957
  typeof distributionImagesPredicateLockRefusal !== 'function' ||
890
958
  typeof distributionLockDigest !== 'function' ||
891
959
  typeof imageDeliveryEntitlementRefusal !== 'function' ||
@@ -907,7 +975,7 @@ export async function loadKernelDistributionSchemas() {
907
975
  `resolveRuntimeImagePlatforms/RuntimeImagePlatformKey/` +
908
976
  `kernel contract-authority digest SSOT/compareLockedServiceResources/resolveNexusBindings/describeNexusBindingRefusal/canonicalNexusFacts/` +
909
977
  `positive closure (resolveBiomeClosure/platformInvariantRoots/DistributionResolutionMode/provenance)/` +
910
- `deriveWorkflowSubjects/deriveTrustCeiling (>= 0.71.0)/admitOperationGrants/admitOrganizationSweeps/` +
978
+ `deriveWorkflowSubjects/deriveTrustCeiling (>= 0.71.0)/admitOperationGrants/admitOrganizationSweeps/admitInstalledOperationCeiling/admitStoreWorkloadCeiling/` +
911
979
  `defaultTrustDomain/FIRST_PARTY_TRUST_DOMAIN/ProcessTrustPolicySchema/DistributionTrustPolicySchema/LockedStorePublisherSchema/` +
912
980
  `resolveFirstPartyCapabilityDomains/DistributionVisibility/` +
913
981
  `BiomeInstallPolicy/BiomeLaunchMode/ImageSourceHashAlgorithm/runtime-image enums — ` +
@@ -933,6 +1001,8 @@ export async function loadKernelDistributionSchemas() {
933
1001
  parseOciDigest,
934
1002
  parseGitTreeId,
935
1003
  parseImageLock,
1004
+ IMAGE_LOCK_WRITE_SCHEMA_VERSION,
1005
+ ImageLockSchemaVersion,
936
1006
  parseImageProducerStatement,
937
1007
  parseDistributionImagesStatement,
938
1008
  parseImageDeliveryEntitlementStatement,
@@ -992,6 +1062,8 @@ export async function loadKernelDistributionSchemas() {
992
1062
  admitOperationGrants,
993
1063
  admitOrganizationSweeps,
994
1064
  contractRequiredServiceTargets,
1065
+ admitInstalledOperationCeiling,
1066
+ admitStoreWorkloadCeiling,
995
1067
  distributionImagesPredicateLockRefusal,
996
1068
  imageDeliveryEntitlementRefusal,
997
1069
  producerWorkflowPinRefusal,
@@ -1623,7 +1695,7 @@ export async function manifestToIndexEntry({ raw, generatedIndex, rel, repo, pat
1623
1695
  'resolve no worker image: a worker declares artifact.kind "oci-image" or "shared-oci-image"',
1624
1696
  );
1625
1697
  }
1626
- // A privilege (`requiresOperations`, `optionalOperations`, `requiresOrganizationSweeps`) is admitted for a lock workload
1698
+ // A privilege (`requiresOperations`, `optionalOperations`, `requestsInstalledOperations`, `requiresOrganizationSweeps`) is admitted for a lock workload
1627
1699
  // principal only; on any other component it is refused, never dropped.
1628
1700
  assertPrivilegesHavePrincipals(components, imageComponents, rel);
1629
1701
  // A biome must declare its images in EXACTLY ONE shape. Declaring both a
@@ -1674,6 +1746,10 @@ export async function manifestToIndexEntry({ raw, generatedIndex, rel, repo, pat
1674
1746
  // (`optionalOperations`), keyed like `operationRequirements`. Declared
1675
1747
  // intent only: the seal grants, leaves ungranted or refuses each one.
1676
1748
  const optionalOperationRequirements = {};
1749
+ // `component → the operations it asks to exercise for the installing
1750
+ // organization` (`requestsInstalledOperations`), keyed like
1751
+ // `operationRequirements`. Declared intent only: the lock admits the ceiling.
1752
+ const installedOperationRequests = {};
1677
1753
  // `component → the organization sweeps it may hold`
1678
1754
  // (`requiresOrganizationSweeps`), keyed like `operationRequirements`.
1679
1755
  // Declared intent only: the seal admits or refuses each one.
@@ -1867,6 +1943,8 @@ export async function manifestToIndexEntry({ raw, generatedIndex, rel, repo, pat
1867
1943
  if (operations !== null) {operationRequirements[name] = operations;}
1868
1944
  const optionalOperations = componentOptionalOperationRequirements(component, where);
1869
1945
  if (optionalOperations !== null) {optionalOperationRequirements[name] = optionalOperations;}
1946
+ const installed = componentInstalledOperationRequests(component, where);
1947
+ if (installed !== null) {installedOperationRequests[name] = installed;}
1870
1948
  const sweeps = componentOrganizationSweepRequirements(component, where);
1871
1949
  if (sweeps !== null) {organizationSweepRequirements[name] = sweeps;}
1872
1950
  const queues = componentWorkerTaskQueues(component, protocol, where);
@@ -2311,6 +2389,17 @@ export async function manifestToIndexEntry({ raw, generatedIndex, rel, repo, pat
2311
2389
  ),
2312
2390
  }
2313
2391
  : {}),
2392
+ // The operations each workload component asks to exercise for the
2393
+ // installing organization; omitted when none declares any.
2394
+ ...(Object.keys(installedOperationRequests).length > 0
2395
+ ? {
2396
+ installedOperationRequests: Object.fromEntries(
2397
+ Object.entries(installedOperationRequests).sort(([a], [b]) =>
2398
+ compareStrings(a, b),
2399
+ ),
2400
+ ),
2401
+ }
2402
+ : {}),
2314
2403
  // The organization sweeps each workload component may hold; omitted when
2315
2404
  // none declares any.
2316
2405
  ...(Object.keys(organizationSweepRequirements).length > 0
@@ -2920,8 +3009,16 @@ export function validateImmutableImageLock(lock, schemas) {
2920
3009
  if (!schemas) {
2921
3010
  throw new Error('trusted kernel distribution schemas are required');
2922
3011
  }
3012
+ assertKernelSealsComposedMembership(schemas, lock.subjects, `distribution "${lock.distributionId}"`);
3013
+ // The revisions the loaded kernel reads (`ImageLockSchemaVersion`: 3 without
3014
+ // composed membership, 4 with it); a kernel before image-lock v4 reads only 3.
3015
+ if (schemas.ImageLockSchemaVersion === undefined) {assertLegacyLockHasNoComposedSubject(lock);}
3016
+ const readableVersions =
3017
+ schemas.ImageLockSchemaVersion === undefined
3018
+ ? [PRE_V14_IMAGE_LOCK_SCHEMA_VERSION]
3019
+ : Object.values(schemas.ImageLockSchemaVersion).filter((version) => Number.isInteger(version));
2923
3020
  if (
2924
- lock.schemaVersion !== 3 ||
3021
+ !readableVersions.includes(lock.schemaVersion) ||
2925
3022
  lock.sourceHashAlgorithm !== schemas.ImageSourceHashAlgorithm.ImageInputV2
2926
3023
  ) {
2927
3024
  throw new Error(
@@ -5,7 +5,9 @@
5
5
  * `operationRequirements`); the kernel's `admitOperationGrants` admits or
6
6
  * refuses every requirement, and a refusal is never a warning. The resolve
7
7
  * and the seal both call {@link assertOperationGrantsAdmitted}, so a lock with
8
- * a refused requirement is neither written nor sealed.
8
+ * a refused requirement is neither written nor sealed. The installed-operation
9
+ * ceiling (`requestsInstalledOperations`, carried as `installedOperationRequests`)
10
+ * and the edition's store-workload ceiling are admitted the same way.
9
11
  */
10
12
 
11
13
  /** Thrown when one or more declared operation requirements are not admitted. */
@@ -83,3 +85,65 @@ export function assertOrganizationSweepsAdmitted(schemas, lock) {
83
85
  }
84
86
  return grants;
85
87
  }
88
+
89
+ /** Thrown when one or more requested installed operations are not admitted into the ceiling. */
90
+ export class InstalledOperationCeilingAdmissionError extends Error {
91
+ /**
92
+ * @param {string} distributionId
93
+ * @param {ReadonlyArray<{code: string, biome: string, component: string, operation: string, message: string}>} refusals
94
+ */
95
+ constructor(distributionId, refusals) {
96
+ super(
97
+ `distribution "${distributionId}" declares installed operation requests that are not admitted:\n` +
98
+ refusals.map((refusal) => `[${refusal.code}] ${refusal.message}`).join('\n'),
99
+ );
100
+ this.name = 'InstalledOperationCeilingAdmissionError';
101
+ this.distributionId = distributionId;
102
+ this.refusals = refusals;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Admit every installed-operation request of `lock` through the kernel
108
+ * (`admitInstalledOperationCeiling`), or throw
109
+ * {@link InstalledOperationCeilingAdmissionError} listing every refusal.
110
+ * Returns the admitted ceiling rows.
111
+ */
112
+ export function assertInstalledOperationCeilingAdmitted(schemas, lock) {
113
+ const { ceilings, refusals } = schemas.admitInstalledOperationCeiling(lock);
114
+ if (refusals.length > 0) {
115
+ throw new InstalledOperationCeilingAdmissionError(lock.distributionId, refusals);
116
+ }
117
+ return ceilings;
118
+ }
119
+
120
+ /** Thrown when one or more domains of the edition's store-workload ceiling are not admitted. */
121
+ export class StoreWorkloadCeilingAdmissionError extends Error {
122
+ /**
123
+ * @param {string} distributionId
124
+ * @param {ReadonlyArray<{code: string, domain: string, message: string}>} refusals
125
+ */
126
+ constructor(distributionId, refusals) {
127
+ super(
128
+ `distribution "${distributionId}" declares a store-workload ceiling that is not admitted:\n` +
129
+ refusals.map((refusal) => `[${refusal.code}] ${refusal.message}`).join('\n'),
130
+ );
131
+ this.name = 'StoreWorkloadCeilingAdmissionError';
132
+ this.distributionId = distributionId;
133
+ this.refusals = refusals;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Admit the store-workload ceiling of `lock` through the kernel
139
+ * (`admitStoreWorkloadCeiling`), or throw
140
+ * {@link StoreWorkloadCeilingAdmissionError} listing every refusal. Returns the
141
+ * resolved ceiling.
142
+ */
143
+ export function assertStoreWorkloadCeilingAdmitted(schemas, lock) {
144
+ const { ceiling, refusals } = schemas.admitStoreWorkloadCeiling(lock);
145
+ if (refusals.length > 0) {
146
+ throw new StoreWorkloadCeilingAdmissionError(lock.distributionId, refusals);
147
+ }
148
+ return ceiling;
149
+ }
@@ -81,6 +81,18 @@ export function componentOptionalOperationRequirements(component, where) {
81
81
  return declaredPrivilegeList(component, where, 'optionalOperations', OperationIdSchema, 'operation id');
82
82
  }
83
83
 
84
+ /**
85
+ * The operations one workload component asks to exercise for the organization
86
+ * that installs its biome (`requestsInstalledOperations`), graded by the same
87
+ * operation-id grammar. Declared INTENT only; the lock admits the ceiling of
88
+ * the request (`admitInstalledOperationCeiling`), and an installed operation is
89
+ * never a deployment grant.
90
+ */
91
+ export function componentInstalledOperationRequests(component, where) {
92
+ const { OperationIdSchema } = loadKernelPrivilegeGrammars();
93
+ return declaredPrivilegeList(component, where, 'requestsInstalledOperations', OperationIdSchema, 'operation id');
94
+ }
95
+
84
96
  /**
85
97
  * The organization sweeps one workload component declares it may hold
86
98
  * (`requiresOrganizationSweeps`), each a member of the kernel's closed
@@ -96,7 +108,12 @@ export function componentOrganizationSweepRequirements(component, where) {
96
108
  * The privilege declarations a component may carry only if it is a workload
97
109
  * principal in the lock.
98
110
  */
99
- export const PRINCIPAL_PRIVILEGE_FIELDS = Object.freeze(['requiresOperations', 'optionalOperations', 'requiresOrganizationSweeps']);
111
+ export const PRINCIPAL_PRIVILEGE_FIELDS = Object.freeze([
112
+ 'requiresOperations',
113
+ 'optionalOperations',
114
+ 'requestsInstalledOperations',
115
+ 'requiresOrganizationSweeps',
116
+ ]);
100
117
 
101
118
  /**
102
119
  * Thrown when a component that has no workload principal in the lock declares
@@ -37,7 +37,7 @@ import { assertRosteredChartSetLock } from './edition-chart-set.mjs';
37
37
  import { EditionLockFile } from './edition-constants.mjs';
38
38
  import { editionsRoot } from './edition-workspace.mjs';
39
39
  import { adoptCatalogueKernel } from './kernel-package.mjs';
40
- import { compareStrings, serializeJson, validateImmutableImageLock } from './lib.mjs';
40
+ import { compareStrings, imageLockWriteSchemaVersion, serializeJson, validateImmutableImageLock } from './lib.mjs';
41
41
  import { pinnedSource, snapshotPinSource } from './pin-source.mjs';
42
42
  import { composeAuthorizationBase, composeManifest, resolveDistributionWithIndex, UnknownSelectionError } from './resolve-distribution.mjs';
43
43
  import { resolveImageRows } from './resolve-images.mjs';
@@ -238,13 +238,14 @@ export async function resolveCustomerImages({ id, catalogue, lock, composed, sch
238
238
  const seen = new Set(vendorSubjects.map(subjectKey));
239
239
  const clash = own.subjects.find((subject) => seen.has(subjectKey(subject)));
240
240
  if (clash !== undefined) {throw new Error(`the customer's own image ${clash.kind}:${clash.id} collides with a catalogue image of the same identity`);}
241
+ const mergedSubjects = [...vendorSubjects, ...own.subjects].sort(compareSubjects);
241
242
  const merged = validateImmutableImageLock(
242
243
  schemas.parseImageLock({
243
- schemaVersion: 3,
244
+ schemaVersion: imageLockWriteSchemaVersion(schemas, mergedSubjects, `distribution "${id}"`),
244
245
  sourceHashAlgorithm: catalogue.images.sourceHashAlgorithm,
245
246
  distributionId: id,
246
247
  contractAuthority: catalogue.contractAuthority,
247
- subjects: [...vendorSubjects, ...own.subjects].sort(compareSubjects),
248
+ subjects: mergedSubjects,
248
249
  platformServiceBindings: [...vendor.platformServiceBindings, ...own.platformServiceBindings].sort((a, b) => compareStrings(a.platformService, b.platformService)),
249
250
  webBiomes: [...vendor.webBiomes, ...own.webBiomes].sort((a, b) => compareStrings(a.biomeId, b.biomeId)),
250
251
  }),
@@ -95,8 +95,10 @@ import {
95
95
  validateServiceDependencyOwnerParity,
96
96
  } from './lib.mjs';
97
97
  import {
98
+ assertInstalledOperationCeilingAdmitted,
98
99
  assertOperationGrantsAdmitted,
99
100
  assertOrganizationSweepsAdmitted,
101
+ assertStoreWorkloadCeilingAdmitted,
100
102
  } from './operation-grant-admission.mjs';
101
103
  import { PUBLISHED_PIN_SOURCE, pinnedSource } from './pin-source.mjs';
102
104
  import { verifiedPlatformServiceIndex } from './platform-service-ownership.mjs';
@@ -307,6 +309,7 @@ export function composeManifest(manifest, pinSource = PUBLISHED_PIN_SOURCE) {
307
309
  // Visibility is a RESTRICTION; the caller (which holds the kernel enum)
308
310
  // reduces the declarations.
309
311
  declaredVisibilities: manifest.visibility === undefined ? [] : [manifest.visibility],
312
+ storeWorkloadCeiling: manifest.storeWorkloadCeiling,
310
313
  declaredSourceNames: new Set(sources.map((source) => source.name)),
311
314
  declaredSources: sources.map((source) => pinnedSource(source, pinSource)),
312
315
  owner: manifest.owner,
@@ -1159,6 +1162,7 @@ export function resolveDistributionFromInputs(
1159
1162
  trustPolicy,
1160
1163
  trustCeiling,
1161
1164
  declaredVisibilities,
1165
+ storeWorkloadCeiling,
1162
1166
  owner,
1163
1167
  leaf,
1164
1168
  // Absent on a selector-model composition — including every `composed` a
@@ -2047,6 +2051,13 @@ export function resolveDistributionFromInputs(
2047
2051
  Object.keys(e.optionalOperationRequirements).length > 0
2048
2052
  ? { optionalOperationRequirements: e.optionalOperationRequirements }
2049
2053
  : {}),
2054
+ // The operations each workload component asks to exercise for the
2055
+ // installing organization, forwarded verbatim; the lock admits their
2056
+ // ceiling (`admitInstalledOperationCeiling`).
2057
+ ...(e.installedOperationRequests !== undefined &&
2058
+ Object.keys(e.installedOperationRequests).length > 0
2059
+ ? { installedOperationRequests: e.installedOperationRequests }
2060
+ : {}),
2050
2061
  // The organization sweeps each workload component may hold, forwarded
2051
2062
  // verbatim; the seal admits them (`admitOrganizationSweeps`).
2052
2063
  ...(e.organizationSweepRequirements !== undefined &&
@@ -2202,6 +2213,9 @@ export function resolveDistributionFromInputs(
2202
2213
  // last for the same reason: an edition without it carries exactly the bytes it
2203
2214
  // always did. Not in `inputHash`; the kernel lock schema validates it.
2204
2215
  if (composed?.storePublisherTrust !== undefined) {lock.storePublisherTrust = composed.storePublisherTrust;}
2216
+ // The edition's store-workload ceiling — written ONLY when the edition
2217
+ // declares one (the kernel refuses an empty list), carried unchanged.
2218
+ if (storeWorkloadCeiling !== undefined) {lock.storeWorkloadCeiling = storeWorkloadCeiling;}
2205
2219
 
2206
2220
  // (7) Re-derive the ownership index through the KERNEL so the table this
2207
2221
  // resolver emits is validated by the exact code every consumer will read
@@ -2221,6 +2235,8 @@ export function resolveDistributionFromInputs(
2221
2235
  // never fails (the resolve and `edition check` both run through here).
2222
2236
  assertOperationGrantsAdmitted(schemas, parsed, { report: (line) => console.log(`[operations] ${line}`) });
2223
2237
  assertOrganizationSweepsAdmitted(schemas, parsed);
2238
+ assertInstalledOperationCeilingAdmitted(schemas, parsed);
2239
+ assertStoreWorkloadCeilingAdmitted(schemas, parsed);
2224
2240
  return validateImmutableDistributionLock(lock);
2225
2241
  }
2226
2242
 
@@ -61,6 +61,7 @@ import {
61
61
  compareStrings,
62
62
  hashImageBuildInputs,
63
63
  imageInputSourceHashAlgorithm,
64
+ imageLockWriteSchemaVersion,
64
65
  loadKernelDistributionSchemas,
65
66
  parseResolvedDistributionLock,
66
67
  readGitJsonAtCommit,
@@ -970,6 +971,13 @@ export function editionSourceIds(lock) {
970
971
  * caller can tell "this image's inputs are unknown" from any other refusal:
971
972
  * the carried set is evidence, and with none there is no owner to derive.
972
973
  */
974
+ export class ComposedProcessWorkerError extends Error {
975
+ constructor(message) {
976
+ super(message);
977
+ this.name = 'ComposedProcessWorkerError';
978
+ }
979
+ }
980
+
973
981
  export class ComposedProcessInputsError extends Error {
974
982
  constructor(message) {
975
983
  super(message);
@@ -1009,16 +1017,31 @@ function composedProcessCarriage(structure, enums, repo, sourceCommit, locate) {
1009
1017
  const byBiome = new Map();
1010
1018
  for (const subject of structure.subjects) {
1011
1019
  if (subject.kind !== enums.RuntimeImageSubjectKind.Service || subject.repo !== repo) {continue;}
1012
- const servicePath = subjectServicePath(subject, sourceCommit, locate);
1020
+ const { path: servicePath, componentKind } = subjectServiceSource(subject, sourceCommit, locate);
1013
1021
  for (const owner of subject.owners) {
1014
1022
  if (owner.kind !== enums.RuntimeImageOwnerKind.Biome) {continue;}
1015
- const row = byBiome.get(owner.biomeId) ?? { biomeId: owner.biomeId, carried: [], uncarried: [] };
1016
- (carried.has(servicePath) ? row.carried : row.uncarried).push(subject.id);
1023
+ const row = byBiome.get(owner.biomeId) ??
1024
+ { biomeId: owner.biomeId, carried: [], uncarried: [], carriedWorkers: [], packageDirs: {} };
1025
+ if (!carried.has(servicePath)) {
1026
+ row.uncarried.push(subject.id);
1027
+ } else if (componentKind === 'worker') {
1028
+ // A worker is never a member: it runs as its own Deployment.
1029
+ row.carriedWorkers.push(subject.id);
1030
+ } else {
1031
+ row.carried.push(subject.id);
1032
+ // The member's package directory IS the composed build's own input entry.
1033
+ row.packageDirs[subject.id] = servicePath;
1034
+ }
1017
1035
  byBiome.set(owner.biomeId, row);
1018
1036
  }
1019
1037
  }
1020
1038
  return [...byBiome.values()]
1021
- .map((row) => ({ ...row, carried: row.carried.sort(compareStrings), uncarried: row.uncarried.sort(compareStrings) }))
1039
+ .map((row) => ({
1040
+ ...row,
1041
+ carried: row.carried.sort(compareStrings),
1042
+ uncarried: row.uncarried.sort(compareStrings),
1043
+ carriedWorkers: row.carriedWorkers.sort(compareStrings),
1044
+ }))
1022
1045
  .sort((a, b) => compareStrings(a.biomeId, b.biomeId));
1023
1046
  }
1024
1047
 
@@ -1087,8 +1110,15 @@ export function readComposedProcessDeclarations(
1087
1110
  * resolve derive the same subject from the same declarations.
1088
1111
  *
1089
1112
  * Owners are the biomes the image CARRIES, derived from the composed build's
1090
- * inputs at the sealed commit (`declaration.carriage`): the biomes all of whose
1091
- * services are members, never every biome with a service in the repository.
1113
+ * inputs at the sealed commit (`declaration.carriage`): every biome at least one
1114
+ * of whose services is a member, never every biome with a service in the
1115
+ * repository. The members leave the structure and are sealed on the subject
1116
+ * as `members[{ service, packageDir }]` — the per-service membership truth: a
1117
+ * service runs from the composed digest if and only if it is a row (owners
1118
+ * never resolve a service). The edition carries the composed subject plus the
1119
+ * services that are no row (the specials, which keep their own subjects even
1120
+ * inside an owner biome), and nothing addresses a member's own image. A worker
1121
+ * is never a row; an own-image worker the composed build carries is refused.
1092
1122
  */
1093
1123
  export function withComposedProcessSubjects(
1094
1124
  structure,
@@ -1099,6 +1129,9 @@ export function withComposedProcessSubjects(
1099
1129
  ) {
1100
1130
  if (declarations.length === 0) {return structure;}
1101
1131
  const subjects = new Map(structure.subjects.map((subject) => [subjectKey(subject), subject]));
1132
+ // A biome's members run from ONE composed image: a biome owning two composed
1133
+ // subjects would leave its pods two images to choose between.
1134
+ const composedOwnerOf = new Map();
1102
1135
  for (const declaration of declarations) {
1103
1136
  const services = structure.subjects.filter(
1104
1137
  (subject) =>
@@ -1110,21 +1143,47 @@ export function withComposedProcessSubjects(
1110
1143
  'biomes the image carries — refusing to guess its owners',
1111
1144
  );
1112
1145
  }
1113
- // Owners are the biomes ALL of whose services the image carries. A biome
1114
- // with only some carried deploys dedicated, so it is no owner (and is
1115
- // named); a biome with none carried is simply not one.
1146
+ // Owners are the biomes ANY of whose services the image carries. A biome
1147
+ // with only some carried is an owner too; its services the image does not
1148
+ // carry are specials and keep their own subjects (named here). A biome with
1149
+ // none carried is simply not one.
1116
1150
  const biomeOwners = [];
1151
+ const members = new Set();
1152
+ const packageDirOf = new Map();
1117
1153
  for (const row of declaration.carriage) {
1154
+ // A worker is never a member. One the composed build carries must run
1155
+ // from a carried sibling's image (`workerImages`, declared in the
1156
+ // manifest), never be dropped into the composed process or fall back.
1157
+ for (const worker of row.carriedWorkers ?? []) {
1158
+ throw new ComposedProcessWorkerError(
1159
+ row.carried.length === 0
1160
+ ? `composed-process "${declaration.repo}" carries worker "${worker}" of biome "${row.biomeId}", ` +
1161
+ 'and that biome has no carried service whose image it could run — a worker is never a ' +
1162
+ 'composed member; carry a service of its biome or take the worker out of the composed inputs'
1163
+ : `composed-process "${declaration.repo}" carries worker "${worker}" of biome "${row.biomeId}" as ` +
1164
+ 'its own oci-image — a worker is never a composed member; declare it `artifact.kind: ' +
1165
+ `shared-oci-image\` of a carried service (${row.carried.join(', ')}) or take it out of the composed inputs`,
1166
+ );
1167
+ }
1118
1168
  if (row.carried.length === 0) {continue;}
1119
1169
  if (row.uncarried.length > 0) {
1120
1170
  onNotice(
1121
- `composed-process "${declaration.repo}": biome "${row.biomeId}" is only partly carried ` +
1171
+ `composed-process "${declaration.repo}": biome "${row.biomeId}" is partly carried ` +
1122
1172
  `(carried: ${row.carried.join(', ')}; not carried: ${row.uncarried.join(', ')}) — ` +
1123
- 'it is not an owner and deploys dedicated',
1173
+ 'its uncarried services keep their own subjects',
1124
1174
  );
1125
- continue;
1126
1175
  }
1176
+ const other = composedOwnerOf.get(row.biomeId);
1177
+ if (other !== undefined) {
1178
+ throw new Error(
1179
+ `biome "${row.biomeId}" is carried by the composed-process images of both "${other}" and ` +
1180
+ `"${declaration.repo}" — a biome is owned by exactly one composed subject`,
1181
+ );
1182
+ }
1183
+ composedOwnerOf.set(row.biomeId, declaration.repo);
1127
1184
  biomeOwners.push({ kind: enums.RuntimeImageOwnerKind.Biome, biomeId: row.biomeId });
1185
+ for (const id of row.carried) {members.add(id);}
1186
+ for (const [id, packageDir] of Object.entries(row.packageDirs ?? {})) {packageDirOf.set(id, packageDir);}
1128
1187
  }
1129
1188
  if (biomeOwners.length === 0) {
1130
1189
  throw new Error(
@@ -1162,6 +1221,43 @@ export function withComposedProcessSubjects(
1162
1221
  `(${[...namespaces].join(', ')}) — a composed image of one build has one`,
1163
1222
  );
1164
1223
  }
1224
+ // A MEMBER reaches its pods through this one subject (every member runs as a
1225
+ // group-of-one of the composed image), so it carries no subject of its own:
1226
+ // no `<imageRef>:<contentHash>` of its own is probed, verified or sealed. A
1227
+ // member is a service of this build the composed build's inputs carry
1228
+ // (`declaration.carriage`), every owner of which is a biome owning the
1229
+ // composed image. Any other service (not carried, or owned by anything but
1230
+ // a biome, e.g. shared with a platform service) keeps its own.
1231
+ const composedOwners = new Set(biomeOwners.map((owner) => owner.biomeId));
1232
+ const memberRows = [];
1233
+ for (const subject of services) {
1234
+ if (
1235
+ members.has(subject.id) &&
1236
+ subject.owners.length > 0 &&
1237
+ subject.owners.every(
1238
+ (owner) => owner.kind === enums.RuntimeImageOwnerKind.Biome && composedOwners.has(owner.biomeId),
1239
+ )
1240
+ ) {
1241
+ subjects.delete(subjectKey(subject));
1242
+ const packageDir = packageDirOf.get(subject.id);
1243
+ if (packageDir === undefined) {
1244
+ throw new ComposedProcessInputsError(
1245
+ `composed-process "${declaration.repo}": member "${subject.id}" has no package directory in ` +
1246
+ 'the composed build inputs — its shared-image workers would have nowhere to run from',
1247
+ );
1248
+ }
1249
+ memberRows.push({ service: subject.id, packageDir });
1250
+ }
1251
+ }
1252
+ if (memberRows.length === 0) {
1253
+ throw new ComposedProcessInputsError(
1254
+ `composed-process "${declaration.repo}" has no member: every service its build carries ` +
1255
+ `(${[...members].sort(compareStrings).join(', ')}) is also owned by something other than a biome ` +
1256
+ 'it owns (e.g. a platform service), so each keeps its own subject — a composed image with no ' +
1257
+ 'member runs nothing; take it out of the edition or carry a biome-owned service',
1258
+ );
1259
+ }
1260
+ memberRows.sort((a, b) => compareStrings(a.service, b.service));
1165
1261
  const id = composedProcessSubjectId(declaration.sourceId);
1166
1262
  addSubject(subjects, {
1167
1263
  kind: enums.RuntimeImageSubjectKind.ComposedProcess,
@@ -1174,6 +1270,7 @@ export function withComposedProcessSubjects(
1174
1270
  sourceManifestPath: declaration.sourceManifestPath,
1175
1271
  requiredPlatforms,
1176
1272
  owners: biomeOwners,
1273
+ members: memberRows,
1177
1274
  });
1178
1275
  }
1179
1276
  return {
@@ -1275,11 +1372,21 @@ function sourceCommitMap(structure, locate = FIRST_PARTY_TREES) {
1275
1372
  * service directory to hash the SAME build-input closure the lock names.
1276
1373
  */
1277
1374
  export function subjectServicePath(subject, sourceCommit, locate = FIRST_PARTY_TREES) {
1375
+ return subjectServiceSource(subject, sourceCommit, locate).path;
1376
+ }
1377
+
1378
+ /**
1379
+ * The repository-relative source directory of a subject and the kind of the
1380
+ * manifest component it is built from (`worker` for a worker component,
1381
+ * `service` otherwise — a legacy `ships.apis[]` entry or a subject with its own
1382
+ * source manifest is not a worker).
1383
+ */
1384
+ function subjectServiceSource(subject, sourceCommit, locate = FIRST_PARTY_TREES) {
1278
1385
  const { dir: repoDirectory, base } = locate(subject.repo);
1279
1386
  const toRepoRelative = (absolute) =>
1280
1387
  relative(repoDirectory, absolute).split(sep).join('/') || '.';
1281
1388
  if (subject.sourceManifestPath) {
1282
- return toRepoRelative(join(base, subject.path));
1389
+ return { path: toRepoRelative(join(base, subject.path)), componentKind: 'service' };
1283
1390
  }
1284
1391
  const biomePath = toRepoRelative(join(base, subject.path));
1285
1392
  const manifestPath =
@@ -1317,6 +1424,7 @@ export function subjectServicePath(subject, sourceCommit, locate = FIRST_PARTY_T
1317
1424
  );
1318
1425
  }
1319
1426
  let servicePath;
1427
+ let componentKind = 'service';
1320
1428
  if (v4ServiceComponents.length > 0) {
1321
1429
  // A SERVICE is identified by `protocol.serviceName`; a WORKER has no
1322
1430
  // service protocol and is identified by its component `key`. Matching only
@@ -1340,6 +1448,7 @@ export function subjectServicePath(subject, sourceCommit, locate = FIRST_PARTY_T
1340
1448
  );
1341
1449
  }
1342
1450
  servicePath = component.artifact.path;
1451
+ componentKind = component.kind;
1343
1452
  } else {
1344
1453
  if (legacyApis.length === 0) {
1345
1454
  throw new Error(
@@ -1357,7 +1466,7 @@ export function subjectServicePath(subject, sourceCommit, locate = FIRST_PARTY_T
1357
1466
  }
1358
1467
  servicePath = api.path;
1359
1468
  }
1360
- return toRepoRelative(join(base, subject.path, servicePath));
1469
+ return { path: toRepoRelative(join(base, subject.path, servicePath)), componentKind };
1361
1470
  }
1362
1471
 
1363
1472
  function assertSourceManifestParity(structure, commitByRepo, locate = FIRST_PARTY_TREES) {
@@ -1815,7 +1924,7 @@ export async function resolveImages(id, { inputs, lock, onSubjectHashed } = {})
1815
1924
  const schemas = await loadKernelDistributionSchemas();
1816
1925
  const imageLock = validateImmutableImageLock(
1817
1926
  schemas.parseImageLock({
1818
- schemaVersion: 3,
1927
+ schemaVersion: imageLockWriteSchemaVersion(schemas, subjects, `distribution "${id}"`),
1819
1928
  sourceHashAlgorithm: schemas.ImageSourceHashAlgorithm.ImageInputV2,
1820
1929
  distributionId: id,
1821
1930
  contractAuthority,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/xema",
3
- "version": "0.41.1",
3
+ "version": "0.43.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/xema-dev/xema-cli.git",
@@ -49,12 +49,12 @@
49
49
  "@nestjs/common": "^11.1.18",
50
50
  "@nestjs/core": "^11.1.18",
51
51
  "@nestjs/platform-express": "^11.1.28",
52
- "@xemahq/biome-sdk": "^18.1.0",
52
+ "@xemahq/biome-sdk": "^22.0.0",
53
53
  "@xemahq/biome-supply-chain": "^0.5.0",
54
54
  "@xemahq/distribution-source-hash": "^0.2.2",
55
55
  "@xemahq/dsl": "^0.24.0",
56
56
  "@xemahq/identity-client": "^0.16.0",
57
- "@xemahq/contracts": "^0.83.0",
57
+ "@xemahq/contracts": "^0.94.0",
58
58
  "@xemahq/kernel-state-runtime": "^0.8.0",
59
59
  "@xemahq/license-verifier": "^0.2.0",
60
60
  "@xemahq/service-registry-nest": "^0.12.2",
@@ -92,6 +92,6 @@
92
92
  "typecheck": "tsc --noEmit"
93
93
  },
94
94
  "xemaRelease": {
95
- "sourceDigest": "sha256:7e8af916b8cd5c8de331d7e274a7022071e6b6ac0d9d54042c2e55461ad46bab"
95
+ "sourceDigest": "sha256:abf054b00865c8a4b0bb44bccd5cc3c65162b8ea8dec47126bc0dfd837392fcd"
96
96
  }
97
97
  }