capdag 1.198.510 → 1.205.540

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/capdag.js CHANGED
@@ -1180,44 +1180,42 @@ const MediaDefErrorCodes = {
1180
1180
  * - `record` marker: presence = has internal fields, absence = opaque (default)
1181
1181
  *
1182
1182
  * Examples:
1183
- * - `media:pdf` → scalar, opaque (no markers)
1184
- * - `media:textable;list` → list, opaque (has list marker)
1185
- * - `media:json;textable;record` → scalar, record (has record marker)
1186
- * - `media:json;list;record;textable` → list of records (has both markers)
1183
+ * - `media:ext=pdf` → scalar, opaque (no markers)
1184
+ * - `media:enc=utf-8;list` → list, opaque (has list marker)
1185
+ * - `media:fmt=json;record` → scalar, record (has record marker)
1186
+ * - `media:fmt=json;list;record` → list of records (has both markers)
1187
1187
  */
1188
1188
 
1189
1189
  // Primitive types - URNs must match base.toml definitions
1190
1190
  // Media URN for void (no input/output) - no coercion tags
1191
1191
  const MEDIA_VOID = 'media:void';
1192
- // Media URN for string type - textable (can become text), scalar by default (no list marker)
1193
- const MEDIA_STRING = 'media:textable';
1194
- // Media URN for integer type - textable, numeric (math ops valid), scalar by default
1195
- const MEDIA_INTEGER = 'media:integer;textable;numeric';
1196
- // Media URN for number type - textable, numeric, scalar by default
1197
- const MEDIA_NUMBER = 'media:textable;numeric';
1192
+ // Media URN for string type — bare UTF-8 text (enc=utf-8), scalar by default (no list marker)
1193
+ const MEDIA_STRING = 'media:enc=utf-8';
1194
+ // Media URN for integer type numeric (math ops valid), scalar by default
1195
+ const MEDIA_INTEGER = 'media:integer;numeric';
1196
+ // Media URN for number type numeric, scalar by default
1197
+ const MEDIA_NUMBER = 'media:numeric';
1198
1198
  // Media URN for boolean type - uses "bool" not "boolean" per base.toml
1199
- const MEDIA_BOOLEAN = 'media:bool;textable';
1200
- // Media URN for a generic record/object type - has internal key-value structure but NOT textable
1201
- // Use MEDIA_JSON for textable JSON objects.
1199
+ const MEDIA_BOOLEAN = 'media:bool;enc=utf-8';
1200
+ // Media URN for a generic record/object type - has internal key-value structure,
1201
+ // no content-format claim. Use MEDIA_JSON for JSON-serialized objects.
1202
1202
  const MEDIA_OBJECT = 'media:record';
1203
- // Media URN for binary data - the most general media type (no constraints)
1203
+ // Media URN for the top type - the most general media type (no constraints)
1204
1204
  const MEDIA_IDENTITY = 'media:';
1205
1205
 
1206
1206
  // List types - URNs must match base.toml definitions
1207
1207
  // Media URN for generic list type
1208
1208
  const MEDIA_LIST = 'media:list';
1209
- // Media URN for textable list type
1210
- const MEDIA_TEXTABLE_LIST = 'media:list;textable';
1211
- // Media URN for string list type - textable with list marker
1212
- const MEDIA_STRING_LIST = 'media:list;textable';
1213
- // Media URN for integer list type - textable, numeric with list marker
1214
- const MEDIA_INTEGER_LIST = 'media:integer;list;textable;numeric';
1215
- // Media URN for number list type - textable, numeric with list marker
1216
- const MEDIA_NUMBER_LIST = 'media:list;numeric;textable';
1209
+ // Media URN for string list type — bare UTF-8 text with list marker
1210
+ const MEDIA_STRING_LIST = 'media:enc=utf-8;list';
1211
+ // Media URN for integer list type numeric with list marker
1212
+ const MEDIA_INTEGER_LIST = 'media:integer;list;numeric';
1213
+ // Media URN for number list type numeric with list marker
1214
+ const MEDIA_NUMBER_LIST = 'media:list;numeric';
1217
1215
  // Media URN for boolean list type - uses "bool" with list marker
1218
- const MEDIA_BOOLEAN_LIST = 'media:bool;list;textable';
1219
- // Media URN for object list type - list of records (NOT textable)
1220
- // Use a specific format like JSON array for textable object lists.
1216
+ const MEDIA_BOOLEAN_LIST = 'media:bool;enc=utf-8;list';
1217
+ // Media URN for object list type - list of records (no content-format claim).
1218
+ // Use a specific format like JSON array for serialized object lists.
1221
1219
  const MEDIA_OBJECT_LIST = 'media:list;record';
1222
1220
 
1223
1221
  // Semantic media types for specialized content
@@ -1274,104 +1272,104 @@ const MEDIA_EPUB = 'media:ext=epub';
1274
1272
 
1275
1273
  // Text format types (PRIMARY naming - type IS the format)
1276
1274
  // Media URN for Markdown text
1277
- const MEDIA_MD = 'media:ext=md;textable';
1275
+ const MEDIA_MD = 'media:enc=utf-8;ext=md';
1278
1276
  // Media URN for plain text
1279
- const MEDIA_TXT = 'media:ext=txt;textable';
1277
+ const MEDIA_TXT = 'media:enc=utf-8;ext=txt';
1280
1278
  // Media URN for reStructuredText
1281
- const MEDIA_RST = 'media:ext=rst;textable';
1279
+ const MEDIA_RST = 'media:enc=utf-8;ext=rst';
1282
1280
  // Media URN for log files
1283
- const MEDIA_LOG = 'media:ext=log;textable';
1281
+ const MEDIA_LOG = 'media:enc=utf-8;ext=log';
1284
1282
  // Media URN for HTML documents
1285
- const MEDIA_HTML = 'media:ext=html;textable';
1283
+ const MEDIA_HTML = 'media:enc=utf-8;ext=html';
1286
1284
  // Media URN for XML documents
1287
- const MEDIA_XML = 'media:ext=xml;textable';
1285
+ const MEDIA_XML = 'media:enc=utf-8;ext=xml';
1288
1286
  // Media URN for JSON data - has record marker (structured key-value)
1289
- const MEDIA_JSON = 'media:json;record;textable';
1287
+ const MEDIA_JSON = 'media:fmt=json;record';
1290
1288
  // Media URN for JSON with schema constraint (input for structured queries)
1291
- const MEDIA_JSON_SCHEMA = 'media:json;json-schema;record;textable';
1289
+ const MEDIA_JSON_SCHEMA = 'media:fmt=json;json-schema;record';
1292
1290
  // Media URN for YAML data - has record marker (structured key-value)
1293
- const MEDIA_YAML = 'media:record;textable;yaml';
1291
+ const MEDIA_YAML = 'media:fmt=yaml;record';
1294
1292
 
1295
1293
  // Format-specific variants for JSON, YAML, CSV
1296
- const MEDIA_JSON_VALUE = 'media:json;textable';
1297
- const MEDIA_JSON_RECORD = 'media:json;record;textable';
1298
- const MEDIA_JSON_LIST = 'media:json;list;textable';
1299
- const MEDIA_JSON_LIST_RECORD = 'media:json;list;record;textable';
1300
- const MEDIA_YAML_VALUE = 'media:textable;yaml';
1301
- const MEDIA_YAML_RECORD = 'media:record;textable;yaml';
1302
- const MEDIA_YAML_LIST = 'media:list;textable;yaml';
1303
- const MEDIA_YAML_LIST_RECORD = 'media:list;record;textable;yaml';
1304
- const MEDIA_CSV = 'media:ext=csv;list;record;textable';
1305
- const MEDIA_CSV_LIST = 'media:ext=csv;list;record;textable';
1294
+ const MEDIA_JSON_VALUE = 'media:fmt=json';
1295
+ const MEDIA_JSON_RECORD = 'media:fmt=json;record';
1296
+ const MEDIA_JSON_LIST = 'media:fmt=json;list';
1297
+ const MEDIA_JSON_LIST_RECORD = 'media:fmt=json;list;record';
1298
+ const MEDIA_YAML_VALUE = 'media:fmt=yaml';
1299
+ const MEDIA_YAML_RECORD = 'media:fmt=yaml;record';
1300
+ const MEDIA_YAML_LIST = 'media:fmt=yaml;list';
1301
+ const MEDIA_YAML_LIST_RECORD = 'media:fmt=yaml;list;record';
1302
+ const MEDIA_CSV = 'media:fmt=csv;list;record';
1303
+ const MEDIA_CSV_LIST = 'media:fmt=csv;list;record';
1306
1304
 
1307
1305
  // File path type — for arguments that represent filesystem paths.
1308
1306
  // There is a single media URN; cardinality (single file vs many files)
1309
1307
  // is carried on the wire via is_sequence, not via URN tags.
1310
- const MEDIA_FILE_PATH = 'media:file-path;textable';
1308
+ const MEDIA_FILE_PATH = 'media:enc=utf-8;file-path';
1311
1309
 
1312
1310
  // Semantic text input types - distinguished by their purpose/context
1313
1311
  // Media URN for model spec (provider:model format, HuggingFace name, etc.) - scalar by default
1314
- const MEDIA_MODEL_SPEC = 'media:model-spec;textable';
1312
+ const MEDIA_MODEL_SPEC = 'media:enc=utf-8;model-spec';
1315
1313
  // Media URN for MLX model path - scalar by default
1316
- const MEDIA_MLX_MODEL_PATH = 'media:mlx-model-path;textable';
1314
+ const MEDIA_MLX_MODEL_PATH = 'media:enc=utf-8;mlx-model-path';
1317
1315
  // Media URN for model repository (input for list-models) - has record marker
1318
- const MEDIA_MODEL_REPO = 'media:model-repo;record;textable';
1316
+ const MEDIA_MODEL_REPO = 'media:enc=utf-8;model-repo;record';
1319
1317
 
1320
1318
  // CAPDAG output types - record marker for structured JSON objects, list marker for arrays
1321
1319
  // Media URN for model dimension output - scalar by default (no list marker)
1322
- const MEDIA_MODEL_DIM = 'media:integer;model-dim;numeric;textable';
1320
+ const MEDIA_MODEL_DIM = 'media:integer;model-dim;numeric';
1323
1321
  // Media URN for model download output - has record marker
1324
- const MEDIA_DOWNLOAD_OUTPUT = 'media:download-result;record;textable';
1322
+ const MEDIA_DOWNLOAD_OUTPUT = 'media:download-result;enc=utf-8;record';
1325
1323
  // Media URN for model list output - has record marker
1326
- const MEDIA_LIST_OUTPUT = 'media:model-list;record;textable';
1324
+ const MEDIA_LIST_OUTPUT = 'media:enc=utf-8;model-list;record';
1327
1325
  // Media URN for model status output - has record marker
1328
- const MEDIA_STATUS_OUTPUT = 'media:model-status;record;textable';
1326
+ const MEDIA_STATUS_OUTPUT = 'media:enc=utf-8;model-status;record';
1329
1327
  // Media URN for model contents output - has record marker
1330
- const MEDIA_CONTENTS_OUTPUT = 'media:model-contents;record;textable';
1328
+ const MEDIA_CONTENTS_OUTPUT = 'media:enc=utf-8;model-contents;record';
1331
1329
  // Media URN for model availability output - has record marker
1332
- const MEDIA_AVAILABILITY_OUTPUT = 'media:model-availability;record;textable';
1330
+ const MEDIA_AVAILABILITY_OUTPUT = 'media:enc=utf-8;model-availability;record';
1333
1331
  // Media URN for model path output - has record marker
1334
- const MEDIA_PATH_OUTPUT = 'media:model-path;record;textable';
1332
+ const MEDIA_PATH_OUTPUT = 'media:enc=utf-8;model-path;record';
1335
1333
  // Media URN for embedding vector output - has record marker
1336
- const MEDIA_EMBEDDING_VECTOR = 'media:embedding-vector;record;textable';
1337
- // Media URN for vision inference output — a concrete textable terminal.
1334
+ const MEDIA_EMBEDDING_VECTOR = 'media:embedding-vector;enc=utf-8;record';
1335
+ // Media URN for vision inference output — a concrete plain-text terminal.
1338
1336
  // Carries `image-description` (the vision-specific marker), `plain-text` (the
1339
1337
  // finalised-text marker that opts into cap:save-as-txt's persistence path),
1340
1338
  // and `file-type=txt` (binds the URN to the `.txt` extension).
1341
- const MEDIA_IMAGE_DESCRIPTION = 'media:ext=txt;image-description;plain-text;textable';
1339
+ const MEDIA_IMAGE_DESCRIPTION = 'media:enc=utf-8;ext=txt;image-description;plain-text';
1342
1340
  // Media URN for finalised plain text — the canonical input/output of cap:save-as-txt.
1343
1341
  // Producers of user-facing prose (LLM text-generation, OCR's extracted text,
1344
1342
  // summarisation) declare this URN as their `out` so the planner restricts the .txt
1345
1343
  // persistence path to those caps. See fabric/media/plain-text.toml.
1346
- const MEDIA_PLAIN_TEXT = 'media:ext=txt;plain-text;textable';
1344
+ const MEDIA_PLAIN_TEXT = 'media:enc=utf-8;ext=txt;plain-text';
1347
1345
  // Media URN for transcription output - has record marker
1348
- const MEDIA_TRANSCRIPTION_OUTPUT = 'media:record;textable;transcription';
1349
- // Media URN for decision output - JSON record with textable
1350
- const MEDIA_DECISION = 'media:decision;json;record;textable';
1351
- // Media URN for textable page output
1352
- const MEDIA_TEXTABLE_PAGE = 'media:ext=txt;page;plain-text;textable';
1353
- // Media URN for Hugging Face API token (secret, textable)
1354
- const MEDIA_HF_TOKEN = 'media:hf-token;secret;textable';
1346
+ const MEDIA_TRANSCRIPTION_OUTPUT = 'media:enc=utf-8;record;transcription';
1347
+ // Media URN for decision output - JSON record
1348
+ const MEDIA_DECISION = 'media:decision;fmt=json;record';
1349
+ // Media URN for a single page of finalised plain text extracted from a document
1350
+ const MEDIA_TEXTABLE_PAGE = 'media:enc=utf-8;ext=txt;page;plain-text';
1351
+ // Media URN for Hugging Face API token (secret)
1352
+ const MEDIA_HF_TOKEN = 'media:enc=utf-8;hf-token;secret';
1355
1353
  // Media URN for a list of model architectures — JSON record
1356
- const MEDIA_MODEL_ARCH_LIST = 'media:model-arch-list;json;record;textable';
1354
+ const MEDIA_MODEL_ARCH_LIST = 'media:fmt=json;model-arch-list;record';
1357
1355
  // Media URN for a model search request — JSON record
1358
- const MEDIA_MODEL_SEARCH_REQUEST = 'media:model-search-request;json;record;textable';
1356
+ const MEDIA_MODEL_SEARCH_REQUEST = 'media:fmt=json;model-search-request;record';
1359
1357
  // Media URN for a model search response — JSON record
1360
- const MEDIA_MODEL_SEARCH_RESPONSE = 'media:model-search-response;json;record;textable';
1358
+ const MEDIA_MODEL_SEARCH_RESPONSE = 'media:fmt=json;model-search-response;record';
1361
1359
  // Media URN for model filter resolution — JSON record
1362
- const MEDIA_MODEL_FILTER_RESOLUTION = 'media:model-filter-resolution;json;record;textable';
1360
+ const MEDIA_MODEL_FILTER_RESOLUTION = 'media:fmt=json;model-filter-resolution;record';
1363
1361
  // Collection types
1364
1362
  const MEDIA_COLLECTION = 'media:collection;record';
1365
1363
  const MEDIA_COLLECTION_LIST = 'media:collection;list;record';
1366
1364
  // Media URN for adapter selection output - JSON record
1367
- const MEDIA_ADAPTER_SELECTION = 'media:adapter-selection;json;record';
1365
+ const MEDIA_ADAPTER_SELECTION = 'media:adapter-selection;fmt=json;record';
1368
1366
  // Fabric registry lookup wire types (consumed/produced by cap:lookup-cap;fabric
1369
1367
  // and cap:lookup-media-def;fabric, both implemented by fetchcartridge).
1370
- const MEDIA_CAP_URN = 'media:cap-urn;textable';
1371
- const MEDIA_MEDIA_URN = 'media:media-urn;textable';
1372
- const MEDIA_FABRIC_DEFVER = 'media:defver;textable';
1373
- const MEDIA_CAP_DEFINITION = 'media:cap-definition;json;record;textable';
1374
- const MEDIA_MEDIA_DEFINITION = 'media:media-definition;json;record;textable';
1368
+ const MEDIA_CAP_URN = 'media:cap-urn;enc=utf-8';
1369
+ const MEDIA_MEDIA_URN = 'media:enc=utf-8;media-urn';
1370
+ const MEDIA_FABRIC_DEFVER = 'media:defver;enc=utf-8';
1371
+ const MEDIA_CAP_DEFINITION = 'media:cap-definition;fmt=json;record';
1372
+ const MEDIA_MEDIA_DEFINITION = 'media:fmt=json;media-definition;record';
1375
1373
 
1376
1374
  // =============================================================================
1377
1375
  // STANDARD CAP URN CONSTANTS
@@ -1383,13 +1381,13 @@ const CAP_IDENTITY = 'cap:effect=none';
1383
1381
 
1384
1382
  // Adapter-selection capability. Default implementation returns empty END (no match).
1385
1383
  // Cartridges that inspect file content override this with a handler that returns {"media_urns": [...]}.
1386
- const CAP_ADAPTER_SELECTION = 'cap:in="media:";out="media:adapter-selection;json;record"';
1384
+ const CAP_ADAPTER_SELECTION = 'cap:in="media:";out="media:adapter-selection;fmt=json;record"';
1387
1385
 
1388
1386
  // Fabric registry lookup caps. Implemented by fetchcartridge.
1389
1387
  // CAP_LOOKUP_CAP_FABRIC resolves a canonical cap URN to its full flattened
1390
1388
  // cap definition; CAP_LOOKUP_MEDIA_DEF_FABRIC does the same for media defs.
1391
- const CAP_LOOKUP_CAP_FABRIC = 'cap:in="media:cap-urn;textable";fabric;lookup-cap;out="media:cap-definition;json;record;textable"';
1392
- const CAP_LOOKUP_MEDIA_DEF_FABRIC = 'cap:in="media:media-urn;textable";fabric;lookup-media-def;out="media:media-definition;json;record;textable"';
1389
+ const CAP_LOOKUP_CAP_FABRIC = 'cap:in="media:cap-urn;enc=utf-8";fabric;lookup-cap;out="media:cap-definition;fmt=json;record"';
1390
+ const CAP_LOOKUP_MEDIA_DEF_FABRIC = 'cap:in="media:enc=utf-8;media-urn";fabric;lookup-media-def;out="media:fmt=json;media-definition;record"';
1393
1391
 
1394
1392
  // =============================================================================
1395
1393
  // MEDIA URN CLASS
@@ -1446,7 +1444,7 @@ class MediaUrn {
1446
1444
 
1447
1445
  /**
1448
1446
  * Parse a media URN string. Validates the prefix is 'media'.
1449
- * @param {string} str - The media URN string (e.g., "media:pdf")
1447
+ * @param {string} str - The media URN string (e.g., "media:ext=pdf")
1450
1448
  * @returns {MediaUrn}
1451
1449
  * @throws {MediaUrnError} If prefix is not 'media'
1452
1450
  */
@@ -1455,9 +1453,6 @@ class MediaUrn {
1455
1453
  return new MediaUrn(urn);
1456
1454
  }
1457
1455
 
1458
- /** @returns {boolean} True if the "textable" marker tag is NOT present (binary = not textable) */
1459
- isBinary() { return this._urn.getTag('textable') === undefined; }
1460
-
1461
1456
  // =========================================================================
1462
1457
  // CARDINALITY (list marker)
1463
1458
  // =========================================================================
@@ -1512,11 +1507,8 @@ class MediaUrn {
1512
1507
  return value === '*';
1513
1508
  }
1514
1509
 
1515
- /** @returns {boolean} True if the "json" marker tag is present */
1516
- isJson() { return this._urn.getTag('json') !== undefined; }
1517
-
1518
- /** @returns {boolean} True if the "textable" marker tag is present */
1519
- isText() { return this._urn.getTag('textable') !== undefined; }
1510
+ /** @returns {boolean} True if the content format is JSON (`fmt=json`) */
1511
+ isJson() { return this._urn.getTag('fmt') === 'json'; }
1520
1512
 
1521
1513
  /** @returns {boolean} True if the "void" marker tag is present —
1522
1514
  * the **unit type** in the type-theoretic reading. media:void is
@@ -1546,19 +1538,19 @@ class MediaUrn {
1546
1538
  isBool() { return this._urn.getTag('bool') !== undefined; }
1547
1539
 
1548
1540
  /**
1549
- * Returns true if this media URN describes YAML representation.
1541
+ * Returns true if this value's content format is YAML (`fmt=yaml`).
1550
1542
  * @returns {boolean}
1551
1543
  */
1552
1544
  isYaml() {
1553
- return this._hasMarkerTag('yaml');
1545
+ return this._urn.getTag('fmt') === 'yaml';
1554
1546
  }
1555
1547
 
1556
1548
  /**
1557
- * Returns true if this media URN describes CSV representation.
1549
+ * Returns true if this value's content format is CSV (`fmt=csv`).
1558
1550
  * @returns {boolean}
1559
1551
  */
1560
1552
  isCsv() {
1561
- return this._hasMarkerTag('csv');
1553
+ return this._urn.getTag('fmt') === 'csv';
1562
1554
  }
1563
1555
 
1564
1556
  /**
@@ -1788,6 +1780,44 @@ function getProfileURL(profileName) {
1788
1780
  return `${getSchemaBaseURL()}/${profileName}`;
1789
1781
  }
1790
1782
 
1783
+ /**
1784
+ * Validate a build-time `MFR_CARTRIDGE_REGISTRY_URL` value and reduce it to
1785
+ * the registry identity the build is published against.
1786
+ *
1787
+ * Mirrors capdag::bifaci::manifest::registry_url_from_build_env. The Rust
1788
+ * version is a compile-time `const fn` fed `option_env!(...)`; in JS the
1789
+ * equivalent input is the raw env value at process start
1790
+ * (`process.env.MFR_CARTRIDGE_REGISTRY_URL`), where an absent variable is
1791
+ * `undefined`.
1792
+ *
1793
+ * Valid states:
1794
+ * - `null`/`undefined` => dev build; registry identity is absent and the
1795
+ * build must use the on-disk `dev/` slot. Returns `null`.
1796
+ * - a non-empty string => published-registry build. Returned verbatim.
1797
+ *
1798
+ * Invalid state:
1799
+ * - the empty string => the caller exported the variable with an empty value.
1800
+ * This is neither a dev build nor a valid registry identity, and it MUST
1801
+ * fail hard (throw) so the build can never silently hash the empty string
1802
+ * into a fake registry slug. The Rust port panics here; the JS port throws
1803
+ * with the same message — no fallback.
1804
+ *
1805
+ * @param {string|null|undefined} raw - The raw build-env value.
1806
+ * @returns {string|null} `null` for a dev build, or the verbatim non-empty URL.
1807
+ * @throws {Error} when `raw` is the empty string.
1808
+ */
1809
+ function registryUrlFromBuildEnv(raw) {
1810
+ if (raw === null || raw === undefined) {
1811
+ return null;
1812
+ }
1813
+ if (raw === '') {
1814
+ throw new Error(
1815
+ 'MFR_CARTRIDGE_REGISTRY_URL must be unset for dev builds or set to a non-empty registry URL for published builds; empty string is invalid'
1816
+ );
1817
+ }
1818
+ return raw;
1819
+ }
1820
+
1791
1821
  // =============================================================================
1792
1822
  // MEDIA URN TAG UTILITIES
1793
1823
  // =============================================================================
@@ -1843,10 +1873,10 @@ class MediaDef {
1843
1873
  return this._parsedMediaUrn;
1844
1874
  }
1845
1875
 
1846
- /** @returns {boolean} True if binary (textable marker tag absent) */
1876
+ /** @returns {boolean} True if binary (no `enc` encoding tag — not text-representable) */
1847
1877
  isBinary() {
1848
1878
  const mu = this.parsedMediaUrn();
1849
- return mu ? mu.isBinary() : false;
1879
+ return mu ? mu.getTag('enc') === undefined : false;
1850
1880
  }
1851
1881
 
1852
1882
  /** @returns {boolean} True if record structure (has record marker) */
@@ -1879,10 +1909,10 @@ class MediaDef {
1879
1909
  return mu ? mu.isJson() : false;
1880
1910
  }
1881
1911
 
1882
- /** @returns {boolean} True if text (textable tag present) */
1912
+ /** @returns {boolean} True if text-representable (has `enc` encoding tag) */
1883
1913
  isText() {
1884
1914
  const mu = this.parsedMediaUrn();
1885
- return mu ? mu.isText() : false;
1915
+ return mu ? mu.getTag('enc') !== undefined : false;
1886
1916
  }
1887
1917
 
1888
1918
  /** @returns {boolean} True if image (image tag present) */
@@ -1967,7 +1997,7 @@ class MediaDef {
1967
1997
  * Resolution: Look up mediaUrn in mediaDefs array (by urn field), FAIL HARD if not found.
1968
1998
  * There is no built-in resolution - all media URNs must be in mediaDefs.
1969
1999
  *
1970
- * @param {string} mediaUrn - The media URN (e.g., "media:textable")
2000
+ * @param {string} mediaUrn - The media URN (e.g., "media:enc=utf-8")
1971
2001
  * @param {Array} mediaDefs - The mediaDefs array (each item has urn, media_type, title, etc.)
1972
2002
  * @returns {MediaDef} The resolved MediaDef
1973
2003
  * @throws {MediaDefError} If media URN cannot be resolved
@@ -2058,7 +2088,7 @@ function buildExtensionIndex(mediaDefs) {
2058
2088
  *
2059
2089
  * @example
2060
2090
  * const urns = mediaUrnsForExtension('pdf', mediaDefs);
2061
- * // May return ['media:pdf']
2091
+ * // May return ['media:ext=pdf']
2062
2092
  */
2063
2093
  function mediaUrnsForExtension(extension, mediaDefs) {
2064
2094
  const index = buildExtensionIndex(mediaDefs);
@@ -3808,7 +3838,7 @@ class CapResult {
3808
3838
  class CapArgumentValue {
3809
3839
  /**
3810
3840
  * Create a new CapArgumentValue
3811
- * @param {string} mediaUrn - Semantic identifier, e.g., "media:model-spec;textable"
3841
+ * @param {string} mediaUrn - Semantic identifier, e.g., "media:enc=utf-8;model-spec"
3812
3842
  * @param {Uint8Array|Buffer} value - Value bytes (UTF-8 for text, raw for binary)
3813
3843
  */
3814
3844
  constructor(mediaUrn, value) {
@@ -4358,6 +4388,126 @@ function isRegistrySlug(s) {
4358
4388
  && /^[0-9a-f]+$/.test(s);
4359
4389
  }
4360
4390
 
4391
+ // =============================================================================
4392
+ // Host-compatibility resolution for registry cartridges
4393
+ // =============================================================================
4394
+ //
4395
+ // Mirrors capdag::bifaci::cartridge_repo: host_platform(), CompatStatus,
4396
+ // CartridgeBuild::primary_package and CartridgeInfo::resolve_for_host.
4397
+
4398
+ /**
4399
+ * The platform string ({os}-{arch}) of the runtime that calls this, in the
4400
+ * exact form the registry uses (`darwin-arm64`, `darwin-x86_64`,
4401
+ * `linux-x86_64`, `windows-x86_64`). Single source of truth: every consumer
4402
+ * that needs "what am I running on?" calls this rather than re-deriving the
4403
+ * os/arch mapping.
4404
+ *
4405
+ * Mirrors capdag::bifaci::cartridge_repo::host_platform. The Rust version
4406
+ * derives os/arch from `cfg!`/`std::env::consts` at compile time; the Node
4407
+ * equivalent reads `process.platform`/`process.arch` at runtime — the engine
4408
+ * binary literally runs on this platform, so this is the authoritative host
4409
+ * string for compatibility resolution. Node's `darwin`/`linux`/`win32` map to
4410
+ * the registry's `darwin`/`linux`/`windows`; Node's `arm64`/`x64` map to the
4411
+ * registry's `arm64`/`x86_64`. Any platform/arch Node reports that has no
4412
+ * mapping passes through unchanged, exactly as the Rust fallback does.
4413
+ *
4414
+ * @returns {string} The normalized `{os}-{arch}` host platform string.
4415
+ */
4416
+ function hostPlatform() {
4417
+ const rawOs = process.platform;
4418
+ let os;
4419
+ if (rawOs === 'darwin') {
4420
+ os = 'darwin';
4421
+ } else if (rawOs === 'linux') {
4422
+ os = 'linux';
4423
+ } else if (rawOs === 'win32') {
4424
+ os = 'windows';
4425
+ } else {
4426
+ os = rawOs;
4427
+ }
4428
+ const rawArch = process.arch;
4429
+ let arch;
4430
+ if (rawArch === 'arm64') {
4431
+ arch = 'arm64';
4432
+ } else if (rawArch === 'x64') {
4433
+ arch = 'x86_64';
4434
+ } else {
4435
+ arch = rawArch;
4436
+ }
4437
+ return `${os}-${arch}`;
4438
+ }
4439
+
4440
+ /**
4441
+ * Host-compatibility status of a registry cartridge, resolved against a
4442
+ * specific host platform string. Mirrors Rust CompatStatus.
4443
+ */
4444
+ const CompatStatus = Object.freeze({
4445
+ // The latest version has a build for this host platform — install as-is.
4446
+ COMPATIBLE: 'compatible',
4447
+ // The latest version has no host build, but an older version does;
4448
+ // resolvedVersion names that older version. Install it, mark outdated.
4449
+ COMPATIBLE_OUTDATED: 'compatible_outdated',
4450
+ // No version has a build for this host platform. Nothing to install.
4451
+ INCOMPATIBLE: 'incompatible',
4452
+ });
4453
+
4454
+ /**
4455
+ * The installer package the host should use for a build, preferring the
4456
+ * platform's native format. Falls back to the legacy singular `package` when
4457
+ * `packages[]` is empty (pre-dual-write manifests). Returns `null` only when
4458
+ * the build ships no installer at all.
4459
+ *
4460
+ * Mirrors CartridgeBuild::primary_package. Builds in the JS model are plain
4461
+ * objects (`{platform, packages?, package?}`), so this is a free function
4462
+ * taking the build object rather than a method.
4463
+ *
4464
+ * @param {Object} build - A platform build object with `platform`, optional
4465
+ * `packages` array and optional legacy `package`.
4466
+ * @returns {Object|null} The chosen distribution-info object, or null.
4467
+ */
4468
+ function primaryPackage(build) {
4469
+ const os = String(build.platform || '').split('-')[0];
4470
+ let preference;
4471
+ switch (os) {
4472
+ case 'darwin': preference = ['pkg']; break;
4473
+ case 'linux': preference = ['deb', 'rpm']; break;
4474
+ case 'windows': preference = ['msi', 'exe']; break;
4475
+ default: preference = []; break;
4476
+ }
4477
+ const packages = Array.isArray(build.packages) ? build.packages : [];
4478
+ for (const format of preference) {
4479
+ const pkg = packages.find(p => p.format === format);
4480
+ if (pkg) return pkg;
4481
+ }
4482
+ if (packages.length > 0) return packages[0];
4483
+ return build.package || null;
4484
+ }
4485
+
4486
+ /**
4487
+ * The resolved verdict the engine attaches to an available cartridge: which
4488
+ * version/package the host should install (if any) and a human reason when it
4489
+ * is not the latest-and-greatest. Mirrors CartridgeCompatibilityResolution.
4490
+ */
4491
+ class CartridgeCompatibilityResolution {
4492
+ /**
4493
+ * @param {Object} opts
4494
+ * @param {string} opts.status - A CompatStatus value.
4495
+ * @param {string} opts.hostPlatform - The host platform this was resolved for.
4496
+ * @param {string|null} [opts.resolvedVersion] - Newest version with a host
4497
+ * build (null when Incompatible).
4498
+ * @param {Object|null} [opts.resolvedPackage] - Host-preferred installer
4499
+ * package within resolvedVersion (null when Incompatible).
4500
+ * @param {string|null} [opts.reason] - Explanation when status is not Compatible.
4501
+ */
4502
+ constructor({ status, hostPlatform, resolvedVersion = null, resolvedPackage = null, reason = null }) {
4503
+ this.status = status;
4504
+ this.hostPlatform = hostPlatform;
4505
+ this.resolvedVersion = resolvedVersion;
4506
+ this.resolvedPackage = resolvedPackage;
4507
+ this.reason = reason;
4508
+ }
4509
+ }
4510
+
4361
4511
  /**
4362
4512
  * Cartridge information from registry
4363
4513
  */
@@ -4440,6 +4590,81 @@ class CartridgeInfo {
4440
4590
  }
4441
4591
  return Array.from(platforms).sort();
4442
4592
  }
4593
+
4594
+ /**
4595
+ * Find this cartridge's build for `hostPlatform` within a given version, if
4596
+ * any. The host package within it is then chosen by `primaryPackage`.
4597
+ * Mirrors CartridgeInfo::build_for_host.
4598
+ *
4599
+ * @param {string} version
4600
+ * @param {string} hostPlatform
4601
+ * @returns {Object|null}
4602
+ */
4603
+ buildForHost(version, hostPlatform) {
4604
+ const versionData = this.versions[version];
4605
+ if (!versionData) return null;
4606
+ return (versionData.builds || []).find(b => b.platform === hostPlatform) || null;
4607
+ }
4608
+
4609
+ /**
4610
+ * Resolve which version/package this host should install, scanning versions
4611
+ * newest-first (`availableVersions` is the authoritative newest-first
4612
+ * ordering). The newest version with a usable host build wins:
4613
+ * - it IS the latest version (`this.version`) → Compatible
4614
+ * - it is older than the latest → CompatibleOutdated
4615
+ * - no version has a usable host build → Incompatible
4616
+ *
4617
+ * A build whose primaryPackage is null ships no installer at all; it is
4618
+ * SKIPPED (not resolved to an un-downloadable version) and the scan
4619
+ * continues to older versions. "Latest" is `this.version` — the same field
4620
+ * `buildForPlatform` trusts — not `availableVersions[0]`. We do not paper
4621
+ * over a `this.version` with no host build by silently calling it latest.
4622
+ *
4623
+ * Mirrors CartridgeInfo::resolve_for_host.
4624
+ *
4625
+ * @param {string} hostPlatform - Normally `hostPlatform()`; passed in so the
4626
+ * resolution is unit-testable for arbitrary hosts.
4627
+ * @returns {CartridgeCompatibilityResolution}
4628
+ */
4629
+ resolveForHost(hostPlatform) {
4630
+ const latest = this.version;
4631
+
4632
+ for (const ver of this.availableVersions) {
4633
+ const build = this.buildForHost(ver, hostPlatform);
4634
+ if (!build) continue;
4635
+ // primaryPackage returns null only when the build ships no installer at
4636
+ // all — a build entry with an empty packages[] and no legacy package.
4637
+ // That is a malformed registry build; skip it rather than resolve to a
4638
+ // version the host cannot actually download, and keep scanning older
4639
+ // versions for a usable one.
4640
+ const pkg = primaryPackage(build);
4641
+ if (!pkg) continue;
4642
+ if (ver === latest) {
4643
+ return new CartridgeCompatibilityResolution({
4644
+ status: CompatStatus.COMPATIBLE,
4645
+ hostPlatform,
4646
+ resolvedVersion: ver,
4647
+ resolvedPackage: pkg,
4648
+ reason: null,
4649
+ });
4650
+ }
4651
+ return new CartridgeCompatibilityResolution({
4652
+ status: CompatStatus.COMPATIBLE_OUTDATED,
4653
+ hostPlatform,
4654
+ resolvedVersion: ver,
4655
+ resolvedPackage: pkg,
4656
+ reason: `Latest ${latest} has no ${hostPlatform} build; newest compatible is ${ver}`,
4657
+ });
4658
+ }
4659
+
4660
+ return new CartridgeCompatibilityResolution({
4661
+ status: CompatStatus.INCOMPATIBLE,
4662
+ hostPlatform,
4663
+ resolvedVersion: null,
4664
+ resolvedPackage: null,
4665
+ reason: `No installable ${hostPlatform} build available in any version`,
4666
+ });
4667
+ }
4443
4668
  }
4444
4669
 
4445
4670
  /**
@@ -5013,6 +5238,20 @@ const CartridgeAttachmentErrorKind = Object.freeze({
5013
5238
  IDENTITY_REJECTED: 'identity_rejected',
5014
5239
  ENTRY_POINT_MISSING: 'entry_point_missing',
5015
5240
  QUARANTINED: 'quarantined',
5241
+ // On-disk install context disagrees with the cartridge.json the cartridge
5242
+ // declares — the slug folder doesn't match slug_for(registry_url), the
5243
+ // channel folder doesn't match the manifest's channel, or a bundled-provider
5244
+ // integrity proof failed. Structurally well-formed but cannot be trusted
5245
+ // because its placement on disk does not match what it claims to be.
5246
+ BAD_INSTALLATION: 'bad_installation',
5247
+ // Operator explicitly disabled this cartridge through the host UI.
5248
+ DISABLED: 'disabled',
5249
+ // The cartridge declares a non-null registry_url but the host could not
5250
+ // reach that registry to verify the cartridge is listed.
5251
+ REGISTRY_UNREACHABLE: 'registry_unreachable',
5252
+ // The cartridge was built against a different fabric registry manifest
5253
+ // version than this host is pinned to.
5254
+ FABRIC_MANIFEST_VERSION_MISMATCH: 'fabric_manifest_version_mismatch',
5016
5255
  });
5017
5256
 
5018
5257
  /**
@@ -5193,6 +5432,941 @@ class InstalledCartridgeRecord {
5193
5432
  }
5194
5433
  }
5195
5434
 
5435
+ // ============================================================================
5436
+ // Cartridge discovery — on-disk scan + identity validation + HELLO probe
5437
+ // ============================================================================
5438
+ //
5439
+ // Mirrors capdag::cartridge_discovery plus its dependencies
5440
+ // capdag::bifaci::cartridge_json (CartridgeJson, validate_registry_url_scheme,
5441
+ // hash_cartridge_directory) and the synchronous slug helper from
5442
+ // capdag::bifaci::cartridge_slug::slug_for.
5443
+ //
5444
+ // This is the single source of truth for classifying each installed cartridge
5445
+ // version directory as attachable (`directory`) or `incompatible`. Node
5446
+ // built-ins (`fs`, `path`, `crypto`, `child_process`) are required lazily
5447
+ // inside the functions so the browser bundle — which never performs disk
5448
+ // discovery — never references them.
5449
+
5450
+ /**
5451
+ * Synchronous on-disk slug for a registry URL. Mirrors
5452
+ * capdag::bifaci::cartridge_slug::slug_for byte-for-byte: `null`/`undefined`
5453
+ * (a dev cartridge) → the literal `DEV_SLUG`; otherwise the first
5454
+ * SLUG_HEX_LEN lowercase-hex characters of sha256(url bytes).
5455
+ *
5456
+ * The async `slugForRegistryUrl` above uses Web Crypto for browser parity;
5457
+ * discovery runs only under Node and needs a synchronous slug in tight
5458
+ * per-file loops, so this uses Node's synchronous `crypto.createHash`. Both
5459
+ * produce identical output.
5460
+ *
5461
+ * @param {string|null|undefined} registryUrl
5462
+ * @returns {string}
5463
+ */
5464
+ function slugForSync(registryUrl) {
5465
+ if (registryUrl === null || registryUrl === undefined) {
5466
+ return DEV_SLUG;
5467
+ }
5468
+ const crypto = require('crypto');
5469
+ const hex = crypto.createHash('sha256').update(registryUrl, 'utf8').digest('hex');
5470
+ return hex.slice(0, SLUG_HEX_LEN);
5471
+ }
5472
+
5473
+ /**
5474
+ * How a cartridge was installed. Pure metadata — never consulted for any host
5475
+ * or engine routing decision. Mirrors Rust CartridgeInstallSource; the
5476
+ * on-disk JSON uses snake_case values.
5477
+ */
5478
+ const CartridgeInstallSource = Object.freeze({
5479
+ REGISTRY: 'registry',
5480
+ DEV: 'dev',
5481
+ BUNDLE: 'bundle',
5482
+ APP_INSTALLER: 'app_installer',
5483
+ });
5484
+
5485
+ /**
5486
+ * Verdict from `validateRegistryUrlScheme`. Mirrors Rust
5487
+ * RegistryUrlSchemeResult: distinguishes OK from each rejection reason so
5488
+ * callers can render an actionable message. `kind` is one of `ok`,
5489
+ * `not_a_url`, `non_https`.
5490
+ */
5491
+ const RegistryUrlSchemeResultKind = Object.freeze({
5492
+ OK: 'ok',
5493
+ NOT_A_URL: 'not_a_url',
5494
+ NON_HTTPS: 'non_https',
5495
+ });
5496
+
5497
+ /**
5498
+ * Validate that a non-null `registry_url` uses the `https` scheme — UNLESS
5499
+ * `devMode` is set, in which case any well-formed URL is accepted (so
5500
+ * developers can point at `http://localhost:port` during integration
5501
+ * testing). Mirrors capdag::bifaci::cartridge_json::validate_registry_url_scheme.
5502
+ *
5503
+ * Cheap parse: split once on `://`. The rule is "scheme must be the literal
5504
+ * bytes `https`" (case-insensitive); full URL validation is the caller's job.
5505
+ *
5506
+ * @param {string} url
5507
+ * @param {boolean} devMode
5508
+ * @returns {{kind: string, url?: string, scheme?: string}}
5509
+ */
5510
+ function validateRegistryUrlScheme(url, devMode) {
5511
+ const idx = url.indexOf('://');
5512
+ if (idx < 0) {
5513
+ return { kind: RegistryUrlSchemeResultKind.NOT_A_URL, url };
5514
+ }
5515
+ const scheme = url.slice(0, idx);
5516
+ const rest = url.slice(idx + 3);
5517
+ if (rest.length === 0) {
5518
+ return { kind: RegistryUrlSchemeResultKind.NOT_A_URL, url };
5519
+ }
5520
+ if (devMode) {
5521
+ return { kind: RegistryUrlSchemeResultKind.OK };
5522
+ }
5523
+ if (scheme.toLowerCase() === 'https') {
5524
+ return { kind: RegistryUrlSchemeResultKind.OK };
5525
+ }
5526
+ return { kind: RegistryUrlSchemeResultKind.NON_HTTPS, scheme };
5527
+ }
5528
+
5529
+ /**
5530
+ * Error kinds for CartridgeJson reading/validation. Mirrors the variants of
5531
+ * Rust CartridgeJsonError. `RegistrySlugMismatch` is the three-place
5532
+ * consistency failure that discovery maps to BAD_INSTALLATION.
5533
+ */
5534
+ const CartridgeJsonErrorKind = Object.freeze({
5535
+ NOT_FOUND: 'not_found',
5536
+ READ_FAILED: 'read_failed',
5537
+ INVALID_JSON: 'invalid_json',
5538
+ ENTRY_POINT_MISSING: 'entry_point_missing',
5539
+ ENTRY_POINT_NOT_EXECUTABLE: 'entry_point_not_executable',
5540
+ ENTRY_PATH_ESCAPE: 'entry_path_escape',
5541
+ REGISTRY_SLUG_MISMATCH: 'registry_slug_mismatch',
5542
+ });
5543
+
5544
+ /**
5545
+ * Error thrown by CartridgeJson.readFromDir. Carries a structured `kind` so
5546
+ * discovery can map a slug mismatch to BAD_INSTALLATION and everything else
5547
+ * to MANIFEST_INVALID. Mirrors Rust CartridgeJsonError.
5548
+ */
5549
+ class CartridgeJsonError extends Error {
5550
+ constructor(kind, message) {
5551
+ super(message);
5552
+ this.name = 'CartridgeJsonError';
5553
+ this.kind = kind;
5554
+ }
5555
+ }
5556
+
5557
+ /**
5558
+ * Install-context metadata stored in `cartridge.json` inside each cartridge
5559
+ * version directory. Mirrors capdag::bifaci::cartridge_json::CartridgeJson.
5560
+ *
5561
+ * Identity tuple: `(registryUrl, channel, name, version)`. `registryUrl` is
5562
+ * required-but-nullable: a MISSING `registry_url` key is a parse error
5563
+ * (forces the new schema across every install path); only `null` means dev.
5564
+ */
5565
+ class CartridgeJson {
5566
+ /**
5567
+ * @param {Object} fields
5568
+ */
5569
+ constructor(fields) {
5570
+ this.name = fields.name;
5571
+ this.version = fields.version;
5572
+ this.channel = fields.channel;
5573
+ this.registryUrl = fields.registryUrl;
5574
+ this.entry = fields.entry;
5575
+ this.installedAt = fields.installedAt;
5576
+ this.installedFrom = fields.installedFrom !== undefined ? fields.installedFrom : null;
5577
+ this.sourceUrl = fields.sourceUrl || '';
5578
+ this.packageSha256 = fields.packageSha256 || '';
5579
+ this.packageSize = fields.packageSize || 0;
5580
+ this.fabricManifestVersion = fields.fabricManifestVersion || 0;
5581
+ }
5582
+
5583
+ /**
5584
+ * Parse a cartridge.json object. Enforces the "required-but-nullable"
5585
+ * contract on `registry_url` — the JSON key MUST be present (missing key is
5586
+ * a parse error); the value MAY be null (dev) or a string (registry). Stock
5587
+ * JSON parsing would collapse absent and explicit-null, so the presence
5588
+ * check is explicit, mirroring the Rust manual deserializer.
5589
+ *
5590
+ * @param {Object} obj - The parsed JSON object.
5591
+ * @returns {CartridgeJson}
5592
+ * @throws {CartridgeJsonError} with kind INVALID_JSON on any schema violation.
5593
+ */
5594
+ static fromObject(obj) {
5595
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
5596
+ throw new CartridgeJsonError(CartridgeJsonErrorKind.INVALID_JSON, 'cartridge.json must be a JSON object');
5597
+ }
5598
+ if (!Object.prototype.hasOwnProperty.call(obj, 'registry_url')) {
5599
+ throw new CartridgeJsonError(CartridgeJsonErrorKind.INVALID_JSON, 'cartridge.json missing required field: registry_url');
5600
+ }
5601
+ for (const required of ['name', 'version', 'channel', 'entry', 'installed_at']) {
5602
+ if (typeof obj[required] !== 'string') {
5603
+ throw new CartridgeJsonError(CartridgeJsonErrorKind.INVALID_JSON, `cartridge.json missing required string field: ${required}`);
5604
+ }
5605
+ }
5606
+ if (obj.channel !== CartridgeChannel.Release && obj.channel !== CartridgeChannel.Nightly) {
5607
+ throw new CartridgeJsonError(CartridgeJsonErrorKind.INVALID_JSON, `cartridge.json invalid channel: ${obj.channel}`);
5608
+ }
5609
+ const reg = obj.registry_url;
5610
+ if (reg !== null && typeof reg !== 'string') {
5611
+ throw new CartridgeJsonError(CartridgeJsonErrorKind.INVALID_JSON, 'cartridge.json registry_url must be null or a string');
5612
+ }
5613
+ let installedFrom = null;
5614
+ if (obj.installed_from !== undefined && obj.installed_from !== null) {
5615
+ const valid = Object.values(CartridgeInstallSource);
5616
+ if (!valid.includes(obj.installed_from)) {
5617
+ throw new CartridgeJsonError(CartridgeJsonErrorKind.INVALID_JSON, `cartridge.json unknown installed_from: ${obj.installed_from}`);
5618
+ }
5619
+ installedFrom = obj.installed_from;
5620
+ }
5621
+ return new CartridgeJson({
5622
+ name: obj.name,
5623
+ version: obj.version,
5624
+ channel: obj.channel,
5625
+ registryUrl: reg,
5626
+ entry: obj.entry,
5627
+ installedAt: obj.installed_at,
5628
+ installedFrom,
5629
+ sourceUrl: typeof obj.source_url === 'string' ? obj.source_url : '',
5630
+ packageSha256: typeof obj.package_sha256 === 'string' ? obj.package_sha256 : '',
5631
+ packageSize: typeof obj.package_size === 'number' ? obj.package_size : 0,
5632
+ fabricManifestVersion: typeof obj.fabric_manifest_version === 'number' ? obj.fabric_manifest_version : 0,
5633
+ });
5634
+ }
5635
+
5636
+ /**
5637
+ * Read and validate a `cartridge.json` from a version directory, enforcing
5638
+ * the three-place rule against `expectedSlug` (the slug folder the host
5639
+ * walked through). Mirrors CartridgeJson::read_from_dir.
5640
+ *
5641
+ * Validates: file exists and is valid JSON; the declared registry_url
5642
+ * hashes to `expectedSlug` (else RegistrySlugMismatch); the entry point
5643
+ * exists, does not escape the version directory, and is executable.
5644
+ *
5645
+ * @param {string} versionDir - Absolute path to the version directory.
5646
+ * @param {string} expectedSlug - The on-disk slug folder name.
5647
+ * @returns {CartridgeJson}
5648
+ * @throws {CartridgeJsonError}
5649
+ */
5650
+ static readFromDir(versionDir, expectedSlug) {
5651
+ const fs = require('fs');
5652
+ const path = require('path');
5653
+ const jsonPath = path.join(versionDir, 'cartridge.json');
5654
+
5655
+ if (!fs.existsSync(jsonPath)) {
5656
+ throw new CartridgeJsonError(CartridgeJsonErrorKind.NOT_FOUND, `cartridge.json not found at ${jsonPath}`);
5657
+ }
5658
+
5659
+ let contents;
5660
+ try {
5661
+ contents = fs.readFileSync(jsonPath, 'utf8');
5662
+ } catch (e) {
5663
+ throw new CartridgeJsonError(CartridgeJsonErrorKind.READ_FAILED, `failed to read cartridge.json at ${jsonPath}: ${e.message}`);
5664
+ }
5665
+
5666
+ let parsed;
5667
+ try {
5668
+ parsed = JSON.parse(contents);
5669
+ } catch (e) {
5670
+ throw new CartridgeJsonError(CartridgeJsonErrorKind.INVALID_JSON, `invalid cartridge.json at ${jsonPath}: ${e.message}`);
5671
+ }
5672
+ const cj = CartridgeJson.fromObject(parsed);
5673
+
5674
+ // Three-place consistency rule (places 1 + 2): the folder slug must match
5675
+ // the slug derived from the provenance's registry_url. None+`dev` and
5676
+ // Some(url)+slug(url) are the only valid pairings.
5677
+ const derivedSlug = slugForSync(cj.registryUrl);
5678
+ if (derivedSlug !== expectedSlug) {
5679
+ throw new CartridgeJsonError(
5680
+ CartridgeJsonErrorKind.REGISTRY_SLUG_MISMATCH,
5681
+ `cartridge.json at ${jsonPath}: registry slug mismatch — registry_url=${JSON.stringify(cj.registryUrl)} ` +
5682
+ `hashes to slug='${derivedSlug}' but the directory tree placed it under '${expectedSlug}'`
5683
+ );
5684
+ }
5685
+
5686
+ // Validate entry point exists.
5687
+ const entryPath = path.join(versionDir, cj.entry);
5688
+ if (!fs.existsSync(entryPath)) {
5689
+ throw new CartridgeJsonError(CartridgeJsonErrorKind.ENTRY_POINT_MISSING, `cartridge.json at ${jsonPath}: entry point '${cj.entry}' does not exist`);
5690
+ }
5691
+
5692
+ // Validate entry path does not escape version directory.
5693
+ let canonicalDir;
5694
+ let canonicalEntry;
5695
+ try { canonicalDir = fs.realpathSync(versionDir); } catch (_e) { canonicalDir = path.resolve(versionDir); }
5696
+ try { canonicalEntry = fs.realpathSync(entryPath); } catch (_e) { canonicalEntry = path.resolve(entryPath); }
5697
+ const dirWithSep = canonicalDir.endsWith(path.sep) ? canonicalDir : canonicalDir + path.sep;
5698
+ if (canonicalEntry !== canonicalDir && !canonicalEntry.startsWith(dirWithSep)) {
5699
+ throw new CartridgeJsonError(CartridgeJsonErrorKind.ENTRY_PATH_ESCAPE, `cartridge.json at ${jsonPath}: entry path '${cj.entry}' escapes version directory`);
5700
+ }
5701
+
5702
+ // Validate entry point is executable (unix permission bits).
5703
+ if (process.platform !== 'win32') {
5704
+ let mode;
5705
+ try {
5706
+ mode = fs.statSync(entryPath).mode;
5707
+ } catch (e) {
5708
+ throw new CartridgeJsonError(CartridgeJsonErrorKind.READ_FAILED, `failed to read cartridge.json at ${jsonPath}: ${e.message}`);
5709
+ }
5710
+ if ((mode & 0o111) === 0) {
5711
+ throw new CartridgeJsonError(CartridgeJsonErrorKind.ENTRY_POINT_NOT_EXECUTABLE, `cartridge.json at ${jsonPath}: entry point '${cj.entry}' is not executable`);
5712
+ }
5713
+ }
5714
+
5715
+ return cj;
5716
+ }
5717
+
5718
+ /** True when installed as a dev build (no registry URL). */
5719
+ isDevInstall() {
5720
+ return this.registryUrl === null || this.registryUrl === undefined;
5721
+ }
5722
+
5723
+ /** The on-disk slug this provenance must live under. */
5724
+ registrySlug() {
5725
+ return slugForSync(this.registryUrl);
5726
+ }
5727
+
5728
+ /**
5729
+ * Resolve the absolute path to the entry point binary.
5730
+ * @param {string} versionDir
5731
+ * @returns {string}
5732
+ */
5733
+ resolveEntryPoint(versionDir) {
5734
+ const path = require('path');
5735
+ return path.join(versionDir, this.entry);
5736
+ }
5737
+ }
5738
+
5739
+ /**
5740
+ * Compute a deterministic SHA256 hash of a directory tree. Walks all files
5741
+ * recursively, sorts them by relative path, then hashes each file's relative
5742
+ * path (UTF-8 bytes) followed by its contents — a stable identity hash
5743
+ * regardless of filesystem ordering. `cartridge.json` itself is excluded (it
5744
+ * holds install-time metadata that varies between installs of identical
5745
+ * content). Mirrors capdag::bifaci::cartridge_json::hash_cartridge_directory.
5746
+ *
5747
+ * @param {string} dir - Absolute path to the directory.
5748
+ * @returns {string} Lowercase hex SHA256.
5749
+ */
5750
+ function hashCartridgeDirectory(dir) {
5751
+ const fs = require('fs');
5752
+ const path = require('path');
5753
+ const crypto = require('crypto');
5754
+
5755
+ const files = [];
5756
+ const collect = (current) => {
5757
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
5758
+ const full = path.join(current, entry.name);
5759
+ if (entry.isDirectory()) {
5760
+ collect(full);
5761
+ } else if (entry.isFile() || entry.isSymbolicLink()) {
5762
+ let relative = path.relative(dir, full);
5763
+ // Normalize to the same separator the Rust to_string_lossy yields on
5764
+ // the platform — on POSIX both use '/'. Windows separators differ but
5765
+ // the cartridge tree is POSIX in practice; keep relative verbatim.
5766
+ if (relative === 'cartridge.json') continue;
5767
+ files.push([relative, full]);
5768
+ }
5769
+ }
5770
+ };
5771
+ collect(dir);
5772
+ files.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
5773
+
5774
+ const hasher = crypto.createHash('sha256');
5775
+ for (const [relativePath, fullPath] of files) {
5776
+ hasher.update(Buffer.from(relativePath, 'utf8'));
5777
+ hasher.update(fs.readFileSync(fullPath));
5778
+ }
5779
+ return hasher.digest('hex');
5780
+ }
5781
+
5782
+ // ---------------------------------------------------------------------------
5783
+ // Bundled-provider integrity (non-macOS).
5784
+ // ---------------------------------------------------------------------------
5785
+ //
5786
+ // Mirrors capdag::cartridge_discovery::verify_bundled_provider_hash and the
5787
+ // BUNDLED_PROVIDER_HASHES const codegen'd by build.rs. Under a plain build —
5788
+ // and in this mirror, which bundles no providers — the set is empty, so any
5789
+ // `installed_from: bundle` cartridge has no baked hash and is rejected.
5790
+ const BUNDLED_PROVIDER_HASHES = Object.freeze([]); // [name, version, hash] triples
5791
+
5792
+ /**
5793
+ * Look up the baked expected directory hash for a bundled provider, or null if
5794
+ * (name, version) was not recorded at build time.
5795
+ * @returns {string|null}
5796
+ */
5797
+ function bundledProviderExpectedHash(name, version) {
5798
+ for (const [n, v, h] of BUNDLED_PROVIDER_HASHES) {
5799
+ if (n === name && v === version) return h;
5800
+ }
5801
+ return null;
5802
+ }
5803
+
5804
+ /**
5805
+ * Verify a bundled provider's on-disk content against the hash baked into this
5806
+ * build. Returns null on success, or an error-reason string. Mirrors
5807
+ * verify_bundled_provider_hash. Non-macOS only: macOS bundled-provider
5808
+ * integrity is the OS code-signature, so the engine there neither bakes nor
5809
+ * checks these hashes.
5810
+ *
5811
+ * @returns {string|null} null on OK, else the failure reason.
5812
+ */
5813
+ function verifyBundledProviderHash(name, version, versionDir) {
5814
+ const expected = bundledProviderExpectedHash(name, version);
5815
+ if (expected === null) {
5816
+ return `no baked hash for bundled provider ${name} ${version} — this build did not record it (MFR_BUNDLED_PROVIDER_HASHES)`;
5817
+ }
5818
+ let actual;
5819
+ try {
5820
+ actual = hashCartridgeDirectory(versionDir);
5821
+ } catch (e) {
5822
+ return `failed to hash bundled provider directory: ${e.message}`;
5823
+ }
5824
+ if (actual === expected) return null;
5825
+ return `content hash mismatch — baked ${expected}, on-disk ${actual}; the shipped provider differs from what this build was compiled to ship`;
5826
+ }
5827
+
5828
+ /**
5829
+ * The identity a host accepts cartridges for. A cartridge whose cartridge.json
5830
+ * diverges from this on channel, registry URL, registry scheme, or fabric
5831
+ * manifest version is surfaced as incompatible — never hosted. Mirrors Rust
5832
+ * DiscoveryIdentity.
5833
+ */
5834
+ class DiscoveryIdentity {
5835
+ /**
5836
+ * @param {Object} opts
5837
+ * @param {string} opts.channel - A CartridgeChannel value.
5838
+ * @param {string|null} opts.registryUrl - Some(url) for release/nightly
5839
+ * hosts, null for dev hosts.
5840
+ * @param {number} opts.fabricManifestVersion
5841
+ */
5842
+ constructor({ channel, registryUrl, fabricManifestVersion }) {
5843
+ if (channel !== CartridgeChannel.Release && channel !== CartridgeChannel.Nightly) {
5844
+ throw new Error(`DiscoveryIdentity: invalid channel '${channel}'`);
5845
+ }
5846
+ this.channel = channel;
5847
+ this.registryUrl = registryUrl !== undefined ? registryUrl : null;
5848
+ this.fabricManifestVersion = fabricManifestVersion;
5849
+ }
5850
+
5851
+ /** This host's own baked-registry slug (`dev` when registryUrl is null). */
5852
+ slug() {
5853
+ return slugForSync(this.registryUrl);
5854
+ }
5855
+ }
5856
+
5857
+ /**
5858
+ * A discovered cartridge version directory, classified. Mirrors Rust
5859
+ * DiscoveredCartridge — a tagged union with `kind` of `directory` (passed all
5860
+ * checks and its HELLO probe succeeded) or `incompatible` (found on disk but
5861
+ * failed a check; carries a structured `error`).
5862
+ */
5863
+ class DiscoveredCartridge {
5864
+ constructor(kind, fields) {
5865
+ this.kind = kind; // 'directory' | 'incompatible'
5866
+ Object.assign(this, fields);
5867
+ }
5868
+ static directory(fields) { return new DiscoveredCartridge('directory', fields); }
5869
+ static incompatible(fields) { return new DiscoveredCartridge('incompatible', fields); }
5870
+ }
5871
+
5872
+ /** Current wall-clock time as Unix seconds. A pre-epoch clock returns 0. */
5873
+ function unixSecondsNow() {
5874
+ const s = Math.floor(Date.now() / 1000);
5875
+ return s >= 0 ? s : 0;
5876
+ }
5877
+
5878
+ // Default protocol limits / version, mirroring capdag::bifaci::frame.
5879
+ const BIFACI_PROTOCOL_VERSION = 2;
5880
+ const BIFACI_DEFAULT_MAX_FRAME = 3670016;
5881
+ const BIFACI_DEFAULT_MAX_CHUNK = 262144;
5882
+ const BIFACI_DEFAULT_MAX_REORDER_BUFFER = 64;
5883
+ const BIFACI_FRAME_TYPE_HELLO = 0;
5884
+ // Frame CBOR integer keys (capdag::bifaci::frame::keys).
5885
+ const BIFACI_KEY_VERSION = 0;
5886
+ const BIFACI_KEY_FRAME_TYPE = 1;
5887
+ const BIFACI_KEY_ID = 2;
5888
+ const BIFACI_KEY_SEQ = 3;
5889
+ const BIFACI_KEY_META = 5;
5890
+
5891
+ /**
5892
+ * Minimal CBOR encoder covering the value shapes the bifaci HELLO frame uses:
5893
+ * unsigned/negative integers, byte strings, text strings, booleans, and maps.
5894
+ * Faithful to RFC 8949 major-type encoding so the bytes are wire-compatible
5895
+ * with the Rust `ciborium` encoder. Map keys are emitted in the order given.
5896
+ *
5897
+ * @param {*} value - One of: number (integer), {__bytes: Buffer}, string,
5898
+ * boolean, or {__map: [[key, val], ...]}.
5899
+ * @returns {Buffer}
5900
+ */
5901
+ function cborEncode(value) {
5902
+ const out = [];
5903
+ const pushTypeLen = (major, n) => {
5904
+ const mt = major << 5;
5905
+ if (n < 24) {
5906
+ out.push(mt | n);
5907
+ } else if (n < 0x100) {
5908
+ out.push(mt | 24, n);
5909
+ } else if (n < 0x10000) {
5910
+ out.push(mt | 25, (n >> 8) & 0xff, n & 0xff);
5911
+ } else if (n < 0x100000000) {
5912
+ out.push(mt | 26, (n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff);
5913
+ } else {
5914
+ // 64-bit: split into high/low 32-bit halves.
5915
+ const hi = Math.floor(n / 0x100000000);
5916
+ const lo = n >>> 0;
5917
+ out.push(mt | 27,
5918
+ (hi >>> 24) & 0xff, (hi >>> 16) & 0xff, (hi >>> 8) & 0xff, hi & 0xff,
5919
+ (lo >>> 24) & 0xff, (lo >>> 16) & 0xff, (lo >>> 8) & 0xff, lo & 0xff);
5920
+ }
5921
+ };
5922
+ const enc = (v) => {
5923
+ if (typeof v === 'number') {
5924
+ if (!Number.isInteger(v)) throw new Error('cborEncode: only integers supported');
5925
+ if (v >= 0) pushTypeLen(0, v);
5926
+ else pushTypeLen(1, -v - 1);
5927
+ } else if (typeof v === 'boolean') {
5928
+ out.push(v ? 0xf5 : 0xf4);
5929
+ } else if (typeof v === 'string') {
5930
+ const buf = Buffer.from(v, 'utf8');
5931
+ pushTypeLen(3, buf.length);
5932
+ for (const b of buf) out.push(b);
5933
+ } else if (v && v.__bytes !== undefined) {
5934
+ const buf = Buffer.isBuffer(v.__bytes) ? v.__bytes : Buffer.from(v.__bytes);
5935
+ pushTypeLen(2, buf.length);
5936
+ for (const b of buf) out.push(b);
5937
+ } else if (v && v.__map !== undefined) {
5938
+ pushTypeLen(5, v.__map.length);
5939
+ for (const [k, val] of v.__map) { enc(k); enc(val); }
5940
+ } else {
5941
+ throw new Error('cborEncode: unsupported value');
5942
+ }
5943
+ };
5944
+ enc(value);
5945
+ return Buffer.from(out);
5946
+ }
5947
+
5948
+ /**
5949
+ * Minimal CBOR decoder returning a JS view of the value. Integers → number,
5950
+ * byte strings → {__bytes: Buffer}, text → string, bool → boolean, maps →
5951
+ * Map. Sufficient to parse a bifaci HELLO response frame. Throws on
5952
+ * unsupported/truncated input. Returns {value, offset}.
5953
+ */
5954
+ function cborDecodeAt(buf, pos) {
5955
+ if (pos >= buf.length) throw new Error('cbor: unexpected end');
5956
+ const ib = buf[pos++];
5957
+ const major = ib >> 5;
5958
+ const minor = ib & 0x1f;
5959
+ const readLen = () => {
5960
+ if (minor < 24) return minor;
5961
+ if (minor === 24) { return buf[pos++]; }
5962
+ if (minor === 25) { const n = (buf[pos] << 8) | buf[pos + 1]; pos += 2; return n; }
5963
+ if (minor === 26) { const n = (buf[pos] * 0x1000000) + (buf[pos + 1] << 16) + (buf[pos + 2] << 8) + buf[pos + 3]; pos += 4; return n; }
5964
+ if (minor === 27) {
5965
+ let n = 0;
5966
+ for (let i = 0; i < 8; i++) { n = n * 256 + buf[pos++]; }
5967
+ return n;
5968
+ }
5969
+ throw new Error('cbor: bad length encoding');
5970
+ };
5971
+ switch (major) {
5972
+ case 0: { const n = readLen(); return { value: n, offset: pos }; }
5973
+ case 1: { const n = readLen(); return { value: -1 - n, offset: pos }; }
5974
+ case 2: { const len = readLen(); const b = buf.slice(pos, pos + len); pos += len; return { value: { __bytes: b }, offset: pos }; }
5975
+ case 3: { const len = readLen(); const s = buf.slice(pos, pos + len).toString('utf8'); pos += len; return { value: s, offset: pos }; }
5976
+ case 5: {
5977
+ const len = readLen();
5978
+ const m = new Map();
5979
+ for (let i = 0; i < len; i++) {
5980
+ const k = cborDecodeAt(buf, pos); pos = k.offset;
5981
+ const v = cborDecodeAt(buf, pos); pos = v.offset;
5982
+ m.set(k.value, v.value);
5983
+ }
5984
+ return { value: m, offset: pos };
5985
+ }
5986
+ case 7: {
5987
+ if (minor === 20) return { value: false, offset: pos };
5988
+ if (minor === 21) return { value: true, offset: pos };
5989
+ if (minor === 22) return { value: null, offset: pos };
5990
+ throw new Error('cbor: unsupported simple value');
5991
+ }
5992
+ default:
5993
+ throw new Error(`cbor: unsupported major type ${major}`);
5994
+ }
5995
+ }
5996
+
5997
+ /**
5998
+ * Encode the engine-side HELLO frame as a length-prefixed CBOR frame, exactly
5999
+ * as capdag::bifaci::io::write_frame does: a 4-byte big-endian length prefix
6000
+ * followed by the CBOR-encoded frame map. Mirrors Frame::hello with default
6001
+ * Limits.
6002
+ *
6003
+ * @returns {Buffer}
6004
+ */
6005
+ function encodeHelloFrame() {
6006
+ const meta = {
6007
+ __map: [
6008
+ ['max_frame', BIFACI_DEFAULT_MAX_FRAME],
6009
+ ['max_chunk', BIFACI_DEFAULT_MAX_CHUNK],
6010
+ ['max_reorder_buffer', BIFACI_DEFAULT_MAX_REORDER_BUFFER],
6011
+ ['version', BIFACI_PROTOCOL_VERSION],
6012
+ ],
6013
+ };
6014
+ const frame = {
6015
+ __map: [
6016
+ [BIFACI_KEY_VERSION, BIFACI_PROTOCOL_VERSION],
6017
+ [BIFACI_KEY_FRAME_TYPE, BIFACI_FRAME_TYPE_HELLO],
6018
+ [BIFACI_KEY_ID, 0],
6019
+ [BIFACI_KEY_SEQ, 0],
6020
+ [BIFACI_KEY_META, meta],
6021
+ ],
6022
+ };
6023
+ const body = cborEncode(frame);
6024
+ const prefix = Buffer.alloc(4);
6025
+ prefix.writeUInt32BE(body.length, 0);
6026
+ return Buffer.concat([prefix, body]);
6027
+ }
6028
+
6029
+ /**
6030
+ * Probe a cartridge binary for its capability surface. Spawns the binary,
6031
+ * performs the bifaci HELLO handshake (engine sends first), parses the
6032
+ * manifest from the cartridge's HELLO response, then kills the process. A
6033
+ * binary that fails to spawn, fails HELLO, or returns an unparseable manifest
6034
+ * is an error — the caller surfaces it as HandshakeFailed.
6035
+ *
6036
+ * Mirrors capdag::cartridge_discovery::probe_cartridge_cap_groups. The handshake
6037
+ * is the real length-prefixed CBOR frame exchange (capdag::bifaci::io); a
6038
+ * non-bifaci binary closes its pipes without emitting a HELLO frame, so the
6039
+ * read of the length-prefixed response hits EOF and the probe fails — exactly
6040
+ * as it must.
6041
+ *
6042
+ * @param {string} entryPath - Absolute path to the entry binary.
6043
+ * @returns {Promise<Array<Object>>} The manifest's cap_groups.
6044
+ * @throws {Error} when the handshake/probe fails.
6045
+ */
6046
+ function probeCartridgeCapGroups(entryPath) {
6047
+ const { spawn } = require('child_process');
6048
+ return new Promise((resolve, reject) => {
6049
+ let child;
6050
+ try {
6051
+ child = spawn(entryPath, [], { stdio: ['pipe', 'pipe', 'inherit'] });
6052
+ } catch (e) {
6053
+ reject(new Error(`Failed to spawn cartridge ${entryPath}: ${e.message}`));
6054
+ return;
6055
+ }
6056
+
6057
+ let settled = false;
6058
+ let buf = Buffer.alloc(0);
6059
+ let timer = null;
6060
+
6061
+ const finish = (err, value) => {
6062
+ if (settled) return;
6063
+ settled = true;
6064
+ if (timer) clearTimeout(timer);
6065
+ try { child.kill('SIGKILL'); } catch (_e) { /* already gone */ }
6066
+ if (err) reject(err); else resolve(value);
6067
+ };
6068
+
6069
+ child.on('error', (e) => finish(new Error(`cartridge ${entryPath} spawn error: ${e.message}`)));
6070
+ child.on('close', () => finish(new Error(`cartridge ${entryPath} HELLO failed: connection closed before receiving HELLO`)));
6071
+
6072
+ timer = setTimeout(() => finish(new Error(`cartridge ${entryPath} HELLO failed: timeout`)), 5000);
6073
+
6074
+ // Send our HELLO first (host side).
6075
+ try {
6076
+ child.stdin.write(encodeHelloFrame());
6077
+ } catch (e) {
6078
+ finish(new Error(`cartridge ${entryPath} HELLO failed: ${e.message}`));
6079
+ return;
6080
+ }
6081
+
6082
+ const tryParse = () => {
6083
+ if (buf.length < 4) return;
6084
+ const len = buf.readUInt32BE(0);
6085
+ if (buf.length < 4 + len) return;
6086
+ const body = buf.slice(4, 4 + len);
6087
+ let decoded;
6088
+ try {
6089
+ decoded = cborDecodeAt(body, 0).value;
6090
+ } catch (e) {
6091
+ finish(new Error(`cartridge ${entryPath} HELLO failed: ${e.message}`));
6092
+ return;
6093
+ }
6094
+ if (!(decoded instanceof Map)) {
6095
+ finish(new Error(`cartridge ${entryPath} HELLO failed: expected frame map`));
6096
+ return;
6097
+ }
6098
+ const frameType = decoded.get(BIFACI_KEY_FRAME_TYPE);
6099
+ if (frameType !== BIFACI_FRAME_TYPE_HELLO) {
6100
+ finish(new Error(`cartridge ${entryPath} HELLO failed: expected HELLO, got frame_type ${frameType}`));
6101
+ return;
6102
+ }
6103
+ const meta = decoded.get(BIFACI_KEY_META);
6104
+ const manifestVal = meta instanceof Map ? meta.get('manifest') : undefined;
6105
+ if (!manifestVal || manifestVal.__bytes === undefined) {
6106
+ finish(new Error(`cartridge ${entryPath} HELLO failed: Cartridge HELLO missing required manifest`));
6107
+ return;
6108
+ }
6109
+ let manifest;
6110
+ try {
6111
+ manifest = JSON.parse(Buffer.from(manifestVal.__bytes).toString('utf8'));
6112
+ } catch (e) {
6113
+ finish(new Error(`cartridge ${entryPath} invalid manifest: ${e.message}`));
6114
+ return;
6115
+ }
6116
+ finish(null, Array.isArray(manifest.cap_groups) ? manifest.cap_groups : []);
6117
+ };
6118
+
6119
+ child.stdout.on('data', (chunk) => {
6120
+ buf = Buffer.concat([buf, chunk]);
6121
+ tryParse();
6122
+ });
6123
+ });
6124
+ }
6125
+
6126
+ /**
6127
+ * Discover every cartridge under `{cartridgesRoot}/{slug}/{channel}/`. Scans
6128
+ * EVERY slug folder present on disk (full parity) — the host's baked
6129
+ * `identity.registryUrl` does NOT restrict which slugs are scanned; each
6130
+ * cartridge is validated in place against the slug folder it sits under (the
6131
+ * three-place rule in `CartridgeJson.readFromDir`). The channel folder IS
6132
+ * pinned to the host's channel. An empty/absent scan root yields an empty
6133
+ * roster; a real IO failure reading an existing root is an error.
6134
+ *
6135
+ * Mirrors capdag::cartridge_discovery::discover_cartridges.
6136
+ *
6137
+ * @param {string} cartridgesRoot
6138
+ * @param {DiscoveryIdentity} identity
6139
+ * @returns {Promise<Array<DiscoveredCartridge>>}
6140
+ */
6141
+ async function discoverCartridges(cartridgesRoot, identity) {
6142
+ const fs = require('fs');
6143
+ const path = require('path');
6144
+ const discovered = [];
6145
+
6146
+ let rootStat;
6147
+ try { rootStat = fs.statSync(cartridgesRoot); } catch (_e) { return discovered; }
6148
+ if (!rootStat.isDirectory()) return discovered;
6149
+
6150
+ let slugEntries;
6151
+ try {
6152
+ slugEntries = fs.readdirSync(cartridgesRoot, { withFileTypes: true });
6153
+ } catch (e) {
6154
+ throw new Error(`read_dir(${cartridgesRoot}): ${e.message}`);
6155
+ }
6156
+
6157
+ for (const slugEntry of slugEntries) {
6158
+ const slugDir = path.join(cartridgesRoot, slugEntry.name);
6159
+ if (!slugEntry.isDirectory()) {
6160
+ // Unmanaged file in cartridges root — only registry-slug / dev
6161
+ // directories belong here. Skipped (logged in the reference).
6162
+ continue;
6163
+ }
6164
+ const expectedSlug = slugEntry.name;
6165
+ const scanRoot = path.join(slugDir, identity.channel);
6166
+ let scanStat;
6167
+ try { scanStat = fs.statSync(scanRoot); } catch (_e) { continue; }
6168
+ if (!scanStat.isDirectory()) continue;
6169
+ await scanChannelRoot(scanRoot, expectedSlug, identity, discovered);
6170
+ }
6171
+
6172
+ return discovered;
6173
+ }
6174
+
6175
+ /**
6176
+ * Scan one `{slug}/{channel}/` root: classify each cartridge name directory's
6177
+ * newest version against the host identity and the slug folder it sits under.
6178
+ * Mirrors capdag::cartridge_discovery::scan_channel_root.
6179
+ */
6180
+ async function scanChannelRoot(scanRoot, expectedSlug, identity, discovered) {
6181
+ const fs = require('fs');
6182
+ const path = require('path');
6183
+
6184
+ let nameEntries;
6185
+ try {
6186
+ nameEntries = fs.readdirSync(scanRoot, { withFileTypes: true });
6187
+ } catch (e) {
6188
+ throw new Error(`read_dir(${scanRoot}): ${e.message}`);
6189
+ }
6190
+
6191
+ for (const entry of nameEntries) {
6192
+ const nameDir = path.join(scanRoot, entry.name);
6193
+ if (!entry.isDirectory()) continue;
6194
+
6195
+ let subEntries;
6196
+ try {
6197
+ subEntries = fs.readdirSync(nameDir, { withFileTypes: true });
6198
+ } catch (_e) {
6199
+ continue;
6200
+ }
6201
+
6202
+ const versionDirs = [];
6203
+ for (const sub of subEntries) {
6204
+ if (sub.isDirectory()) versionDirs.push(path.join(nameDir, sub.name));
6205
+ }
6206
+ if (versionDirs.length === 0) continue;
6207
+
6208
+ // Prefer the newest version (lexical-descending on the version folder name).
6209
+ versionDirs.sort((a, b) => {
6210
+ const va = path.basename(a);
6211
+ const vb = path.basename(b);
6212
+ return vb < va ? -1 : vb > va ? 1 : 0;
6213
+ });
6214
+ const versionDir = versionDirs[0];
6215
+
6216
+ const pathDerivedName = path.basename(nameDir);
6217
+ const pathDerivedVersion = path.basename(versionDir);
6218
+ const detectedAt = unixSecondsNow();
6219
+
6220
+ // read_from_dir enforces the three-place rule against the ACTUAL slug
6221
+ // folder (expectedSlug): the cartridge's declared registry_url must hash
6222
+ // to it. A slug mismatch is a BadInstallation; an unreadable/garbage
6223
+ // cartridge.json is ManifestInvalid. Both surfaced, never hosted.
6224
+ let cj;
6225
+ try {
6226
+ cj = CartridgeJson.readFromDir(versionDir, expectedSlug);
6227
+ } catch (e) {
6228
+ const kind = (e instanceof CartridgeJsonError && e.kind === CartridgeJsonErrorKind.REGISTRY_SLUG_MISMATCH)
6229
+ ? CartridgeAttachmentErrorKind.BAD_INSTALLATION
6230
+ : CartridgeAttachmentErrorKind.MANIFEST_INVALID;
6231
+ discovered.push(DiscoveredCartridge.incompatible({
6232
+ version_dir: versionDir,
6233
+ id: pathDerivedName,
6234
+ channel: identity.channel,
6235
+ registry_url: identity.registryUrl,
6236
+ version: pathDerivedVersion,
6237
+ error: new CartridgeAttachmentError(
6238
+ kind,
6239
+ `cartridge.json failed to load under slug '${expectedSlug}': ${e.message}`,
6240
+ detectedAt
6241
+ ),
6242
+ }));
6243
+ continue;
6244
+ }
6245
+
6246
+ if (cj.channel !== identity.channel) {
6247
+ discovered.push(DiscoveredCartridge.incompatible({
6248
+ version_dir: versionDir,
6249
+ id: cj.name,
6250
+ channel: cj.channel,
6251
+ registry_url: cj.registryUrl,
6252
+ version: cj.version,
6253
+ error: new CartridgeAttachmentError(
6254
+ CartridgeAttachmentErrorKind.BAD_INSTALLATION,
6255
+ `Channel mismatch: cartridge declares '${cj.channel}' but host is pinned to '${identity.channel}'. Release and nightly artefacts must not mix.`,
6256
+ detectedAt
6257
+ ),
6258
+ }));
6259
+ continue;
6260
+ }
6261
+
6262
+ // Scheme check is per-cartridge: a dev cartridge (null registry_url) never
6263
+ // reaches here; a registry cartridge must use https (devMode=false).
6264
+ if (cj.registryUrl !== null && cj.registryUrl !== undefined) {
6265
+ const res = validateRegistryUrlScheme(cj.registryUrl, false);
6266
+ if (res.kind === RegistryUrlSchemeResultKind.NON_HTTPS) {
6267
+ discovered.push(DiscoveredCartridge.incompatible({
6268
+ version_dir: versionDir,
6269
+ id: cj.name,
6270
+ channel: cj.channel,
6271
+ registry_url: cj.registryUrl,
6272
+ version: cj.version,
6273
+ error: new CartridgeAttachmentError(
6274
+ CartridgeAttachmentErrorKind.INCOMPATIBLE,
6275
+ `registry_url uses '${res.scheme}' scheme, must be https in non-dev builds. Rebuild the cartridge with an https registry URL.`,
6276
+ detectedAt
6277
+ ),
6278
+ }));
6279
+ continue;
6280
+ }
6281
+ if (res.kind === RegistryUrlSchemeResultKind.NOT_A_URL) {
6282
+ discovered.push(DiscoveredCartridge.incompatible({
6283
+ version_dir: versionDir,
6284
+ id: cj.name,
6285
+ channel: cj.channel,
6286
+ registry_url: cj.registryUrl,
6287
+ version: cj.version,
6288
+ error: new CartridgeAttachmentError(
6289
+ CartridgeAttachmentErrorKind.INCOMPATIBLE,
6290
+ `registry_url '${res.url}' is not a well-formed URL.`,
6291
+ detectedAt
6292
+ ),
6293
+ }));
6294
+ continue;
6295
+ }
6296
+ }
6297
+
6298
+ if (cj.fabricManifestVersion !== identity.fabricManifestVersion) {
6299
+ discovered.push(DiscoveredCartridge.incompatible({
6300
+ version_dir: versionDir,
6301
+ id: cj.name,
6302
+ channel: cj.channel,
6303
+ registry_url: cj.registryUrl,
6304
+ version: cj.version,
6305
+ error: new CartridgeAttachmentError(
6306
+ CartridgeAttachmentErrorKind.FABRIC_MANIFEST_VERSION_MISMATCH,
6307
+ `Cartridge built against fabric manifest version ${cj.fabricManifestVersion}, but host is pinned to ${identity.fabricManifestVersion}. Rebuild the cartridge with MFR_FABRIC_MANIFEST_VERSION=${identity.fabricManifestVersion}.`,
6308
+ detectedAt
6309
+ ),
6310
+ }));
6311
+ continue;
6312
+ }
6313
+
6314
+ // Bundled-provider integrity. A cartridge marked installed_from=bundle is
6315
+ // shipped INSIDE this build and has no upstream registry to verify against,
6316
+ // so it needs its own integrity proof. Platform-split: on macOS the OS
6317
+ // code-signature is the guard (baked-hash verification intentionally
6318
+ // skipped); on non-macOS the on-disk directory must hash to the baked
6319
+ // value, else BadInstallation.
6320
+ if (cj.installedFrom === CartridgeInstallSource.BUNDLE) {
6321
+ if (process.platform !== 'darwin') {
6322
+ const reason = verifyBundledProviderHash(cj.name, cj.version, versionDir);
6323
+ if (reason !== null) {
6324
+ discovered.push(DiscoveredCartridge.incompatible({
6325
+ version_dir: versionDir,
6326
+ id: cj.name,
6327
+ channel: cj.channel,
6328
+ registry_url: cj.registryUrl,
6329
+ version: cj.version,
6330
+ error: new CartridgeAttachmentError(
6331
+ CartridgeAttachmentErrorKind.BAD_INSTALLATION,
6332
+ `bundled provider integrity check failed: ${reason}`,
6333
+ detectedAt
6334
+ ),
6335
+ }));
6336
+ continue;
6337
+ }
6338
+ }
6339
+ }
6340
+
6341
+ const entryPoint = cj.resolveEntryPoint(versionDir);
6342
+ try {
6343
+ const capGroups = await probeCartridgeCapGroups(entryPoint);
6344
+ discovered.push(DiscoveredCartridge.directory({
6345
+ entry_point: entryPoint,
6346
+ version_dir: versionDir,
6347
+ id: cj.name,
6348
+ channel: cj.channel,
6349
+ registry_url: cj.registryUrl,
6350
+ version: cj.version,
6351
+ cap_groups: capGroups,
6352
+ }));
6353
+ } catch (e) {
6354
+ discovered.push(DiscoveredCartridge.incompatible({
6355
+ version_dir: versionDir,
6356
+ id: cj.name,
6357
+ channel: cj.channel,
6358
+ registry_url: cj.registryUrl,
6359
+ version: cj.version,
6360
+ error: new CartridgeAttachmentError(
6361
+ CartridgeAttachmentErrorKind.HANDSHAKE_FAILED,
6362
+ `HELLO handshake / cap discovery probe failed: ${e.message}`,
6363
+ detectedAt
6364
+ ),
6365
+ }));
6366
+ }
6367
+ }
6368
+ }
6369
+
5196
6370
  // ============================================================================
5197
6371
  // Machine Notation — compact, round-trippable DAG path identifiers
5198
6372
  //
@@ -6416,7 +7590,7 @@ module.exports = {
6416
7590
  MEDIA_OBJECT,
6417
7591
  // List types
6418
7592
  MEDIA_LIST,
6419
- MEDIA_TEXTABLE_LIST,
7593
+ MEDIA_TEXTABLE_LIST: MEDIA_STRING_LIST,
6420
7594
  MEDIA_STRING_LIST,
6421
7595
  MEDIA_INTEGER_LIST,
6422
7596
  MEDIA_NUMBER_LIST,
@@ -6540,6 +7714,28 @@ module.exports = {
6540
7714
  CartridgeAttachmentError,
6541
7715
  CartridgeRuntimeStats,
6542
7716
  InstalledCartridgeRecord,
7717
+ // Host-compatibility resolution
7718
+ hostPlatform,
7719
+ CompatStatus,
7720
+ primaryPackage,
7721
+ CartridgeCompatibilityResolution,
7722
+ // Build-env registry identity
7723
+ registryUrlFromBuildEnv,
7724
+ // Cartridge discovery
7725
+ slugForSync,
7726
+ CartridgeInstallSource,
7727
+ RegistryUrlSchemeResultKind,
7728
+ validateRegistryUrlScheme,
7729
+ CartridgeJsonErrorKind,
7730
+ CartridgeJsonError,
7731
+ CartridgeJson,
7732
+ hashCartridgeDirectory,
7733
+ BUNDLED_PROVIDER_HASHES,
7734
+ verifyBundledProviderHash,
7735
+ DiscoveryIdentity,
7736
+ DiscoveredCartridge,
7737
+ probeCartridgeCapGroups,
7738
+ discoverCartridges,
6543
7739
  // Registry slug
6544
7740
  DEV_SLUG,
6545
7741
  SLUG_HEX_LEN,