@ggui-ai/protocol 0.9.0 → 0.10.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.
Files changed (59) hide show
  1. package/dist/gadgets/stdlib-gadgets.d.ts +1 -1
  2. package/dist/gadgets/stdlib-gadgets.d.ts.map +1 -1
  3. package/dist/gadgets/stdlib-gadgets.js +1 -1
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +7 -0
  7. package/dist/integrations/mcp-apps.d.ts +50 -1
  8. package/dist/integrations/mcp-apps.d.ts.map +1 -1
  9. package/dist/integrations/mcp-apps.js +67 -1
  10. package/dist/registry/props-schema-hash.d.ts +3 -0
  11. package/dist/registry/props-schema-hash.d.ts.map +1 -0
  12. package/dist/registry/props-schema-hash.js +23 -0
  13. package/dist/schemas/blueprint.d.ts +2 -0
  14. package/dist/schemas/blueprint.d.ts.map +1 -1
  15. package/dist/schemas/blueprint.js +9 -0
  16. package/dist/schemas/data-contract.d.ts +61 -7
  17. package/dist/schemas/data-contract.d.ts.map +1 -1
  18. package/dist/schemas/data-contract.js +73 -14
  19. package/dist/schemas/handshake-suggestion.d.ts +3 -6
  20. package/dist/schemas/handshake-suggestion.d.ts.map +1 -1
  21. package/dist/schemas/handshake-suggestion.js +11 -9
  22. package/dist/schemas/mcp.d.ts +20 -0
  23. package/dist/schemas/mcp.d.ts.map +1 -1
  24. package/dist/schemas/mcp.js +15 -1
  25. package/dist/schemas/ops-blueprint.d.ts +19 -10
  26. package/dist/schemas/ops-blueprint.d.ts.map +1 -1
  27. package/dist/schemas/ops-blueprint.js +39 -10
  28. package/dist/types/blueprint.d.ts +12 -0
  29. package/dist/types/blueprint.d.ts.map +1 -1
  30. package/dist/types/contract-inference.d.ts +5 -1
  31. package/dist/types/contract-inference.d.ts.map +1 -1
  32. package/dist/types/data-contract.d.ts +12 -2
  33. package/dist/types/data-contract.d.ts.map +1 -1
  34. package/dist/types/llm-route.d.ts +1 -1
  35. package/dist/types/llm-route.d.ts.map +1 -1
  36. package/dist/types/llm-route.js +1 -0
  37. package/dist/types/llm.d.ts +1 -1
  38. package/dist/types/llm.d.ts.map +1 -1
  39. package/dist/types/llm.js +22 -5
  40. package/dist/types/mcp.d.ts +26 -3
  41. package/dist/types/mcp.d.ts.map +1 -1
  42. package/dist/types/render.d.ts +33 -0
  43. package/dist/types/render.d.ts.map +1 -1
  44. package/dist/types/render.js +26 -1
  45. package/dist/validation/ajv-runtime.d.ts +26 -6
  46. package/dist/validation/ajv-runtime.d.ts.map +1 -1
  47. package/dist/validation/ajv-runtime.js +87 -9
  48. package/dist/validation/contract-validator.d.ts +104 -33
  49. package/dist/validation/contract-validator.d.ts.map +1 -1
  50. package/dist/validation/contract-validator.js +152 -42
  51. package/dist/validation/enforced-props-schema.d.ts +56 -0
  52. package/dist/validation/enforced-props-schema.d.ts.map +1 -0
  53. package/dist/validation/enforced-props-schema.js +232 -0
  54. package/dist/validation/schema-subset.d.ts.map +1 -1
  55. package/dist/validation/schema-subset.js +27 -10
  56. package/dist/version.d.ts +169 -2
  57. package/dist/version.d.ts.map +1 -1
  58. package/dist/version.js +168 -1
  59. package/package.json +7 -1
@@ -1,5 +1,5 @@
1
1
  import { deriveContextDefault } from '../types/data-contract.js';
2
- import { compileForValidation, compileValidatorModule, mapAjvErrorsToViolations, prefixViolations, } from './ajv-runtime.js';
2
+ import { compileForValidation, compileValidatorFunctionExpr, compileValidatorModule, mapAjvErrorsToViolations, prefixViolations, } from './ajv-runtime.js';
3
3
  import { checkCrossReferences } from './cross-references.js';
4
4
  import { checkNameInvariants } from './name-invariants.js';
5
5
  import { isRecord } from './is-record.js';
@@ -32,6 +32,28 @@ export function buildPropsWrapperSchema(spec) {
32
32
  .map(([name]) => name),
33
33
  };
34
34
  }
35
+ /**
36
+ * Validate runtime props against an EXPLICIT enforced schema — the
37
+ * persisted-schema render path of the schema-precise render contract
38
+ * (docs/plans/2026-08-19-schema-precise-render.md P1). The paired
39
+ * `ggui_handshake` persists the exact `buildEnforcedPropsSchema`
40
+ * artifact it returned on the wire; `ggui_render` validates against
41
+ * that PERSISTED schema rather than recomputing from the propsSpec,
42
+ * so the returned and enforced schemas cannot diverge under
43
+ * rolling-deploy version skew (the AUTHORITY obligation is structural,
44
+ * not best-effort). Closed-shape injection at compile is idempotent —
45
+ * a pre-injected enforced schema round-trips unchanged.
46
+ */
47
+ export function validatePropsDataWithSchema(props, schema) {
48
+ const validate = compileForValidation(schema);
49
+ const ok = validate(props);
50
+ if (ok)
51
+ return { valid: true, violations: [] };
52
+ return {
53
+ valid: false,
54
+ violations: mapAjvErrorsToViolations(validate.errors, props),
55
+ };
56
+ }
35
57
  /**
36
58
  * Validate runtime props data against a PropsSpec contract.
37
59
  *
@@ -353,30 +375,80 @@ export function compileContractValidators(specs) {
353
375
  return Object.keys(out).length > 0 ? out : undefined;
354
376
  }
355
377
  /**
356
- * Wrap a {@link CompiledContractValidators} object as the source text of
357
- * an ES module whose `default` export is the same object. This is the
358
- * wire format served from the content-addressable contract route
359
- * (`GET /contract/<hash>.js`) in #109's decomposition slice — one URL,
360
- * one fetch, one dynamic-import per unique contract.
361
- *
362
- * Why a wrapping module rather than emitting the validator-modules
363
- * raw: each inner validator-module is independently `export default ...`,
364
- * so they can't share a single file without name collisions. The
365
- * iframe-runtime's existing `loadCompiledValidators` already knows how
366
- * to take a `CompiledContractValidators` and load each inner module via
367
- * `blob:` import; this wrapper just hands it the same shape it expects,
368
- * sourced from one HTTP round-trip instead of inline.
369
- *
370
- * `JSON.stringify` is deterministic on objects with string keys in V8
371
- * + Node — the producer's iteration order is preserved, so a given
372
- * contract always serializes to identical bytes. {@link computeContractBundle}
373
- * leans on that determinism so the resulting hash is stable across
374
- * renders of the same contract.
378
+ * Compile a contract's runtime-validated sub-schemas into
379
+ * expression-form validators ({@link ContractValidatorExprs}) — the
380
+ * same four surfaces, guards, and closed-shape semantics as
381
+ * {@link compileContractValidators}, differing only in emission form.
382
+ * Returns `undefined` when the contract declares no runtime-validated
383
+ * schema at all.
384
+ */
385
+ export function compileContractValidatorExprs(specs) {
386
+ const compilePerEntry = (spec) => {
387
+ if (!spec)
388
+ return undefined;
389
+ const collected = {};
390
+ for (const [name, entry] of Object.entries(spec)) {
391
+ if (entry && entry.schema) {
392
+ collected[name] = compileValidatorFunctionExpr(entry.schema);
393
+ }
394
+ }
395
+ return Object.keys(collected).length > 0 ? collected : undefined;
396
+ };
397
+ const out = {};
398
+ if (specs.propsSpec &&
399
+ specs.propsSpec.properties &&
400
+ Object.keys(specs.propsSpec.properties).length > 0) {
401
+ out.props = compileValidatorFunctionExpr(buildPropsWrapperSchema(specs.propsSpec));
402
+ }
403
+ const actions = compilePerEntry(specs.actionSpec);
404
+ if (actions)
405
+ out.actions = actions;
406
+ const streams = compilePerEntry(specs.streamSpec);
407
+ if (streams)
408
+ out.streams = streams;
409
+ const context = compilePerEntry(specs.contextSpec);
410
+ if (context)
411
+ out.context = context;
412
+ return Object.keys(out).length > 0 ? out : undefined;
413
+ }
414
+ /**
415
+ * Wrap {@link ContractValidatorExprs} as the source text of ONE plain
416
+ * ES module whose `default` export carries the validate FUNCTIONS
417
+ * themselves — the v2 wire format of the content-addressable contract
418
+ * route (`GET /contract/<hash>.js`), ggui#522 slice 2.
419
+ *
420
+ * The v1 format exported the validator-module SOURCES as strings, so
421
+ * the iframe still needed one `blob:` dynamic import per validator —
422
+ * exactly the scheme-source grant a strict host CSP refuses, which
423
+ * made client-side validation silently fail open on such hosts. Here
424
+ * the expressions concatenate at BUILD time into ordinary code: the
425
+ * frame does one `import(validatorsUrl)` (a plain https module load,
426
+ * governed by `script-src` origins alone) and receives functions. No
427
+ * `blob:`, no `data:`, no eval anywhere.
428
+ *
429
+ * Key emission order follows the producer's iteration order, which is
430
+ * deterministic in V8/Node for string keys — {@link computeContractBundle}
431
+ * hashes the INPUT specs anyway, so byte-level determinism of this
432
+ * source is a courtesy, not a correctness requirement.
375
433
  *
376
434
  * @public
377
435
  */
378
- export function bundleCompiledValidatorsAsModule(compiled) {
379
- return `export default ${JSON.stringify(compiled)};\n`;
436
+ export function bundleValidatorExprsAsExecutableModule(exprs) {
437
+ const lines = ['"use strict";', 'const v = {};'];
438
+ if (exprs.props !== undefined) {
439
+ lines.push(`v.props = ${exprs.props};`);
440
+ }
441
+ for (const group of ['actions', 'streams', 'context']) {
442
+ const entries = exprs[group];
443
+ if (entries === undefined)
444
+ continue;
445
+ lines.push(`v.${group} = {};`);
446
+ for (const [name, expr] of Object.entries(entries)) {
447
+ lines.push(`v.${group}[${JSON.stringify(name)}] = ${expr};`);
448
+ }
449
+ }
450
+ lines.push('export default v;');
451
+ return `${lines.join('\n')}\n`;
380
452
  }
381
453
  /**
382
454
  * Recursive canonical-JSON serializer with object keys sorted
@@ -410,21 +482,35 @@ function canonicalJsonStringify(value) {
410
482
  return 'null';
411
483
  }
412
484
  /**
413
- * Convenience over {@link compileContractValidators} +
414
- * {@link bundleCompiledValidatorsAsModule} + sha256 — produces the
415
- * `{contractHash, bundleSource, validators}` triple the emitter (render.ts
416
- * / update.ts in #109 C4) writes to the content-addressable store and
417
- * emits as `_meta["ai.ggui/contract"] = {contractHash, validatorsUrl}`.
418
- *
419
- * `contractHash` is `sha256(canonicalJsonStringify(specs))` (hex). Hashing
420
- * the INPUT specs — not the compiled output — guarantees a stable hash
421
- * across server processes and Ajv version bumps: the same contract
422
- * definition always lands at the same URL. Compiled output bytes may
423
- * differ across calls (Ajv's standalone emitter uses incrementing
424
- * counter names like `validate10`/`validate11`), but the CodeStore is
425
- * idempotent (first write wins) and the URL response carries
426
- * `Cache-Control: immutable`, so browsers + CDNs lock in the
427
- * first-served bytes and never observe a counter-name reshuffle.
485
+ * Version salt for {@link computeContractBundle}'s hash. The bundle's
486
+ * URL + store key are `Cache-Control: immutable` and the CodeStore is
487
+ * first-write-wins, so a FORMAT change under an unchanged key would be
488
+ * invisible to every cache in the chain — the salt moves the whole
489
+ * family to fresh keys instead. `v2` = the executable-module format
490
+ * ({@link bundleValidatorExprsAsExecutableModule}); v1 (unsalted) was
491
+ * the string-carrying format whose per-validator `blob:` loads a
492
+ * strict CSP refuses.
493
+ */
494
+ const CONTRACT_BUNDLE_HASH_SALT = 'ggui-validators-v2\n';
495
+ /**
496
+ * Convenience over {@link compileContractValidatorExprs} +
497
+ * {@link bundleValidatorExprsAsExecutableModule} + sha256 — produces
498
+ * the `{contractHash, bundleSource, validators}` triple the emitter
499
+ * (render.ts / the resource read / the /state route) writes to the
500
+ * content-addressable store and emits as
501
+ * `{contractHash, validatorsUrl}` on the render slice.
502
+ *
503
+ * `contractHash` is `sha256(salt + canonicalJsonStringify(specs))`
504
+ * (hex). Hashing the INPUT specs — not the compiled output —
505
+ * guarantees a stable hash across server processes and Ajv version
506
+ * bumps: the same contract definition always lands at the same URL.
507
+ * Compiled output bytes may differ across calls (Ajv's standalone
508
+ * emitter uses incrementing counter names like `validate10`/
509
+ * `validate11`), but the CodeStore is idempotent (first write wins)
510
+ * and the URL response carries `Cache-Control: immutable`, so browsers
511
+ * + CDNs lock in the first-served bytes and never observe a
512
+ * counter-name reshuffle. The salt exists precisely because of that
513
+ * immutability — see {@link CONTRACT_BUNDLE_HASH_SALT}.
428
514
  *
429
515
  * Returns `undefined` when the contract declares no runtime-validated
430
516
  * schema at all (matches {@link compileContractValidators}'s posture).
@@ -432,16 +518,16 @@ function canonicalJsonStringify(value) {
432
518
  * @public
433
519
  */
434
520
  export async function computeContractBundle(specs) {
435
- const validators = compileContractValidators(specs);
521
+ const validators = compileContractValidatorExprs(specs);
436
522
  if (validators === undefined)
437
523
  return undefined;
438
- const bundleSource = bundleCompiledValidatorsAsModule(validators);
524
+ const bundleSource = bundleValidatorExprsAsExecutableModule(validators);
439
525
  // Web Crypto's subtle.digest is universal (Node 19+, all modern
440
526
  // browsers, Workers). The protocol package ships into iframe-runtime
441
527
  // bundles too — `node:crypto` would force esbuild to mark a Node
442
528
  // builtin unresolved at browser bundle time even though this
443
529
  // function is server-only at call time.
444
- const bytes = new TextEncoder().encode(canonicalJsonStringify(specs));
530
+ const bytes = new TextEncoder().encode(`${CONTRACT_BUNDLE_HASH_SALT}${canonicalJsonStringify(specs)}`);
445
531
  const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);
446
532
  const contractHash = Array.from(new Uint8Array(digest))
447
533
  .map((b) => b.toString(16).padStart(2, '0'))
@@ -729,7 +815,12 @@ function validateSchemaStructure(schema, path, violations) {
729
815
  });
730
816
  return;
731
817
  }
732
- if (schema.type === 'array' && !schema.items) {
818
+ // `type` may be a draft-07 type ARRAY (`['array','null']`) since
819
+ // draft-2026-08-19 — read it as a SET so the structural rules below
820
+ // apply to nullable array/object schemas too (a single-string
821
+ // comparison silently skipped them).
822
+ const typeSet = new Set(Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : []);
823
+ if (typeSet.has('array') && !schema.items) {
733
824
  violations.push({
734
825
  field: path,
735
826
  message: `Array schema at '${path}' has no items — cannot validate array elements`,
@@ -737,7 +828,7 @@ function validateSchemaStructure(schema, path, violations) {
737
828
  received: 'undefined',
738
829
  });
739
830
  }
740
- if (schema.type === 'object' && schema.required?.length) {
831
+ if (typeSet.has('object') && schema.required?.length) {
741
832
  const definedProps = Object.keys(schema.properties ?? {});
742
833
  for (const req of schema.required) {
743
834
  if (!definedProps.includes(req)) {
@@ -789,6 +880,19 @@ export class ContractViolationError extends Error {
789
880
  violations;
790
881
  tool;
791
882
  hint;
883
+ /**
884
+ * sha256 (lowercase hex) of the RFC 8785 canonical bytes of the
885
+ * ENFORCED props schema this violation was validated against —
886
+ * present when the enforcing site holds one (the handshake-persisted
887
+ * schema on the render path). The breach classifier of the
888
+ * schema-precise render contract: a caller holding the handshake's
889
+ * `propsSchemaHash` compares it to this value — mismatch means the
890
+ * server enforced a different schema than it returned (server
891
+ * breach); match means the props themselves were at fault. Absent on
892
+ * violations produced without an enforced-schema identity (synthetic
893
+ * violations, stream/context/action validation).
894
+ */
895
+ propsSchemaHash;
792
896
  constructor(opts) {
793
897
  const formattedViolations = formatViolations(opts.violations);
794
898
  super(`Contract violation in ${opts.tool}:\n${formattedViolations}`);
@@ -796,6 +900,9 @@ export class ContractViolationError extends Error {
796
900
  this.violations = opts.violations;
797
901
  this.tool = opts.tool;
798
902
  this.hint = opts.hint ?? defaultHintFor(opts.tool);
903
+ if (opts.propsSchemaHash !== undefined) {
904
+ this.propsSchemaHash = opts.propsSchemaHash;
905
+ }
799
906
  }
800
907
  /** Structured payload for MCP error response `data` field. */
801
908
  toErrorData() {
@@ -804,6 +911,9 @@ export class ContractViolationError extends Error {
804
911
  tool: this.tool,
805
912
  violations: this.violations,
806
913
  hint: this.hint,
914
+ ...(this.propsSchemaHash !== undefined
915
+ ? { propsSchemaHash: this.propsSchemaHash }
916
+ : {}),
807
917
  };
808
918
  }
809
919
  }
@@ -0,0 +1,56 @@
1
+ import type { JsonSchema, PropsSpec } from '../types/data-contract.js';
2
+ /** Frozen wire values for the handshake's `propsSchemaProfile` field. */
3
+ export type PropsSchemaProfile = 'grammar-safe' | 'full';
4
+ /**
5
+ * The grammar-safe core — the closed keyword set a `'grammar-safe'`
6
+ * schema may use (P3 pin 4; purely syntactic). `aliases` is enumerated
7
+ * now (it is the P4 in-contract alias keyword) so arming P4 needs no
8
+ * grammar change; until P4 ships it simply never appears in emitted
9
+ * bytes.
10
+ */
11
+ export declare const GRAMMAR_SAFE_KEYWORDS: ReadonlySet<string>;
12
+ /**
13
+ * The restricted `format` vocabulary admitted to the grammar-safe core
14
+ * (P3 pin 5). A consumer grammar MAY enforce any subset its engine
15
+ * supports; unenforced formats remain server-validated — AUTHORITY is
16
+ * one-directional, so an under-enforcing grammar is safe. A schema
17
+ * carrying a format outside this list classifies `'full'`.
18
+ */
19
+ export declare const GRAMMAR_SAFE_FORMATS: ReadonlySet<string>;
20
+ /**
21
+ * RFC 8785 (JCS) serialization of a props schema — the canonical
22
+ * bytes `propsSchemaHash` is computed over, and the byte form the
23
+ * emitted `propsSchema` value is constructed to match. Consumers
24
+ * verify by re-canonicalizing the received value with any JCS
25
+ * library and hashing — raw received bytes are equivalent whenever
26
+ * the transport preserves key order (JS enumeration reorders
27
+ * integer-like property names, so re-canonicalization is the
28
+ * guaranteed path).
29
+ */
30
+ export declare function canonicalPropsSchemaBytes(schema: JsonSchema): string;
31
+ /**
32
+ * Build the enforced props schema for a {@link PropsSpec} — the exact
33
+ * schema `ggui_render` compiles and validates against for the paired
34
+ * handshake, in emission form (closed shape materialized, `nullable`
35
+ * rewritten, metadata keywords stripped, canonical key order).
36
+ *
37
+ * An empty / degenerate spec yields the empty closed wrapper
38
+ * `{additionalProperties:false, properties:{}, required:[], type:'object'}`
39
+ * — never omitted for a non-declined handshake: under it, any
40
+ * non-empty props are invalid, which makes the props-without-propsSpec
41
+ * rejection schema-derivable and the accept-path drop documented
42
+ * leniency (one-directional AUTHORITY).
43
+ *
44
+ * `injectClosedShape` is idempotent, so compiling this pre-injected
45
+ * tree (`compileForValidation`) enforces byte-identical semantics to
46
+ * `validatePropsData` over the source spec — the AUTHORITY property
47
+ * the conformance kit's drift fixture pins.
48
+ */
49
+ export declare function buildEnforcedPropsSchema(spec: PropsSpec): JsonSchema;
50
+ /**
51
+ * Classify an (emission-form) props schema against the grammar-safe
52
+ * core — P3 pins 4+5. Purely syntactic; the conformance kit's profile
53
+ * fixture asserts the wire flag agrees with this reference checker.
54
+ */
55
+ export declare function classifyPropsSchemaProfile(schema: JsonSchema): PropsSchemaProfile;
56
+ //# sourceMappingURL=enforced-props-schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"enforced-props-schema.d.ts","sourceRoot":"","sources":["../../src/validation/enforced-props-schema.ts"],"names":[],"mappings":"AA4CA,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAIvE,yEAAyE;AACzE,MAAM,MAAM,kBAAkB,GAAG,cAAc,GAAG,MAAM,CAAC;AAEzD;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,EAAE,WAAW,CAAC,MAAM,CAcpD,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,EAAE,WAAW,CAAC,MAAM,CAWnD,CAAC;AAEH;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,UAAU,GAAG,MAAM,CAEpE;AAuED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,SAAS,GAAG,UAAU,CAIpE;AAED;;;;GAIG;AACH,wBAAgB,0BAA0B,CACxC,MAAM,EAAE,UAAU,GACjB,kBAAkB,CAiCpB"}
@@ -0,0 +1,232 @@
1
+ /**
2
+ * ENFORCED PROPS SCHEMA — the wire artifact of the schema-precise
3
+ * render arc (P1, docs/plans/2026-08-19-schema-precise-render.md;
4
+ * shape frozen 2026-08-19 per the P3 consumer review, guuey#271).
5
+ *
6
+ * `buildEnforcedPropsSchema(propsSpec)` produces the EXACT JSON Schema
7
+ * the paired `ggui_render` enforces for a handshake — the same
8
+ * synthesis (`buildPropsWrapperSchema`) and the same closed-shape
9
+ * injection (`injectClosedShape`) the render-time validator compiles,
10
+ * with the emission normalizations applied IN the bytes:
11
+ *
12
+ * - `additionalProperties: false` materialized at every object node
13
+ * (render injects it at Ajv compile; the wire artifact carries it
14
+ * explicitly so a consumer reading the schema sees the enforced
15
+ * closed shape, not a pre-injection approximation).
16
+ * - `nullable: true` rewritten to the canonical `type: [X, 'null']`
17
+ * union (the validating engine type-widens on `nullable`; a strict
18
+ * reader of the emitted schema must see the same acceptance).
19
+ * - Metadata keywords that are no-ops at the emission layer
20
+ * (`example`, `nullable`) stripped — they are non-standard at this
21
+ * layer and can fail a strict downstream compiler.
22
+ * - Constructed in canonical key order (sorted at every schema
23
+ * node), so the serialized value is byte-stable across authors'
24
+ * key ordering and across handshakes of the same contract —
25
+ * load-bearing for consumer compile caches keyed on the bytes.
26
+ *
27
+ * Canonical bytes are RFC 8785 (JCS) — the SAME standard the
28
+ * `contractHash` pipeline pins (`registry/canonicalize-contract.ts`),
29
+ * so an external implementation reproduces them with any JCS library.
30
+ * The hash over those bytes lives server-side at
31
+ * `@ggui-ai/protocol/props-schema-hash` (node:crypto — the
32
+ * `blueprint-key` subpath convention).
33
+ *
34
+ * `classifyPropsSchemaProfile` implements the frozen profile rule
35
+ * (pins 4+5): purely SYNTACTIC membership — a schema is
36
+ * `'grammar-safe'` iff every keyword appearing at any schema node is
37
+ * in {@link GRAMMAR_SAFE_KEYWORDS}, every `format` value is in
38
+ * {@link GRAMMAR_SAFE_FORMATS}, and every `additionalProperties` is
39
+ * the literal `false`. Anything else is `'full'` — the consumer falls
40
+ * back to schema-as-context instead of grammar compilation. Consumers
41
+ * MUST treat unknown profile values as `'full'` (new profiles are
42
+ * additive minors).
43
+ */
44
+ import canonicalize from 'canonicalize';
45
+ import { buildPropsWrapperSchema } from './contract-validator.js';
46
+ import { injectClosedShape } from './ajv-runtime.js';
47
+ /**
48
+ * The grammar-safe core — the closed keyword set a `'grammar-safe'`
49
+ * schema may use (P3 pin 4; purely syntactic). `aliases` is enumerated
50
+ * now (it is the P4 in-contract alias keyword) so arming P4 needs no
51
+ * grammar change; until P4 ships it simply never appears in emitted
52
+ * bytes.
53
+ */
54
+ export const GRAMMAR_SAFE_KEYWORDS = new Set([
55
+ 'type',
56
+ 'enum',
57
+ 'const',
58
+ 'properties',
59
+ 'required',
60
+ 'items',
61
+ 'additionalProperties',
62
+ 'oneOf',
63
+ 'anyOf',
64
+ 'format',
65
+ 'description',
66
+ 'title',
67
+ 'aliases',
68
+ ]);
69
+ /**
70
+ * The restricted `format` vocabulary admitted to the grammar-safe core
71
+ * (P3 pin 5). A consumer grammar MAY enforce any subset its engine
72
+ * supports; unenforced formats remain server-validated — AUTHORITY is
73
+ * one-directional, so an under-enforcing grammar is safe. A schema
74
+ * carrying a format outside this list classifies `'full'`.
75
+ */
76
+ export const GRAMMAR_SAFE_FORMATS = new Set([
77
+ 'date-time',
78
+ 'time',
79
+ 'date',
80
+ 'duration',
81
+ 'email',
82
+ 'hostname',
83
+ 'uri',
84
+ 'ipv4',
85
+ 'ipv6',
86
+ 'uuid',
87
+ ]);
88
+ /**
89
+ * RFC 8785 (JCS) serialization of a props schema — the canonical
90
+ * bytes `propsSchemaHash` is computed over, and the byte form the
91
+ * emitted `propsSchema` value is constructed to match. Consumers
92
+ * verify by re-canonicalizing the received value with any JCS
93
+ * library and hashing — raw received bytes are equivalent whenever
94
+ * the transport preserves key order (JS enumeration reorders
95
+ * integer-like property names, so re-canonicalization is the
96
+ * guaranteed path).
97
+ */
98
+ export function canonicalPropsSchemaBytes(schema) {
99
+ return canonicalize(schema) ?? '{}';
100
+ }
101
+ /**
102
+ * Schema-position-aware canonical rebuild of one node: strips
103
+ * emission-layer metadata keywords, rewrites `nullable`, recurses into
104
+ * the schema-valued positions (`properties` values, `items`,
105
+ * schema-valued `additionalProperties`, `oneOf`/`anyOf` branches), and
106
+ * inserts keys in sorted order. Data-valued positions (`enum` members,
107
+ * `const`, `default`) pass through verbatim — they are values, not
108
+ * schemas, and JCS canonicalizes their serialization regardless of
109
+ * construction order.
110
+ */
111
+ function canonicalizeSchemaNode(node) {
112
+ const out = {};
113
+ const nullableWidens = node.nullable === true && typeof node.type === 'string';
114
+ for (const key of Object.keys(node).sort()) {
115
+ if (key === 'example' || key === 'nullable')
116
+ continue;
117
+ const value = node[key];
118
+ if (key === 'type') {
119
+ const t = node.type;
120
+ if (nullableWidens && typeof t === 'string') {
121
+ out.type = [t, 'null'];
122
+ }
123
+ else if (node.nullable === true && Array.isArray(t)) {
124
+ out.type = t.includes('null') ? [...t] : [...t, 'null'];
125
+ }
126
+ else {
127
+ out.type = t;
128
+ }
129
+ continue;
130
+ }
131
+ if (key === 'properties' && isSchemaMap(value)) {
132
+ const props = {};
133
+ for (const name of Object.keys(value).sort()) {
134
+ props[name] = canonicalizeSchemaNode(value[name]);
135
+ }
136
+ out.properties = props;
137
+ continue;
138
+ }
139
+ if (key === 'items' && isSchemaValue(value)) {
140
+ out.items = canonicalizeSchemaNode(value);
141
+ continue;
142
+ }
143
+ if (key === 'additionalProperties' && isSchemaValue(value)) {
144
+ out.additionalProperties = canonicalizeSchemaNode(value);
145
+ continue;
146
+ }
147
+ if (key === 'oneOf' || key === 'anyOf') {
148
+ const branches = key === 'oneOf' ? node.oneOf : node.anyOf;
149
+ if (branches !== undefined) {
150
+ out[key] = branches.map(canonicalizeSchemaNode);
151
+ continue;
152
+ }
153
+ }
154
+ out[key] = value;
155
+ }
156
+ return out;
157
+ }
158
+ function isSchemaValue(value) {
159
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
160
+ }
161
+ function isSchemaMap(value) {
162
+ return (typeof value === 'object' &&
163
+ value !== null &&
164
+ !Array.isArray(value) &&
165
+ Object.values(value).every(isSchemaValue));
166
+ }
167
+ /**
168
+ * Build the enforced props schema for a {@link PropsSpec} — the exact
169
+ * schema `ggui_render` compiles and validates against for the paired
170
+ * handshake, in emission form (closed shape materialized, `nullable`
171
+ * rewritten, metadata keywords stripped, canonical key order).
172
+ *
173
+ * An empty / degenerate spec yields the empty closed wrapper
174
+ * `{additionalProperties:false, properties:{}, required:[], type:'object'}`
175
+ * — never omitted for a non-declined handshake: under it, any
176
+ * non-empty props are invalid, which makes the props-without-propsSpec
177
+ * rejection schema-derivable and the accept-path drop documented
178
+ * leniency (one-directional AUTHORITY).
179
+ *
180
+ * `injectClosedShape` is idempotent, so compiling this pre-injected
181
+ * tree (`compileForValidation`) enforces byte-identical semantics to
182
+ * `validatePropsData` over the source spec — the AUTHORITY property
183
+ * the conformance kit's drift fixture pins.
184
+ */
185
+ export function buildEnforcedPropsSchema(spec) {
186
+ return canonicalizeSchemaNode(injectClosedShape(buildPropsWrapperSchema(spec)));
187
+ }
188
+ /**
189
+ * Classify an (emission-form) props schema against the grammar-safe
190
+ * core — P3 pins 4+5. Purely syntactic; the conformance kit's profile
191
+ * fixture asserts the wire flag agrees with this reference checker.
192
+ */
193
+ export function classifyPropsSchemaProfile(schema) {
194
+ for (const key of Object.keys(schema)) {
195
+ if (!GRAMMAR_SAFE_KEYWORDS.has(key))
196
+ return 'full';
197
+ const value = schema[key];
198
+ if (key === 'format') {
199
+ if (typeof value !== 'string' || !GRAMMAR_SAFE_FORMATS.has(value)) {
200
+ return 'full';
201
+ }
202
+ continue;
203
+ }
204
+ if (key === 'additionalProperties') {
205
+ if (value !== false)
206
+ return 'full';
207
+ continue;
208
+ }
209
+ if (key === 'properties' && isSchemaMap(value)) {
210
+ for (const nested of Object.values(value)) {
211
+ if (classifyPropsSchemaProfile(nested) === 'full')
212
+ return 'full';
213
+ }
214
+ continue;
215
+ }
216
+ if (key === 'items' && isSchemaValue(value)) {
217
+ if (classifyPropsSchemaProfile(value) === 'full')
218
+ return 'full';
219
+ continue;
220
+ }
221
+ if ((key === 'oneOf' || key === 'anyOf') && Array.isArray(value)) {
222
+ for (const branch of value) {
223
+ if (!isSchemaValue(branch))
224
+ return 'full';
225
+ if (classifyPropsSchemaProfile(branch) === 'full')
226
+ return 'full';
227
+ }
228
+ continue;
229
+ }
230
+ }
231
+ return 'grammar-safe';
232
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"schema-subset.d.ts","sourceRoot":"","sources":["../../src/validation/schema-subset.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiFG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAEvE;;;;;GAKG;AACH,MAAM,MAAM,qBAAqB,GAC7B,eAAe;AACjB;sDACsD;GACpD,gBAAgB;AAClB;;;;mDAImD;GACjD,iBAAiB;AACnB;sEACsE;GACpE,kBAAkB;AACpB,mCAAmC;GACjC,gBAAgB;AAClB,sEAAsE;GACpE,8BAA8B;AAChC;;mDAEmD;GACjD,aAAa,CAAC;AAElB;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B;;;;;;;OAOG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,6BAA6B;IAC7B,QAAQ,CAAC,MAAM,EAAE,qBAAqB,CAAC;IACvC;;sDAEkD;IAClD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;4CACwC;IACxC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;gDAG4C;IAC5C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;GAKG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,UAAU,EAAE,SAAS,eAAe,EAAE,CAAC;CACjD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,UAAU,EACpB,MAAM,EAAE,UAAU,GACjB,kBAAkB,CAiBpB;AAoTD,YAAY,EAAE,SAAS,EAAE,CAAC"}
1
+ {"version":3,"file":"schema-subset.d.ts","sourceRoot":"","sources":["../../src/validation/schema-subset.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiFG;AACH,OAAO,KAAK,EACV,UAAU,EAEV,SAAS,EACV,MAAM,2BAA2B,CAAC;AAEnC;;;;;GAKG;AACH,MAAM,MAAM,qBAAqB,GAC7B,eAAe;AACjB;sDACsD;GACpD,gBAAgB;AAClB;;;;mDAImD;GACjD,iBAAiB;AACnB;sEACsE;GACpE,kBAAkB;AACpB,mCAAmC;GACjC,gBAAgB;AAClB,sEAAsE;GACpE,8BAA8B;AAChC;;mDAEmD;GACjD,aAAa,CAAC;AAElB;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B;;;;;;;OAOG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,6BAA6B;IAC7B,QAAQ,CAAC,MAAM,EAAE,qBAAqB,CAAC;IACvC;;sDAEkD;IAClD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;4CACwC;IACxC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;gDAG4C;IAC5C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;GAKG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,UAAU,EAAE,SAAS,eAAe,EAAE,CAAC;CACjD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,UAAU,EACpB,MAAM,EAAE,UAAU,GACjB,kBAAkB,CAiBpB;AA8TD,YAAY,EAAE,SAAS,EAAE,CAAC"}
@@ -88,17 +88,22 @@ function compare(superset, subset, path, out) {
88
88
  // Type match. Superset `undefined` is a wildcard (accepts any
89
89
  // type). Subset `undefined` against a specific superset type is a
90
90
  // violation — "no declared type" is wider than any specific type.
91
- const sup = normalizeType(superset);
92
- const sub = normalizeType(subset);
91
+ const sup = normalizeTypeSet(superset);
92
+ const sub = normalizeTypeSet(subset);
93
93
  if (sup !== undefined) {
94
- if (sub === undefined || sub !== sup) {
94
+ // Draft-07 type ARRAYS (e.g. `['string','null']`, the canonical
95
+ // nullability form the enforced-props-schema emission produces)
96
+ // compare as SETS: every type the subset can emit must be accepted
97
+ // by the superset.
98
+ const subsetOk = sub !== undefined && [...sub].every((t) => sup.has(t));
99
+ if (!subsetOk) {
95
100
  out.push({
96
101
  path,
97
102
  reason: 'type-mismatch',
98
- superset: sup,
99
- subset: sub ?? '(unspecified)',
103
+ superset: formatTypeSet(sup),
104
+ subset: sub === undefined ? '(unspecified)' : formatTypeSet(sub),
100
105
  message: `${pathLabel(path)}: type mismatch — superset accepts ` +
101
- `'${sup}' but subset ${sub === undefined ? 'does not declare a type' : `declares '${sub}'`}.`,
106
+ `'${formatTypeSet(sup)}' but subset ${sub === undefined ? 'does not declare a type' : `declares '${formatTypeSet(sub)}'`}.`,
102
107
  });
103
108
  // Type mismatch invalidates downstream object/array structural
104
109
  // checks — if the types don't match, deeper comparison is noise.
@@ -106,14 +111,14 @@ function compare(superset, subset, path, out) {
106
111
  }
107
112
  }
108
113
  // Object structure.
109
- if (sup === 'object' || sub === 'object') {
114
+ if (sup?.has('object') || sub?.has('object')) {
110
115
  compareObject(superset, subset, path, out);
111
116
  }
112
117
  // Array items. We descend through items only when both sides have
113
118
  // `type: 'array'` (or superset omitted type and subset declares
114
119
  // array — but that case is caught by the type block above as a
115
120
  // mismatch). Omission on either side is permissive.
116
- if (sup === 'array' || sub === 'array') {
121
+ if (sup?.has('array') || sub?.has('array')) {
117
122
  compareArray(superset, subset, path, out);
118
123
  }
119
124
  }
@@ -235,8 +240,20 @@ function compareArray(superset, subset, path, out) {
235
240
  const childPath = path === '' ? 'items' : `${path}.items`;
236
241
  compare(supItems, subItems, childPath, out);
237
242
  }
238
- function normalizeType(schema) {
239
- return schema.type;
243
+ // ── Helpers ───────────────────────────────────────────────────────
244
+ /**
245
+ * The declared type(s) as a SET — single declarations and draft-07
246
+ * type arrays normalize to the same shape so subset comparison is
247
+ * uniform. `undefined` = no type declared (permissive).
248
+ */
249
+ function normalizeTypeSet(schema) {
250
+ const t = schema.type;
251
+ if (t === undefined)
252
+ return undefined;
253
+ return new Set(Array.isArray(t) ? t : [t]);
254
+ }
255
+ function formatTypeSet(set) {
256
+ return [...set].join('|');
240
257
  }
241
258
  function resolveAdditional(value) {
242
259
  // JSON Schema draft-07: omitted ⇒ additionalProperties `true`.