@gregiteen/ssss-cli 0.10.0 → 0.10.2

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/CHANGELOG.md CHANGED
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.2] - 2026-09-28
11
+ ### Fixed
12
+ - The 0.10.0 notes promised pointer fields for large-file assets, but the
13
+ `asset` registry entry never declared them. It now declares `storage_uri`
14
+ and `resource_ref`. An asset with `encoding: none` is a pointer: it must set
15
+ `storage_uri` and be marked `x_portability: resource_bound` (or
16
+ `tenant_private`). `resource_ref` must name a `resource` document. Template
17
+ and sale exports replace `storage_uri` with `REQUIREMENT` and keep `hash` and
18
+ `size_bytes`, so the buyer can check the file they bind.
19
+
20
+ ### Behavior changes hosts should check
21
+ - An `asset` with `encoding: none` and no `storage_uri` or no
22
+ `x_portability: resource_bound|tenant_private` is now rejected.
23
+ - An `asset`'s `x_portability` may only be `resource_bound` or `tenant_private`.
24
+ - `resource_ref` on an `asset` must point at an existing `resource` document.
25
+ - Template/sale bundles containing pointer assets now declare a
26
+ `asset_storage_uri` parameter and a `bind-asset` provisioning step.
27
+ - New conformance section: asset pointers (§5.4 asset, §5.5).
28
+
29
+ ## [0.10.1] - 2026-09-28
30
+ ### Fixed
31
+ - A required list field set to an empty list (`deferred_steps: []`) was
32
+ rejected as "Missing required field". Required means present: only an
33
+ absent, `null` or empty-string value is missing now. Hosts had to patch the
34
+ package to write valid documents with empty lists.
35
+
36
+ ### Behavior changes hosts should check
37
+ - Documents with a required list field set to `[]` now validate.
38
+ - New conformance checks: an empty list satisfies a required list field;
39
+ absent, `null` and empty-string values are still missing.
40
+
10
41
  ## [0.10.0] - 2026-09-28
11
42
  ### Added
12
43
  - **`asset` primitive**: Text-encoded files (Base64 for small binaries, UTF-8 for code/SVGs) and pointer metadata for large files (`resource_ref`, `storage_uri`, `hash`).
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.10.0
1
+ 0.10.2
@@ -106,7 +106,7 @@
106
106
  "file_count": 14,
107
107
  "provenance": {
108
108
  "content_hash": "sha256:7febd673938a3dc04c068e0bf08097a9783c09e612606d2df9a4db8dcb8907f5",
109
- "exporter": "@gregiteen/ssss-cli@0.10.0"
109
+ "exporter": "@gregiteen/ssss-cli@0.10.2"
110
110
  }
111
111
  },
112
112
  "files": [
package/docs/ssss-spec.md CHANGED
@@ -354,6 +354,7 @@ timestamp: 2026-05-16T14:00:00Z
354
354
  A text-encoded asset payload: source code, vector graphics, or base64-encoded binary content, or pointer metadata for large files.
355
355
 
356
356
  REQUIRED: `type`, `name`, `mime_type`, `encoding` (`base64|utf-8|none`).
357
+ OPTIONAL: `slug`, `description`, `hash`, `size_bytes`, `storage_uri`, `resource_ref`.
357
358
 
358
359
  ```markdown
359
360
  ---
@@ -369,6 +370,33 @@ encoding: "base64"
369
370
  iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==
370
371
  ```
371
372
 
373
+ A file too large to carry in the vault is stored as a **pointer**: `encoding: none`
374
+ and an empty body. A pointer REQUIRES `storage_uri`, the URI of the stored object,
375
+ and `x_portability` set to `resource_bound` or `tenant_private` (§5.5), so a
376
+ template or sale export never ships the seller's storage location: the exporter
377
+ replaces `storage_uri` with `REQUIREMENT` and the buyer binds their own copy at
378
+ provision. `resource_ref`, when present, is the vault path of the `resource`
379
+ document (bucket, object store) that holds the object. `hash` (`sha256:<hex>`) and
380
+ `size_bytes` pin the object's content and survive export, so the buyer can verify
381
+ the file they supply.
382
+
383
+ ```markdown
384
+ ---
385
+ type: asset
386
+ name: "training-video"
387
+ title: "Onboarding Video"
388
+ description: "Staff onboarding video, stored in object storage."
389
+ timestamp: 2026-09-28T00:00:00Z
390
+ mime_type: "video/mp4"
391
+ encoding: "none"
392
+ storage_uri: "s3://media-bucket/training/onboarding.mp4"
393
+ resource_ref: "resources/media-bucket.md"
394
+ hash: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
395
+ size_bytes: 734003200
396
+ x_portability: resource_bound
397
+ ---
398
+ ```
399
+
372
400
  #### `resource`
373
401
 
374
402
  An external infrastructure, cloud asset, domain, database, or API dependency bound to the workspace. Carries `portability: resource_bound`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gregiteen/ssss-cli",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "CLI tools and the reference engine for the Structured Semantic Syntax System",
5
5
  "main": "src/engine.mjs",
6
6
  "exports": {
@@ -40,11 +40,19 @@
40
40
  "append_only": false,
41
41
  "portability": "structural",
42
42
  "required_fields": ["type", "name", "mime_type", "encoding"],
43
- "optional_fields": ["slug", "hash", "size_bytes", "description"],
43
+ "optional_fields": ["slug", "hash", "size_bytes", "description", "storage_uri", "resource_ref", "x_portability"],
44
+ "required_when": {
45
+ "encoding==none": ["storage_uri", "x_portability"]
46
+ },
44
47
  "enums": {
45
- "encoding": ["base64", "utf-8", "none"]
48
+ "encoding": ["base64", "utf-8", "none"],
49
+ "x_portability": ["resource_bound", "tenant_private"]
50
+ },
51
+ "references": {
52
+ "resource_ref": { "allowed_types": ["resource"] }
46
53
  },
47
- "notes": "A text-encoded asset payload: source code, vector graphics, or base64-encoded binary content.",
54
+ "resource": { "kind": "storage", "binds": ["storage_uri"], "provision_relation": "stores_asset" },
55
+ "notes": "A text-encoded asset payload: source code, vector graphics, or base64-encoded binary content. With `encoding: none` the body is empty and the asset is a pointer to a large file: `storage_uri` locates the object, `resource_ref` optionally names the `resource` document (bucket, store) that holds it, and `hash`/`size_bytes` pin its content. A pointer MUST be marked `x_portability: resource_bound` (or `tenant_private`), so template/sale exports replace `storage_uri` with `REQUIREMENT` instead of shipping the seller's storage location.",
48
56
  "spec_ref": "§5.4 asset"
49
57
  },
50
58
  "resource": {
@@ -9,6 +9,7 @@ import {
9
9
  createRegistryLock,
10
10
  loadRegistries,
11
11
  resolvePrimitiveDefinition,
12
+ validateDataConstraints,
12
13
  verifyRegistryLock,
13
14
  } from '../src/registry.mjs';
14
15
  import { createValidator } from '../src/validator.mjs';
@@ -258,6 +259,16 @@ function checkFrontmatterSubset(check) {
258
259
  JSON.stringify(parseDocument(serializeDocument(data, '\nbody')).data) === JSON.stringify(data));
259
260
  }
260
261
 
262
+ // A required field is satisfied by any present value, including an empty
263
+ // list; only an absent, null or empty-string value is missing.
264
+ function checkRequiredPresence(check) {
265
+ const def = { required_fields: ['steps', 'name'] };
266
+ const missing = (data) => validateDataConstraints(def, data).map((i) => i.field).sort().join(',');
267
+ check('required fields: an empty list satisfies a required list field', missing({ steps: [], name: 'x' }) === '');
268
+ check('required fields: absent, null and empty-string values are missing',
269
+ missing({ name: '' }) === 'name,steps' && missing({ steps: null, name: 'x' }) === 'steps');
270
+ }
271
+
261
272
  // Capabilities (spec §6.6): trailing-`*` grants are prefix scoped, and the
262
273
  // pre-0.9 façade still honors the role permissions it always documented.
263
274
  async function checkCapabilities(root, check) {
@@ -606,6 +617,7 @@ export async function runKernel09Conformance() {
606
617
  check('UI planning redacts prompt-injection content and falls back safely', planned.generated && planned.manifest.components[0].bind === 'status');
607
618
  await checkResourceLifecycle(check);
608
619
  checkFrontmatterSubset(check);
620
+ checkRequiredPresence(check);
609
621
  await checkCapabilities(temp, check);
610
622
  } finally {
611
623
  fs.rmSync(temp, { recursive: true, force: true });
@@ -480,6 +480,98 @@ async function runOperationContractRegressionConformance() {
480
480
  return pass === checks.length;
481
481
  }
482
482
 
483
+ async function runAssetPointerConformance() {
484
+ const checks = [];
485
+ const check = (name, cond, detail = '') => { checks.push({ name, cond, detail }); };
486
+
487
+ const vault = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-asset-'));
488
+ const doc = (fields, body = '') => ['---', ...fields, '---', '', body, ''].join('\n');
489
+ const assetHead = [
490
+ 'type: asset',
491
+ 'name: training-video',
492
+ 'title: Training Video',
493
+ 'description: Onboarding video stored outside the vault.',
494
+ 'timestamp: 2026-09-28T00:00:00Z',
495
+ 'mime_type: video/mp4',
496
+ ];
497
+ let n = 0;
498
+ const write = (filePath, content) => engine.processOperation({
499
+ type: 'operation',
500
+ idempotency_key: `asset-${++n}`,
501
+ workspace_id: 'ws-asset',
502
+ path: filePath,
503
+ actor: { role: 'system' },
504
+ content,
505
+ }, vault);
506
+ const errorsOf = (res) => (res.validation?.errors || []).join('; ');
507
+ const engine = createEngine({ registryDir: REGISTRY_DIR });
508
+ try {
509
+ const bucket = await write('resources/media-bucket.md', doc([
510
+ 'type: resource',
511
+ 'name: Media Bucket',
512
+ 'title: Media Bucket',
513
+ 'description: Object storage for large media.',
514
+ 'timestamp: 2026-09-28T00:00:00Z',
515
+ 'kind: s3_bucket',
516
+ 'status: bound',
517
+ 'binds: s3://seller-media',
518
+ ]));
519
+ check('resource fixture commits', bucket.success === true, errorsOf(bucket));
520
+
521
+ const inline = await write('assets/logo.md', doc([
522
+ 'type: asset', 'name: logo', 'title: Logo', 'description: Inline icon.',
523
+ 'timestamp: 2026-09-28T00:00:00Z', 'mime_type: image/svg+xml', 'encoding: utf-8',
524
+ ], '<svg xmlns="http://www.w3.org/2000/svg"/>'));
525
+ check('inline asset needs no pointer fields', inline.success === true, errorsOf(inline));
526
+
527
+ const noUri = await write('assets/no-uri.md', doc([...assetHead, 'encoding: none', 'x_portability: resource_bound']));
528
+ check('pointer asset without storage_uri is rejected',
529
+ noUri.success === false && errorsOf(noUri).includes('storage_uri'), errorsOf(noUri));
530
+
531
+ const noClass = await write('assets/no-class.md', doc([...assetHead, 'encoding: none', 'storage_uri: s3://seller-media/v.mp4']));
532
+ check('pointer asset without x_portability is rejected',
533
+ noClass.success === false && errorsOf(noClass).includes('x_portability'), errorsOf(noClass));
534
+
535
+ const structural = await write('assets/structural.md', doc([...assetHead, 'encoding: none',
536
+ 'storage_uri: s3://seller-media/v.mp4', 'x_portability: structural']));
537
+ check('pointer asset cannot be marked structural',
538
+ structural.success === false && errorsOf(structural).includes('x_portability'), errorsOf(structural));
539
+
540
+ const wrongRef = await write('assets/wrong-ref.md', doc([...assetHead, 'encoding: none',
541
+ 'storage_uri: s3://seller-media/v.mp4', 'x_portability: resource_bound', 'resource_ref: assets/logo.md']));
542
+ check('resource_ref must name a resource document',
543
+ wrongRef.success === false && errorsOf(wrongRef).includes('resource_ref'), errorsOf(wrongRef));
544
+
545
+ const hash = `sha256:${'a'.repeat(64)}`;
546
+ const pointer = await write('assets/training-video.md', doc([...assetHead, 'encoding: none',
547
+ 'storage_uri: s3://seller-media/training/v.mp4', 'resource_ref: resources/media-bucket.md',
548
+ `hash: "${hash}"`, 'size_bytes: 734003200', 'x_portability: resource_bound']));
549
+ check('pointer asset with storage_uri, resource_ref, hash and size_bytes commits',
550
+ pointer.success === true, errorsOf(pointer));
551
+
552
+ const sale = exportBundle(vault, { registryDir: REGISTRY_DIR, profile: 'sale' });
553
+ const exported = sale.files.find((f) => f.path === 'assets/training-video.md');
554
+ const exportedData = exported ? parseDocument(exported.content).data : {};
555
+ check('sale export replaces storage_uri with REQUIREMENT and keeps the content pin',
556
+ exportedData.storage_uri === 'REQUIREMENT' && exportedData.hash === hash &&
557
+ exportedData.size_bytes === 734003200 && !exported.content.includes('seller-media/training'),
558
+ exported?.content || 'pointer asset missing from sale bundle');
559
+ check('sale bundle with a pointer asset validates',
560
+ validateBundle(sale, { registryDir: REGISTRY_DIR }).valid === true,
561
+ validateBundle(sale, { registryDir: REGISTRY_DIR }).errors.join('; '));
562
+ } finally {
563
+ fs.rmSync(vault, { recursive: true, force: true });
564
+ }
565
+
566
+ let pass = 0;
567
+ for (const c of checks) {
568
+ if (c.cond) { pass++; console.log(` ✅ ${c.name}`); }
569
+ else console.log(` ❌ ${c.name}${c.detail ? ` — ${c.detail}` : ''}`);
570
+ }
571
+ console.log(`\n ${pass}/${checks.length} asset pointer checks passed (§5.4 asset, §5.5)`);
572
+ return pass === checks.length;
573
+ }
574
+
483
575
  async function runSemanticLocalizationConformance() {
484
576
  const checks = [];
485
577
  const check = (name, cond, detail = '') => { checks.push({ name, cond, detail }); };
@@ -996,6 +1088,8 @@ async function main() {
996
1088
  const runtimeOk = await runRuntimeConformance();
997
1089
  console.log('\nRunning operation regression conformance (src/engine.mjs, §6/§7) ...');
998
1090
  const operationRegressionOk = await runOperationContractRegressionConformance();
1091
+ console.log('\nRunning asset pointer conformance (registry/core.json asset, §5.4/§5.5) ...');
1092
+ const assetPointerOk = await runAssetPointerConformance();
999
1093
  console.log('\nRunning extension-registry conformance (src/registry.mjs) ...');
1000
1094
  const extensionRegistryOk = runRegistryExtensionConformance();
1001
1095
  console.log('\nRunning semantic/multilingual rendering conformance (src/semantic.mjs, §11.9) ...');
@@ -1007,7 +1101,7 @@ async function main() {
1007
1101
  console.log('\nRunning CLI smoke conformance (scripts/ssss.mjs) ...');
1008
1102
  const cliSmokeOk = await runCliSmokeConformance();
1009
1103
  process.exit(
1010
- engineOk && runtimeOk && operationRegressionOk && extensionRegistryOk &&
1104
+ engineOk && runtimeOk && operationRegressionOk && assetPointerOk && extensionRegistryOk &&
1011
1105
  semanticOk && kernel09Ok && bundleOk && cliSmokeOk ? 0 : 1
1012
1106
  );
1013
1107
  } else {
package/src/registry.mjs CHANGED
@@ -355,8 +355,9 @@ export function documentHash(content) {
355
355
  }
356
356
 
357
357
  function isEmpty(value) {
358
- return value === undefined || value === null || value === '' ||
359
- (Array.isArray(value) && value.length === 0);
358
+ // "Required" means present: an empty array (e.g. `deferred_steps: []`) is a
359
+ // valid value for a required list field, so it is not treated as missing.
360
+ return value === undefined || value === null || value === '';
360
361
  }
361
362
 
362
363
  /**