@ggui-ai/protocol-conformance 0.14.0 → 0.16.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/README.md +33 -16
  2. package/dist/cli.d.ts +27 -1
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +269 -23
  5. package/dist/domain-error-conformance/cases/amend-unknown-session.json +9 -0
  6. package/dist/domain-error-conformance/cases/consume-unknown-session.json +9 -0
  7. package/dist/domain-error-conformance/cases/emit-unknown-session.json +9 -0
  8. package/dist/domain-error-conformance/cases/get-session-unknown-session.json +6 -0
  9. package/dist/domain-error-conformance/cases/render-unknown-handshake.json +6 -0
  10. package/dist/domain-error-conformance/cases/update-unknown-session.json +9 -0
  11. package/dist/domain-error-conformance/index.d.ts +97 -0
  12. package/dist/domain-error-conformance/index.d.ts.map +1 -0
  13. package/dist/domain-error-conformance/index.js +97 -0
  14. package/dist/fixtures/index.d.ts +8 -6
  15. package/dist/fixtures/index.d.ts.map +1 -1
  16. package/dist/fixtures/index.js +9 -7
  17. package/dist/fixtures/{subscribe-tenancy → subscribe-app-scope}/absent-appid-defaults.json +1 -1
  18. package/dist/fixtures/{subscribe-tenancy → subscribe-app-scope}/app-mismatch.json +1 -1
  19. package/dist/fixtures/subscribe-app-scope/index.d.ts +6 -0
  20. package/dist/fixtures/subscribe-app-scope/index.d.ts.map +1 -0
  21. package/dist/fixtures/{subscribe-tenancy → subscribe-app-scope}/index.js +5 -5
  22. package/dist/index.d.ts +7 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +11 -1
  25. package/dist/json-equal.d.ts +6 -0
  26. package/dist/json-equal.d.ts.map +1 -0
  27. package/dist/json-equal.js +23 -0
  28. package/dist/refusal-envelope-conformance/cases/refuse-after-fix-caller.json +3 -3
  29. package/dist/refusal-envelope-conformance/cases/refuse-non-render-surface.json +4 -4
  30. package/dist/refusal-envelope-conformance/index.d.ts.map +1 -1
  31. package/dist/refusal-envelope-conformance/index.js +1 -20
  32. package/dist/run-conformance.d.ts +22 -3
  33. package/dist/run-conformance.d.ts.map +1 -1
  34. package/dist/run-conformance.js +86 -9
  35. package/dist/theme-binding-conformance/cases/app-theme-v1-base.json +18 -0
  36. package/dist/theme-binding-conformance/cases/app-theme-v1-one-palette.json +11 -0
  37. package/dist/theme-binding-conformance/cases/app-theme-v2-accepted.json +18 -0
  38. package/dist/theme-binding-conformance/cases/app-theme-v2-keyframes-accepted.json +16 -0
  39. package/dist/theme-binding-conformance/cases/app-theme-v2-keyframes-not-keyframes.json +15 -0
  40. package/dist/theme-binding-conformance/cases/app-theme-v2-label-too-long.json +17 -0
  41. package/dist/theme-binding-conformance/cases/app-theme-v2-missing-dark.json +13 -0
  42. package/dist/theme-binding-conformance/cases/app-theme-v2-no-hash.json +15 -0
  43. package/dist/theme-binding-conformance/cases/app-theme-v2-platform-pin.json +17 -0
  44. package/dist/theme-binding-conformance/cases/overlay-hash-canonical.json +17 -0
  45. package/dist/theme-binding-conformance/cases/theme-mode-absence.json +10 -0
  46. package/dist/theme-binding-conformance/cases/theme-mode-default-when-silent-host.json +10 -0
  47. package/dist/theme-binding-conformance/cases/theme-mode-order.json +10 -0
  48. package/dist/theme-binding-conformance/index.d.ts +59 -0
  49. package/dist/theme-binding-conformance/index.d.ts.map +1 -0
  50. package/dist/theme-binding-conformance/index.js +110 -0
  51. package/dist/transport-refusal-conformance/cases/refuse-deprovisioned-endpoint.json +27 -0
  52. package/dist/transport-refusal-conformance/cases/refuse-render-only-code.json +12 -0
  53. package/dist/transport-refusal-conformance/index.d.ts +56 -0
  54. package/dist/transport-refusal-conformance/index.d.ts.map +1 -0
  55. package/dist/transport-refusal-conformance/index.js +52 -0
  56. package/dist/types.d.ts +1 -1
  57. package/package.json +18 -3
  58. package/dist/fixtures/subscribe-tenancy/index.d.ts +0 -6
  59. package/dist/fixtures/subscribe-tenancy/index.d.ts.map +0 -1
@@ -47,7 +47,8 @@
47
47
  * loop is empty today.)
48
48
  * l. Record the outcome via reporter + accumulate in result.
49
49
  * 3. Fold in the PURE-FUNCTION catalogs (ggui#786) —
50
- * `refusal-envelope` and `registry-completeness`. They grade a
50
+ * `refusal-envelope`, `registry-completeness`, `transport-refusal`,
51
+ * `domain-error` and `theme-binding` (ggui#987). They grade a
51
52
  * caller-supplied projector / registry rather than the wire, so
52
53
  * they need no transport; when the caller supplies neither, their
53
54
  * rows are reported SKIPPED with that reason so an ungraded
@@ -65,15 +66,23 @@ import { behaviorIs, deepEqual, matchBehavior } from './match-behavior.js';
65
66
  import { openWsTransport } from './ws-transport.js';
66
67
  import { refusalEnvelopeCases, runRefusalEnvelopeConformance, } from './refusal-envelope-conformance/index.js';
67
68
  import { registryCompletenessPins, runRegistryCompletenessConformance, } from './registry-completeness/index.js';
69
+ import { runTransportRefusalConformance, transportRefusalCases, } from './transport-refusal-conformance/index.js';
70
+ import { runThemeBindingConformance } from './theme-binding-conformance/index.js';
71
+ import { domainErrorCases, runDomainErrorConformance, } from './domain-error-conformance/index.js';
68
72
  /**
69
- * Row-name prefixes for the PURE-FUNCTION catalogs the runner folds
70
- * into a run. Prefixed so a catalog row can never collide with a
73
+ * Row-name prefixes for the catalogs the runner folds into a run beside
74
+ * the WebSocket fixtures: three grade an in-process function or a data
75
+ * table; `domain-error` (ggui#880) drives live `tools/call` scenarios
76
+ * through an adopter-supplied driver. Prefixed so a catalog row can never collide with a
71
77
  * WebSocket fixture name, and so the reporter can group them without a
72
78
  * second registry of names.
73
79
  */
74
80
  export const PURE_FUNCTION_CATALOG_SLUGS = [
75
81
  'refusal-envelope',
76
82
  'registry-completeness',
83
+ 'transport-refusal',
84
+ 'domain-error',
85
+ 'theme-binding',
77
86
  ];
78
87
  // =============================================================================
79
88
  // Runner
@@ -111,7 +120,7 @@ export async function runConformance(config) {
111
120
  break;
112
121
  }
113
122
  }
114
- runPureFunctionCatalogs(config, reporter, { passed, failed, skipped });
123
+ await runPureFunctionCatalogs(config, reporter, { passed, failed, skipped });
115
124
  const result = {
116
125
  passed,
117
126
  failed,
@@ -136,7 +145,7 @@ export async function runConformance(config) {
136
145
  * absent input produces a SKIP row naming what to supply rather than
137
146
  * nothing at all.
138
147
  */
139
- function runPureFunctionCatalogs(config, reporter, buckets) {
148
+ async function runPureFunctionCatalogs(config, reporter, buckets) {
140
149
  const only = config.only;
141
150
  const wanted = (name) => only === undefined || only.length === 0 || only.includes(name);
142
151
  const pass = (name) => {
@@ -161,7 +170,7 @@ function runPureFunctionCatalogs(config, reporter, buckets) {
161
170
  const projector = config.refusalProjector;
162
171
  if (projector === undefined) {
163
172
  for (const testCase of refusalEnvelopeCases) {
164
- skip(`refusal-envelope/${testCase.name}`, 'no `refusalProjector` supplied — pass one to runConformance() to grade SPEC §7.1\u2019s refused arm');
173
+ skip(`refusal-envelope/${testCase.name}`, 'no `refusalProjector` supplied — pass one to runConformance(), or `--projector <module>` on the CLI, to grade SPEC §7.1\u2019s refused arm');
165
174
  }
166
175
  }
167
176
  else {
@@ -178,11 +187,59 @@ function runPureFunctionCatalogs(config, reporter, buckets) {
178
187
  });
179
188
  }
180
189
  }
190
+ // ── transport-refusal (ggui#825) ──
191
+ const endpointProjector = config.transportRefusalProjector;
192
+ if (endpointProjector === undefined) {
193
+ for (const testCase of transportRefusalCases) {
194
+ skip(`transport-refusal/${testCase.name}`, 'no `transportRefusalProjector` supplied — pass one to runConformance(), or `--transport-projector <module>` on the CLI, to grade the endpoint-level refusal (SPEC §7.1, mcp-endpoint surface)');
195
+ }
196
+ }
197
+ else {
198
+ const endpoint = runTransportRefusalConformance(endpointProjector);
199
+ for (const name of endpoint.passed)
200
+ pass(`transport-refusal/${name}`);
201
+ for (const mismatch of endpoint.failed) {
202
+ fail({
203
+ name: `transport-refusal/${mismatch.name}`,
204
+ criterion: 'endpoint-level refusal envelope (SPEC §7.1, mcp-endpoint surface)',
205
+ expected: mismatch.expected,
206
+ received: mismatch.actual,
207
+ message: 'the projected endpoint refusal does not match the catalog case',
208
+ });
209
+ }
210
+ }
211
+ // ── domain-error (ggui#880) ──
212
+ const toolCallDriver = config.toolCallDriver;
213
+ if (toolCallDriver === undefined) {
214
+ for (const testCase of domainErrorCases) {
215
+ skip(`domain-error/${testCase.name}`, 'no `toolCallDriver` supplied — pass one to runConformance(), or `--tool-call-driver <module>` on the CLI, to grade SPEC §7.9\u2019s Plane-2 wire text');
216
+ }
217
+ }
218
+ else {
219
+ // A live driver is real calls: drive only the rows `only` selects.
220
+ const selected = domainErrorCases.filter((testCase) => wanted(`domain-error/${testCase.name}`));
221
+ const graded = selected.length === 0
222
+ ? { passed: [], failed: [], skipped: [] }
223
+ : await runDomainErrorConformance(toolCallDriver, { cases: selected });
224
+ for (const name of graded.passed)
225
+ pass(`domain-error/${name}`);
226
+ for (const skipped of graded.skipped)
227
+ skip(`domain-error/${skipped.name}`, skipped.reason);
228
+ for (const mismatch of graded.failed) {
229
+ fail({
230
+ name: `domain-error/${mismatch.name}`,
231
+ criterion: `Plane-2 wire text (SPEC §7.9): ${mismatch.criterion}`,
232
+ expected: mismatch.expected,
233
+ received: mismatch.actual,
234
+ message: 'the raw tools/call result does not carry the registered slug as the leading token of its text',
235
+ });
236
+ }
237
+ }
181
238
  // ── registry-completeness ──
182
239
  const registry = config.refusalRegistry;
183
240
  if (registry === undefined) {
184
241
  for (const pin of registryCompletenessPins) {
185
- skip(`registry-completeness/${pin.name}`, 'no `refusalRegistry` supplied — pass the deployment\u2019s refusal-code registry to runConformance() to grade it');
242
+ skip(`registry-completeness/${pin.name}`, 'no `refusalRegistry` supplied — pass the deployment\u2019s refusal-code registry to runConformance(), or `--registry <file.json>` on the CLI, to grade it');
186
243
  }
187
244
  }
188
245
  else {
@@ -199,6 +256,26 @@ function runPureFunctionCatalogs(config, reporter, buckets) {
199
256
  });
200
257
  }
201
258
  }
259
+ // ── theme-binding (ggui#987) ──
260
+ // The theming wave's arbiter: graded against the protocol's own functions
261
+ // (`appThemeSchema`, `effectiveThemeMode`, `canonicalOverlayHash`), so
262
+ // there is no adopter input to supply and no reason to skip — every run
263
+ // reports it. `only` still selects rows.
264
+ for (const graded of await runThemeBindingConformance()) {
265
+ const name = `theme-binding/${graded.name}`;
266
+ if (graded.pass) {
267
+ pass(name);
268
+ }
269
+ else {
270
+ fail({
271
+ name,
272
+ criterion: 'theme binding (SPEC "Theme binding — the normative total order", ggui#987)',
273
+ expected: 'the case\u2019s expectation',
274
+ received: graded.detail,
275
+ message: graded.detail,
276
+ });
277
+ }
278
+ }
202
279
  }
203
280
  async function runOneFixture(fixture, config, _reporter) {
204
281
  const fixtureStarted = Date.now();
@@ -661,8 +738,8 @@ function slugToCriterion(slug) {
661
738
  return 'SPEC §4.4 reserved-channel authority';
662
739
  case 'schema-version-handshake':
663
740
  return 'Protocol #3 version negotiation';
664
- case 'subscribe-tenancy':
665
- return 'SPEC §12.2 subscribe tenancy — appId MUST match the bound app (§12.2.3 APP_MISMATCH)';
741
+ case 'subscribe-app-scope':
742
+ return 'SPEC §12.2 subscribe app scope — appId MUST match the bound app (§12.2.3 APP_MISMATCH)';
666
743
  default:
667
744
  return slug;
668
745
  }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "app-theme-v1-base",
3
+ "description": "A v1 overlay carrying a registered `base` ladder is REFUSED since ggui#987 (D2 = B): the registration tier and `base` are gone. Accepted before #987 (promoted, then failed \u2014 the \u00a71.1 receipt).",
4
+ "theme": {
5
+ "mode": "dark",
6
+ "cssVariables": {},
7
+ "base": {
8
+ "documentHash": "0000000000000000000000000000000000000000000000000000000000000000",
9
+ "light": {
10
+ "--ggui-color-primary-500": "#3355ff"
11
+ },
12
+ "dark": {
13
+ "--ggui-color-primary-500": "#99aaff"
14
+ }
15
+ }
16
+ },
17
+ "expect": "refused"
18
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "app-theme-v1-one-palette",
3
+ "description": "The v1 overlay wire \u2014 a pinned mode and ONE cssVariables palette \u2014 is REFUSED since ggui#987: an overlay that could not follow the host's mode is not accepted; both projections are required. Accepted before #987 (promoted, then failed \u2014 the \u00a71.1 receipt).",
4
+ "theme": {
5
+ "mode": "light",
6
+ "cssVariables": {
7
+ "--ggui-color-primary-500": "#3355ff"
8
+ }
9
+ },
10
+ "expect": "refused"
11
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "app-theme-v2-accepted",
3
+ "description": "The v2 overlay: both projections, the attestation, an optional default mode and a label. Accepted.",
4
+ "theme": {
5
+ "overlayHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
6
+ "overlays": {
7
+ "light": {
8
+ "--ggui-color-primary-500": "#3355ff"
9
+ },
10
+ "dark": {
11
+ "--ggui-color-primary-500": "#99aaff"
12
+ }
13
+ },
14
+ "mode": "light",
15
+ "name": "ocean"
16
+ },
17
+ "expect": "accepted"
18
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "app-theme-v2-keyframes-accepted",
3
+ "description": "Per-mode keyframes text that is exactly `@keyframes <ident> { … }` blocks (two blocks, a comment, frame selectors of every kind) is ACCEPTED by appThemeSchema — the slot carries what the card injects verbatim after the variables (ggui#987 §3.3, follower D).",
4
+ "theme": {
5
+ "overlays": {
6
+ "light": { "--ggui-color-primary-500": "#3355ff" },
7
+ "dark": { "--ggui-color-primary-500": "#99aaff" }
8
+ },
9
+ "overlayHash": "0000000000000000000000000000000000000000000000000000000000000000",
10
+ "keyframes": {
11
+ "light": "/* entrance */ @keyframes entrance { 0% { opacity: 0; transform: translateY(4px) } 100% { opacity: 1; transform: none } }\n@keyframes accent-pulse{0%,100%{opacity:1}50%{opacity:.7}}",
12
+ "dark": "@keyframes spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }"
13
+ }
14
+ },
15
+ "expect": "accepted"
16
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "app-theme-v2-keyframes-not-keyframes",
3
+ "description": "Keyframes text that carries anything other than `@keyframes` blocks — here a bare `body { … }` rule after a valid block — is REFUSED by appThemeSchema: the slot is bounded to what it is for, so the theme principal cannot ship arbitrary same-document CSS through it (ggui#987 §3.4, follower D).",
4
+ "theme": {
5
+ "overlays": {
6
+ "light": { "--ggui-color-primary-500": "#3355ff" },
7
+ "dark": { "--ggui-color-primary-500": "#99aaff" }
8
+ },
9
+ "overlayHash": "0000000000000000000000000000000000000000000000000000000000000000",
10
+ "keyframes": {
11
+ "light": "@keyframes spin { to { transform: rotate(360deg) } } body { display: none }"
12
+ }
13
+ },
14
+ "expect": "refused"
15
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "app-theme-v2-label-too-long",
3
+ "description": "A stamped label carrying a FULL sha256 after the prefix is 77 chars and exceeds appThemeSchema's 64-char `name` cap \u2014 REFUSED at the door. The seam ggui#987 flagged for the platform's pin shape.",
4
+ "theme": {
5
+ "overlays": {
6
+ "light": {
7
+ "--ggui-color-primary-500": "#3355ff"
8
+ },
9
+ "dark": {
10
+ "--ggui-color-primary-500": "#99aaff"
11
+ }
12
+ },
13
+ "overlayHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
14
+ "name": "guuey-app-v2.dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
15
+ },
16
+ "expect": "refused"
17
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "app-theme-v2-missing-dark",
3
+ "description": "An overlay with only one projection cannot follow the host's mode \u2014 REFUSED.",
4
+ "theme": {
5
+ "overlayHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
6
+ "overlays": {
7
+ "light": {
8
+ "--ggui-color-primary-500": "#3355ff"
9
+ }
10
+ }
11
+ },
12
+ "expect": "refused"
13
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "app-theme-v2-no-hash",
3
+ "description": "The attestation is REQUIRED: an overlay without `overlayHash` is refused at the door.",
4
+ "theme": {
5
+ "overlays": {
6
+ "light": {
7
+ "--ggui-color-primary-500": "#3355ff"
8
+ },
9
+ "dark": {
10
+ "--ggui-color-primary-500": "#99aaff"
11
+ }
12
+ }
13
+ },
14
+ "expect": "refused"
15
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "app-theme-v2-platform-pin",
3
+ "description": "The exact overlay shape a platform's -v2 composer sends (ggui#987 \u00a79; guuey#1128): both projections, the attestation, and a label of the form `<prefix>.<hash-prefix>`. `name` is capped at 64 chars by appThemeSchema, so a stamped label must keep its hash segment to 51 chars after a 13-char prefix. Accepted.",
4
+ "theme": {
5
+ "overlays": {
6
+ "light": {
7
+ "--ggui-color-primary-500": "#3355ff"
8
+ },
9
+ "dark": {
10
+ "--ggui-color-primary-500": "#99aaff"
11
+ }
12
+ },
13
+ "overlayHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
14
+ "name": "guuey-app-v2.ccccccccccccccccccccccccccccccccccccccccccccccccccc"
15
+ },
16
+ "expect": "accepted"
17
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "overlay-hash-canonical",
3
+ "description": "canonicalOverlayHash is ONE function on every minter and door: keys sorted recursively, no whitespace, absent fields omitted, sha256 lowercase hex. The expected value was computed independently with node:crypto over the canonical JSON; the input's keys are deliberately unsorted.",
4
+ "input": {
5
+ "overlays": {
6
+ "light": {
7
+ "--ggui-color-primary-500": "#3355ff",
8
+ "--ggui-color-ground": "#ffffff"
9
+ },
10
+ "dark": {
11
+ "--ggui-color-primary-500": "#99aaff",
12
+ "--ggui-color-ground": "#0e1014"
13
+ }
14
+ }
15
+ },
16
+ "expect": "7cc8c947beeae43de91a8e3eedc182a739ae134e3a3957958ebee4ec6cf25e96"
17
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "theme-mode-absence",
3
+ "description": "The absence law (ggui#551): when no layer resolves, the projection is undefined \u2014 never a light default.",
4
+ "sources": {
5
+ "stamped": null,
6
+ "sessionSidecar": null,
7
+ "hostAnnounced": null
8
+ },
9
+ "expect": null
10
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "theme-mode-default-when-silent-host",
3
+ "description": "A host that announces nothing leaves the stamp (then the sidecar) to fill the mode \u2014 the defaults still apply.",
4
+ "sources": {
5
+ "stamped": "light",
6
+ "sessionSidecar": "dark",
7
+ "hostAnnounced": null
8
+ },
9
+ "expect": "light"
10
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "theme-mode-order",
3
+ "description": "The client projection of the themeMode total order since ggui#987 (D4 = A, 'follow the widget'): with a stamped mode and a host announce present, the HOST wins \u2014 the embedding host owns runtime mode; the stamp and the sidecar are defaults. Before #987 this case expected 'light' (the stamp); it was promoted into the kit at that version and FAILED when the order flipped \u2014 the VERSION-POLICY \u00a71.1 receipt that names the flip breaking.",
4
+ "sources": {
5
+ "stamped": "light",
6
+ "sessionSidecar": null,
7
+ "hostAnnounced": "dark"
8
+ },
9
+ "expect": "dark"
10
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Theme-binding conformance — a pure-function catalog (ggui#987).
3
+ *
4
+ * Three contracts the kit grades directly against `@ggui-ai/protocol`, so
5
+ * their breaking-ness is decided by a fixture and not by prose
6
+ * (VERSION-POLICY §2 — the kit is the arbiter):
7
+ *
8
+ * - `theme-mode-*` — the CLIENT projection of the `themeMode` total
9
+ * order, `effectiveThemeMode` (`integrations/theme-binding.ts`): the
10
+ * embedding host owns runtime mode (D4).
11
+ * - `app-theme-*` — which overlay shapes `appThemeSchema` accepts: both
12
+ * projections and the attestation required; the v1 one-palette wire and
13
+ * `base` refused.
14
+ * - `overlay-hash-*` — `canonicalOverlayHash` against an independently
15
+ * computed vector.
16
+ *
17
+ * History (the §1.1 receipt): `theme-mode-order`, `app-theme-v1-one-palette`
18
+ * and `app-theme-v1-base` were promoted from the unit pins at the version
19
+ * BEFORE the revision, passed there, and failed when it landed — that is
20
+ * what named the revision breaking. Like `../refusal-envelope-conformance`,
21
+ * the cases are raw JSON under `./cases/` and are NOT registered in
22
+ * `fixturesByContract`; the entry point is the runner below and its test.
23
+ */
24
+ import { canonicalOverlayHash } from '@ggui-ai/protocol';
25
+ type Opinion = 'light' | 'dark';
26
+ export interface ThemeModeCase {
27
+ readonly name: string;
28
+ readonly description: string;
29
+ readonly sources: {
30
+ readonly stamped: Opinion | null;
31
+ readonly sessionSidecar: Opinion | null;
32
+ readonly hostAnnounced: Opinion | null;
33
+ };
34
+ readonly expect: Opinion | null;
35
+ }
36
+ export interface AppThemeCase {
37
+ readonly name: string;
38
+ readonly description: string;
39
+ readonly theme: unknown;
40
+ readonly expect: 'accepted' | 'refused';
41
+ }
42
+ export interface OverlayHashCase {
43
+ readonly name: string;
44
+ readonly description: string;
45
+ readonly input: Parameters<typeof canonicalOverlayHash>[0];
46
+ readonly expect: string;
47
+ }
48
+ export declare const THEME_MODE_CASES: readonly ThemeModeCase[];
49
+ export declare const APP_THEME_CASES: readonly AppThemeCase[];
50
+ export declare const OVERLAY_HASH_CASES: readonly OverlayHashCase[];
51
+ export interface ThemeBindingResult {
52
+ readonly name: string;
53
+ readonly pass: boolean;
54
+ readonly detail: string;
55
+ }
56
+ /** Grade every theme-binding case against the protocol's own functions. */
57
+ export declare function runThemeBindingConformance(): Promise<readonly ThemeBindingResult[]>;
58
+ export {};
59
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/theme-binding-conformance/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,OAAO,EAAkB,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAiBzE,KAAK,OAAO,GAAG,OAAO,GAAG,MAAM,CAAC;AAEhC,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,OAAO,EAAE;QAChB,QAAQ,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;QACjC,QAAQ,CAAC,cAAc,EAAE,OAAO,GAAG,IAAI,CAAC;QACxC,QAAQ,CAAC,aAAa,EAAE,OAAO,GAAG,IAAI,CAAC;KACxC,CAAC;IACF,QAAQ,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAAC;CACjC;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,UAAU,GAAG,SAAS,CAAC;CACzC;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC,OAAO,oBAAoB,CAAC,CAAC,CAAC,CAAC,CAAC;IAC3D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAkDD,eAAO,MAAM,gBAAgB,EAAE,SAAS,aAAa,EAA0F,CAAC;AAChJ,eAAO,MAAM,eAAe,EAAE,SAAS,YAAY,EAAuO,CAAC;AAC3R,eAAO,MAAM,kBAAkB,EAAE,SAAS,eAAe,EAAgD,CAAC;AAE1G,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAID,2EAA2E;AAC3E,wBAAsB,0BAA0B,IAAI,OAAO,CAAC,SAAS,kBAAkB,EAAE,CAAC,CAqBzF"}
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Theme-binding conformance — a pure-function catalog (ggui#987).
3
+ *
4
+ * Three contracts the kit grades directly against `@ggui-ai/protocol`, so
5
+ * their breaking-ness is decided by a fixture and not by prose
6
+ * (VERSION-POLICY §2 — the kit is the arbiter):
7
+ *
8
+ * - `theme-mode-*` — the CLIENT projection of the `themeMode` total
9
+ * order, `effectiveThemeMode` (`integrations/theme-binding.ts`): the
10
+ * embedding host owns runtime mode (D4).
11
+ * - `app-theme-*` — which overlay shapes `appThemeSchema` accepts: both
12
+ * projections and the attestation required; the v1 one-palette wire and
13
+ * `base` refused.
14
+ * - `overlay-hash-*` — `canonicalOverlayHash` against an independently
15
+ * computed vector.
16
+ *
17
+ * History (the §1.1 receipt): `theme-mode-order`, `app-theme-v1-one-palette`
18
+ * and `app-theme-v1-base` were promoted from the unit pins at the version
19
+ * BEFORE the revision, passed there, and failed when it landed — that is
20
+ * what named the revision breaking. Like `../refusal-envelope-conformance`,
21
+ * the cases are raw JSON under `./cases/` and are NOT registered in
22
+ * `fixturesByContract`; the entry point is the runner below and its test.
23
+ */
24
+ import { appThemeSchema, canonicalOverlayHash } from '@ggui-ai/protocol';
25
+ import { effectiveThemeMode } from '@ggui-ai/protocol/integrations/theme-binding';
26
+ import appThemeV1Base from './cases/app-theme-v1-base.json' with { type: 'json' };
27
+ import appThemeV1OnePalette from './cases/app-theme-v1-one-palette.json' with { type: 'json' };
28
+ import appThemeV2Accepted from './cases/app-theme-v2-accepted.json' with { type: 'json' };
29
+ import appThemeV2MissingDark from './cases/app-theme-v2-missing-dark.json' with { type: 'json' };
30
+ import appThemeV2LabelTooLong from './cases/app-theme-v2-label-too-long.json' with { type: 'json' };
31
+ import appThemeV2KeyframesAccepted from './cases/app-theme-v2-keyframes-accepted.json' with { type: 'json' };
32
+ import appThemeV2KeyframesNotKeyframes from './cases/app-theme-v2-keyframes-not-keyframes.json' with { type: 'json' };
33
+ import appThemeV2NoHash from './cases/app-theme-v2-no-hash.json' with { type: 'json' };
34
+ import appThemeV2PlatformPin from './cases/app-theme-v2-platform-pin.json' with { type: 'json' };
35
+ import overlayHashCanonical from './cases/overlay-hash-canonical.json' with { type: 'json' };
36
+ import themeModeAbsence from './cases/theme-mode-absence.json' with { type: 'json' };
37
+ import themeModeDefaultWhenSilentHost from './cases/theme-mode-default-when-silent-host.json' with { type: 'json' };
38
+ import themeModeOrder from './cases/theme-mode-order.json' with { type: 'json' };
39
+ const isOpinion = (v) => v === 'light' || v === 'dark';
40
+ const opinionOrNull = (v, where) => {
41
+ if (v === null || isOpinion(v))
42
+ return v;
43
+ throw new Error(`theme-binding case: ${where} must be 'light' | 'dark' | null, got ${JSON.stringify(v)}`);
44
+ };
45
+ const isRecord = (v) => typeof v === 'object' && v !== null;
46
+ const isVarMap = (v) => isRecord(v) && Object.values(v).every((x) => typeof x === 'string');
47
+ function themeModeCase(raw) {
48
+ if (!isRecord(raw) || typeof raw['name'] !== 'string' || typeof raw['description'] !== 'string' || !isRecord(raw['sources']))
49
+ throw new Error('theme-binding case: malformed theme-mode case');
50
+ const src = raw['sources'];
51
+ return {
52
+ name: raw['name'],
53
+ description: raw['description'],
54
+ sources: {
55
+ stamped: opinionOrNull(src['stamped'], 'sources.stamped'),
56
+ sessionSidecar: opinionOrNull(src['sessionSidecar'], 'sources.sessionSidecar'),
57
+ hostAnnounced: opinionOrNull(src['hostAnnounced'], 'sources.hostAnnounced'),
58
+ },
59
+ expect: opinionOrNull(raw['expect'], 'expect'),
60
+ };
61
+ }
62
+ function appThemeCase(raw) {
63
+ if (!isRecord(raw) || typeof raw['name'] !== 'string' || typeof raw['description'] !== 'string' || !('theme' in raw))
64
+ throw new Error('theme-binding case: malformed app-theme case');
65
+ const expect = raw['expect'];
66
+ if (expect !== 'accepted' && expect !== 'refused')
67
+ throw new Error(`theme-binding case: expect must be accepted|refused, got ${JSON.stringify(expect)}`);
68
+ return { name: raw['name'], description: raw['description'], theme: raw['theme'], expect };
69
+ }
70
+ function overlayHashCase(raw) {
71
+ if (!isRecord(raw) || typeof raw['name'] !== 'string' || typeof raw['description'] !== 'string' || typeof raw['expect'] !== 'string' || !isRecord(raw['input']))
72
+ throw new Error('theme-binding case: malformed overlay-hash case');
73
+ const input = raw['input'];
74
+ const overlays = input['overlays'];
75
+ if (!isRecord(overlays) || !isVarMap(overlays['light']) || !isVarMap(overlays['dark']))
76
+ throw new Error('theme-binding case: overlay-hash input needs overlays.light and overlays.dark');
77
+ return {
78
+ name: raw['name'],
79
+ description: raw['description'],
80
+ input: { overlays: { light: overlays['light'], dark: overlays['dark'] } },
81
+ expect: raw['expect'],
82
+ };
83
+ }
84
+ export const THEME_MODE_CASES = [themeModeOrder, themeModeAbsence, themeModeDefaultWhenSilentHost].map(themeModeCase);
85
+ export const APP_THEME_CASES = [appThemeV1OnePalette, appThemeV1Base, appThemeV2Accepted, appThemeV2MissingDark, appThemeV2NoHash, appThemeV2PlatformPin, appThemeV2LabelTooLong, appThemeV2KeyframesAccepted, appThemeV2KeyframesNotKeyframes].map(appThemeCase);
86
+ export const OVERLAY_HASH_CASES = [overlayHashCanonical].map(overlayHashCase);
87
+ const nullable = (v) => (v === null ? undefined : v);
88
+ /** Grade every theme-binding case against the protocol's own functions. */
89
+ export async function runThemeBindingConformance() {
90
+ const results = [];
91
+ for (const c of THEME_MODE_CASES) {
92
+ const got = effectiveThemeMode({
93
+ stamped: nullable(c.sources.stamped),
94
+ sessionSidecar: nullable(c.sources.sessionSidecar),
95
+ hostAnnounced: nullable(c.sources.hostAnnounced),
96
+ });
97
+ const want = nullable(c.expect);
98
+ results.push({ name: c.name, pass: got === want, detail: `effectiveThemeMode(${JSON.stringify(c.sources)}) = ${String(got)}; expected ${String(want)}` });
99
+ }
100
+ for (const c of APP_THEME_CASES) {
101
+ const parsed = appThemeSchema.safeParse(c.theme);
102
+ const got = parsed.success ? 'accepted' : 'refused';
103
+ results.push({ name: c.name, pass: got === c.expect, detail: `appThemeSchema: ${got}; expected ${c.expect}${parsed.success ? '' : ` (${parsed.error.issues.map((i) => i.message).join('; ')})`}` });
104
+ }
105
+ for (const c of OVERLAY_HASH_CASES) {
106
+ const got = await canonicalOverlayHash(c.input);
107
+ results.push({ name: c.name, pass: got === c.expect, detail: `canonicalOverlayHash = ${got}; expected ${c.expect}` });
108
+ }
109
+ return results;
110
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "refuse-deprovisioned-endpoint",
3
+ "description": "A deprovisioned app answers EVERY JSON-RPC request on its per-app MCP endpoint (initialize included) with HTTP 403 and a JSON-RPC error whose data.refusal carries the registry projection — code, message, fix, retry, appId — so the client reads app_deprovisioned instead of guessing between a deleted app and a bad credential. error.code is -32003 (APP_NOT_FOUND — the endpoint no longer serves this app) and error.message is App not found; an untyped authorization failure answers -32007 (UNAUTHORIZED) with no data, so a client can tell the two apart by code and can tell neither from a dropped socket by mistake — -32000 is the SDK client's ConnectionClosed and a first-party server never chooses it (ggui#836).",
4
+ "refusal": {
5
+ "code": "app_deprovisioned",
6
+ "message": "the app record has no owner claim any more — the app was deprovisioned",
7
+ "fix": "provision the app again; this app id never renders again",
8
+ "retry": "never",
9
+ "appId": "app_deprov_1"
10
+ },
11
+ "expect": {
12
+ "httpStatus": 403,
13
+ "error": {
14
+ "code": -32003,
15
+ "message": "App not found",
16
+ "data": {
17
+ "refusal": {
18
+ "code": "app_deprovisioned",
19
+ "message": "the app record has no owner claim any more — the app was deprovisioned",
20
+ "fix": "provision the app again; this app id never renders again",
21
+ "retry": "never",
22
+ "appId": "app_deprov_1"
23
+ }
24
+ }
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "refuse-render-only-code",
3
+ "description": "A REGISTERED code whose surfaces exclude mcp-endpoint has no transport envelope: the per-app endpoint's authorization never speaks it, and a projector that types it anyway would put a render-gate state on a surface the registry does not list. The three auth arms (no issuer, foreign issuer, denied subject) stay the bare 403 by the same rule — naming any of them would say which is true.",
4
+ "refusal": {
5
+ "code": "insufficient_credit",
6
+ "message": "the configured allowance for this app is exhausted",
7
+ "fix": "top up the allowance for this app, then retry",
8
+ "retry": "after-fix",
9
+ "appId": "app_live_1"
10
+ },
11
+ "expect": null
12
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The refusal handed to the projector — the registry projection on the
3
+ * transport surface. Authored here rather than imported from
4
+ * `@ggui-ai/protocol` for the same reason as the render catalog: the kit
5
+ * grades the WIRE, and a stringly input keeps a stale kit from typing
6
+ * its way past a registry change.
7
+ */
8
+ export interface TransportRefusalInput {
9
+ /** Registered refusal code. */
10
+ readonly code: string;
11
+ /** Precise diagnostic. */
12
+ readonly message: string;
13
+ /** The one recovery step. */
14
+ readonly fix: string;
15
+ /** Retry class (`after-fix` | `next-period` | `later` | `never`). */
16
+ readonly retry: string;
17
+ /** The app id the refused endpoint serves — equals the path's `{appId}` (ggui#870). */
18
+ readonly appId: string;
19
+ }
20
+ /** What the endpoint answers: the HTTP status and the JSON-RPC error object. */
21
+ export interface ProjectedTransportRefusal {
22
+ readonly httpStatus: number;
23
+ readonly error: {
24
+ readonly code: number;
25
+ readonly message: string;
26
+ readonly data: {
27
+ readonly refusal: TransportRefusalInput;
28
+ };
29
+ };
30
+ }
31
+ /** One transport-refusal case, authored as JSON under `./cases/`. */
32
+ export interface TransportRefusalConformanceCase {
33
+ readonly name: string;
34
+ readonly description: string;
35
+ readonly refusal: TransportRefusalInput;
36
+ /** `null` when the code has no transport envelope (surfaces exclude `mcp-endpoint`). */
37
+ readonly expect: ProjectedTransportRefusal | null;
38
+ }
39
+ export declare const transportRefusalCases: readonly TransportRefusalConformanceCase[];
40
+ export interface TransportRefusalMismatch {
41
+ readonly name: string;
42
+ readonly expected: ProjectedTransportRefusal | null;
43
+ /** What the projector produced — or the message of the error it threw. */
44
+ readonly actual: ProjectedTransportRefusal | null | string;
45
+ }
46
+ export interface TransportRefusalConformanceResult {
47
+ readonly passed: readonly string[];
48
+ readonly failed: readonly TransportRefusalMismatch[];
49
+ }
50
+ /**
51
+ * Grade a per-app endpoint refusal projector against the catalog. Pure
52
+ * and synchronous: no server, no transport — the deterministic
53
+ * projection is the whole obligation.
54
+ */
55
+ export declare function runTransportRefusalConformance(project: (refusal: TransportRefusalInput) => ProjectedTransportRefusal | null): TransportRefusalConformanceResult;
56
+ //# sourceMappingURL=index.d.ts.map