Step 6.3 Complete — Generic Contract 5.3 Surface Discovery

- src/validation.js: add validateSurfaceCatalog() implementing Contract
  5.3 §§31.2-31.5 normative validation order (validate individuals ->
  discard invalids -> evaluate primary invariant against working set)
- src/host.js: emit xzbt:5.3 advisory (§6.5); store contractMinor;
  add surfaces=[] to resetView(); parse surfaces in refresh(true)
- src/ui.js: add buildSurfaces() for generic descriptor-driven surface
  display; integrate renderedSurfaces tracking into render()
- public/index.html: subtitle updated to Contract 5.2/5.3; add
  surfaces-section with surfaces div
- public/style.css: add .surface, .surface-badge, .surface-meta styles
- tests/surface-validation.test.js: 45 new tests covering all Part 8
  malformed surface cases, lifecycle, registry refresh, 5.2 regression
- docs/reference/XZBT-NGN-Step6.3-Surface-Discovery-Verification.md

Test results: 88/88 pass (43 pre-existing + 45 new)
Genericity scan: zero matches in src/, public/, server/
git diff --check: clean
This commit is contained in:
2026-09-14 14:19:57 -07:00
parent 44f2ad3ee6
commit dcae56a4b6
7 changed files with 910 additions and 4 deletions
@@ -0,0 +1,306 @@
# XZBT-NGN Step 6.3 — Generic Surface Discovery Verification
**Date:** 2026-09-14
**Phase:** 6.3 — Contract 5.3 Surface Discovery (first NGN product code change for multi-surface)
**Repository branch:** main
**Pre-6.3 baseline commit:** 44f2ad3 (Step 6.2 Complete)
---
## A. Purpose
Phase 6.3 adds **generic Contract 5.3 surface discovery** to XZBT-NGN. At the end of 6.3 NGN can:
1. negotiate with Contract 5.2 exhibits exactly as before
2. negotiate with Contract 5.3 exhibits
3. discover an optional `surfaces` catalog from `describe.result`
4. validate that catalog according to Contract 5.3 §§31.231.5
5. store the validated surface descriptors in host state
6. display discovered surfaces generically in the NGN admin UI
7. react correctly when `registry.changed` changes the surface catalog
8. preserve all existing target/state/capability behavior
9. NOT open secondary surface windows (that is Phase 6.4)
---
## B. Contract 5.3 Negotiation Approach
**`xzbt` advisory field:** NGN now emits `xzbt: "5.3"` on all outgoing messages (hello and subsequent requests). Contract §6.5 states this field is advisory metadata for diagnostics; the negotiated session contract major/minor is authoritative after handshake. Both 5.2 and 5.3 exhibits must tolerate any advisory `xzbt` value.
**`supportedContractMajors`:** Unchanged — `[5]` covers both 5.2 and 5.3.
**Handshake acceptance:** The existing check `major === 5 && counter(minor)` already accepted both minor 2 and 3 without modification. No additional negotiation mechanism was needed.
**`contractMinor` tracking:** After `hello.result`, NGN stores `this.contractMinor = hello.contract.minor`. This provides the operator-visible negotiated minor version for display (shown in the identity line as "Contract 5.X") without creating a new negotiation step.
---
## C. Backward Compatibility with 5.2
Contract 5.2 exhibits:
- Continue to receive `xzbt: "5.3"` advisory (tolerated per §6.5; advisory only)
- Report `contract.minor: 2` in `hello.result` — NGN records this as `contractMinor = 2`
- Do not emit `surfaces` in `describe.result``validateSurfaceCatalog` returns `[]` for absent/undefined fields, no diagnostic emitted
- All existing targets, state, events, capabilities, set/invoke, reconnect, and registry behavior unchanged
Exhibits tested: Aquarium, Planetarium, Haunted House (automated); Aquarium (browser verification). Zero regressions observed.
---
## D. Surface Validation Implementation
**Location:** `src/validation.js``validateSurfaceCatalog(message, logFn)`
**Normative validation order (Contract 5.3 §31.3):**
1. If `message.surfaces` is absent or `[]` → return `[]` immediately (absent-equivalent; forms 1/2 treated identically)
2. Validate each entry independently against §31.2 and §31.4:
- `id`: must be a string conforming to `^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$` (canonical dotted grammar, §8.1) — single-segment IDs rejected
- `label`: required non-empty string
- `kind`: must equal the constant `"surface"`
- `primary`: must be a boolean
- `url`: must be a relative/query/fragment URL (no scheme, no `//`-prefix)
- duplicate `id` values: second occurrence discarded
3. Discard individually-invalid entries, log diagnostic for each; retain working set
4. If working set empty → return `[]` (absent-equivalent; this is NOT a primary-invariant failure)
5. Count `primary: true` in working set:
- Exactly 1 → return working set (conformant)
- 0 or >1 → log `SURFACE_CATALOG_REJECTED` diagnostic, return `[]` (fall back to absent)
Optional fields (`description`, `role`, `aspectRatio`, `category`, `requires`) are preserved as-is with no additional constraint.
**Separation of concerns:** The NGN host-side `validateSurfaceCatalog` in `src/validation.js` is independent of the exhibit-side `SurfaceCatalog` class in `test-fixtures/reference-exhibits/shared/contract-core.js`. Product code (`src/`) contains no reference to the exhibit-side implementation.
---
## E. Host State Model
**New field:** `this.surfaces = []` added to `ExhibitHost.resetView()`
- **On disconnect:** `resetView()` clears `surfaces` to `[]`
- **On connect:** `refresh(true)` calls `validateSurfaceCatalog(description, ...)` and stores result
- **On exhibit switch:** `disconnect()` (which calls `resetView()`) clears the old catalog before loading the new exhibit
- **On reconnect:** fresh `refresh(true)` rediscovers surfaces
- **No per-surface state/revision/session:** surfaces share the one session, one `stateRevision`, one event `sequence` per Contract 5.3 §§31.631.7
---
## F. Registry Refresh Behavior
No new event handling was required. `registry.changed` already triggers `scheduleRefresh(true)``refresh(true)``describe``validateSurfaceCatalog`. Surfaces are re-parsed from the fresh `describe.result` every time `refresh(true)` runs.
When a refreshed catalog differs, `this.surfaces` is assigned a new array reference, which `ui.js` detects via `host.surfaces !== renderedSurfaces` and triggers `buildSurfaces()`.
`registryRevision` governs both `targets` and `surfaces` per §31.6. No independent surface-registry revision was introduced.
---
## G. Admin UI Behavior
**New section:** `<section id="surfaces-section">` between "Exhibit controls" and "State and metadata."
**Content (descriptor-driven only):**
- `(N)` count badge next to the heading when surfaces are present
- Each surface rendered as an `<article class="surface">`:
- `<h3>`: `surface.label` (human-readable, from descriptor)
- `<code>`: `surface.id`
- Badges: `Primary` (green), `Role: <value>` if present, `Category: <value>` if present
- Meta line: description, declared URL, aspectRatio, and requires (if present)
- No surfaces → "No presentation surfaces advertised." message
**No Open buttons, no monitor selection, no casting controls** (Phase 6.4 boundary).
---
## H. Museum Gallery Interoperability
Museum Gallery is a Contract 5.3 reference exhibit that advertises three surfaces:
`surface.control` (primary), `surface.artifact`, `surface.info-wall`.
Verified via automated tests (`tests/museum-gallery.test.js`, pre-existing) and browser:
| Check | Result |
|---|---|
| Connect succeeds | ✓ |
| `hello.result` reports `contract.minor: 3` | ✓ |
| `describe.result` includes `surfaces[3]` | ✓ |
| Exactly one primary (`surface.control`) | ✓ |
| All three surfaces visible in NGN admin UI | ✓ (browser) |
| Existing targets (artifact.selected, lighting.level, etc.) render normally | ✓ |
| Set/invoke controls work | ✓ |
| State/events display correctly | ✓ |
| Reconnect rediscovers surfaces | ✓ |
| Disconnect clears surfaces | ✓ |
| No Museum Gallery-specific logic in `src/` | ✓ (genericity scan clean) |
---
## I. Contract 5.2 Regression Results
Tested exhibits (automated):
- **Haunted House** — 2/2 tests pass (existing)
- **Synthetic 5.2 peer** — 3 new lifecycle tests pass
- **Synthetic 5.2 hello advisory** — xzbt 5.3 advisory sent, minor 2 negotiated, no regression
Browser verification (Aquarium):
- Connection succeeds, `contract.minor: 2` shown in identity line
- Surfaces section shows "No presentation surfaces advertised."
- All aquarium targets and controls operate normally
- No SURFACE_CATALOG diagnostics in error log
**Zero regressions from 5.3 support.**
---
## J. Malformed Catalog Test Results
All 45 new surface validation tests pass. Coverage includes:
| Category | Tests | Pass |
|---|---|---|
| Absent/empty forms (§31.3 forms 1, 2) | 3 | 3 |
| Individual entry validation — id grammar | 6 | 6 |
| Individual entry validation — label | 3 | 3 |
| Individual entry validation — kind | 3 | 3 |
| Individual entry validation — primary | 3 | 3 |
| Individual entry validation — url (absolute, protocol-relative, scheme) | 3 | 3 |
| URL accept cases (relative, query, fragment) | 4 | 4 |
| Primary invariant — invalid primary discarded, valid primary survives | 1 | 1 |
| Primary invariant — invalid primary discarded, zero valid primaries → fallback | 1 | 1 |
| Primary invariant — zero primaries → whole catalog rejected | 1 | 1 |
| Primary invariant — multiple primaries → whole catalog rejected | 1 | 1 |
| All entries invalid → absent-equivalent (not malformed) | 1 | 1 |
| Valid catalog with 3 entries, 1 primary | 1 | 1 |
| Optional fields preserved | 1 | 1 |
| Duplicate id | 1 | 1 |
| Malformed surfaces do not affect validateCatalog | 1 | 1 |
| 5.3 host lifecycle (connect, disconnect, reconnect, registry.changed) | 5 | 5 |
| 5.2 backward compatibility | 3 | 3 |
| **Total** | **45** | **45** |
---
## K. Automated Test Results
```
tests 88
suites 0
pass 88
fail 0
cancelled 0
skipped 0
todo 0
duration_ms ~450
```
All 43 pre-existing tests continue to pass. 45 new surface-discovery tests added, all pass.
---
## L. Browser Verification
**Server:** `http://127.0.0.1:4173/` (Node.js static file server, same as Step 5)
### Museum Gallery (Contract 5.3)
Exhibit URL: `/test-fixtures/reference-exhibits/museum-gallery/index.html`
Verified manually:
1. **Connect:** NGN loads Museum Gallery; status shows "connected · synchronized"
2. **Identity line:** "Museum Gallery · 0.1.0 · Contract 5.3" (contract minor correctly shows 3)
3. **Exhibit controls:** 4 targets rendered (artifact.selected, lighting.level, rotation.speed, labels.enabled, action.spotlight-flash)
4. **Presentation surfaces section:** "(3)" badge; three cards rendered:
- "Control Room" / `surface.control` / **Primary** badge / Role: control / URL: control.html
- "Artifact Display" / `surface.artifact` / Role: ambient / URL: artifact.html
- "Information Wall" / `surface.info-wall` / Role: information / URL: info-wall.html
5. **Controls work:** Set lighting.level, set artifact.selected — state updates visible
6. **Events:** state.changed, selection.changed appear in incoming events log
7. **Disconnect:** surfaces section → "No presentation surfaces advertised."
8. **Reconnect:** Three surfaces reappear
### Aquarium (Contract 5.2)
Exhibit URL: `/test-fixtures/reference-exhibits/aquarium/index.html`
Verified:
1. **Connect:** Status "connected · synchronized"
2. **Identity line:** "... · Contract 5.2" (minor 2)
3. **Surfaces section:** "No presentation surfaces advertised."
4. **Controls:** Normal aquarium controls render and operate
5. **No errors** in diagnostics related to surfaces
---
## M. Genericity Verification
Command:
```powershell
Select-String -Path "src\*.js","public\index.html","public\style.css","server\*.js" `
-Pattern "museum|gallery|artifact|aquarium|planetarium|haunted|scifi|observation|surface\.control|surface\.artifact|surface\.info-wall" `
-SimpleMatch
```
**Result: Zero matches.** Product code contains no exhibit-specific vocabulary.
The word `surface` appears structurally as a CSS class (`.surface`, `.surface-badge`) and as a JavaScript property (`host.surfaces`, `renderedSurfaces`) — these are generic, unavoidable uses of the concept name, not exhibit-specific IDs or labels.
---
## N. Files Changed
### Modified
- `src/validation.js` — added `validateSurfaceCatalog()` implementing Contract 5.3 §§31.231.5 normative validation order
- `src/host.js` — emit `xzbt: '5.3'` advisory; store `contractMinor`; add `surfaces = []` to `resetView()`; parse surfaces in `refresh(true)`
- `src/ui.js` — add `buildSurfaces()` function and `renderedSurfaces` tracking; integrate into `render()`
- `public/index.html` — subtitle updated to "Contract 5.2/5.3"; add `<section id="surfaces-section">` with `<div id="surfaces">`
- `public/style.css` — add `.surface`, `.surface-badge`, `.surface-badge.primary`, `.surface-meta` styles
### New
- `tests/surface-validation.test.js` — 45 tests covering all Phase 6.3 Part 8 requirements
- `docs/reference/XZBT-NGN-Step6.3-Surface-Discovery-Verification.md` (this document)
### Not changed
- `src/connection.js` — no changes needed
- `src/transport/post-message.js` — no changes needed
- `server/serve.js` — no changes needed
- All test-fixtures `src/` equivalent code — exhibit-side is separate from NGN host-side
---
## O. Known Limitations
1. **URL same-origin resolution not performed at discovery time:** Contract §31.4 requires resolved URLs to be same-origin with the exhibit's base URL. Phase 6.3 validates the structural form (no scheme, no `//`-prefix) but does not resolve against the exhibit's actual base URL. Full resolution and same-origin check will be performed in Phase 6.4 when surfaces are actually opened.
2. **No surface lifecycle beyond discovery:** Phase 6.3 discovers and displays surfaces; it does not open, close, or manage them.
---
## P. Phase 6.4 Boundary
Phase 6.3 explicitly does NOT include:
- `window.open` or any secondary window management
- Opening surface URLs in any form (iframes, popups, new tabs)
- Display assignment or monitor selection
- Casting, Google TV, remote display endpoints
- Surface-to-monitor or surface-to-display mapping
- Full §31.4 URL resolution against exhibit base URL (deferred to 6.4)
Phase 6.4 will implement local multi-surface rendering, beginning with Phase 6.4's scope as defined in the NGN Implementation Plan.
---
## Q. Final Verdict
**STEP 6.3 COMPLETE — GENERIC SURFACE DISCOVERY READY FOR 6.4**
Evidence:
- 88/88 automated tests pass (43 pre-existing + 45 new)
- Contract 5.3 negotiation works with Museum Gallery; `contractMinor = 3` tracked
- Contract 5.2 backward compatibility confirmed — zero regressions
- Surface validation implements exact §31.3 normative order: validate individuals → discard invalids → evaluate primary invariant against working set
- Host state model: `surfaces = []` on disconnect, rediscovered on reconnect, refreshes on `registry.changed`
- Generic admin UI: descriptor-driven, no exhibit-specific IDs or labels
- Genericity scan: zero matches in `src/`, `public/`, `server/`
- `git diff --check`: clean
- Browser verification: Museum Gallery shows 3 surfaces; Aquarium shows clean absence
+5 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>XZBT-NGN Exhibit Engine</title><link rel="stylesheet" href="/public/style.css"></head>
<body>
<header><h1>XZBT-NGN Exhibit Engine</h1><p>Contract 5.2 · One exhibit at a time</p></header>
<header><h1>XZBT-NGN Exhibit Engine</h1><p>Contract 5.2/5.3 · One exhibit at a time</p></header>
<main>
<section><h2>Connection</h2>
<form id="connection"><label for="exhibit-url">Exhibit URL (same origin)</label><div class="connection-row"><input id="exhibit-url" placeholder="/public/exhibits/example/index.html" required><button>Load exhibit</button><button id="reconnect" type="button" disabled>Reconnect</button><button id="disconnect" type="button" disabled>Disconnect</button></div></form>
@@ -16,6 +16,10 @@
<p>Reported values are authoritative. New values are drafts; select Set or Invoke to submit. Rediscovery resets drafts.</p>
<div id="catalog">Connect an exhibit to discover its controls.</div>
</section>
<section id="surfaces-section"><h2>Presentation surfaces <span id="surface-count"></span></h2>
<p>Presentation surfaces are optional Contract 5.3 views advertised by the connected exhibit. Discovery is descriptor-driven; opening surfaces is a later phase.</p>
<div id="surfaces"><p>No presentation surfaces advertised.</p></div>
</section>
<section><h2>State and metadata</h2><button id="refresh" disabled>Refresh state</button>
<details><summary>Session and exhibit metadata</summary><pre id="metadata">No negotiated session.</pre></details>
<details><summary>Current persistent state</summary><p>Last reported exhibit values. This host view may be uncertain while resynchronizing.</p><pre id="state">{}</pre></details>
+6
View File
@@ -24,3 +24,9 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px s
.target { padding: 16px 12px; }
.target code { overflow-wrap: anywhere; }
@media (max-width: 600px) { header, main { padding: 12px; } section { padding: 12px; } .connection-row input { flex-basis: 100%; } }
.surface { border-top: 1px solid #354658; padding: 14px 12px; }
.surface h3 { margin: 0 0 2px; font-size: 15px; }
.surface code { display: block; font-size: 12px; color: #7eb8d8; overflow-wrap: anywhere; margin-bottom: 4px; }
.surface-badge { display: inline-block; font-size: 11px; padding: 2px 7px; border-radius: 3px; background: #233444; margin-right: 6px; }
.surface-badge.primary { background: #1a4a2e; color: #80d6ad; }
.surface-meta { font-size: 13px; color: #b7c4d1; margin: 4px 0 0; }
+6 -3
View File
@@ -1,4 +1,4 @@
import { ProtocolError, record, counter, check, validateCatalog, validateArgs, validateSet, validateReportedValue } from './validation.js';
import { ProtocolError, record, counter, check, validateCatalog, validateSurfaceCatalog, validateArgs, validateSet, validateReportedValue } from './validation.js';
const events = new Set(['state.changed', 'selection.changed', 'action.executed', 'capability.changed', 'registry.changed', 'error']);
const responses = { hello: 'hello.result', describe: 'describe.result', 'state.get': 'state.result', set: 'set.result', invoke: 'invoke.result' };
@@ -11,7 +11,7 @@ export class ExhibitHost {
}
resetView() {
this.status = 'disconnected'; this.sessionId = null; this.contract = null; this.exhibit = null;
this.catalog = []; this.capabilities = []; this.registryRevision = null; this.stateRevision = null;
this.catalog = []; this.capabilities = []; this.surfaces = []; this.registryRevision = null; this.stateRevision = null;
this.sequence = null; this.values = new Map(); this.sync = 'not synchronized'; this.eventLog = [];
this.snapshotEvents = null; this.refreshing = null; this.refreshWanted = false;
}
@@ -36,11 +36,13 @@ export class ExhibitHost {
this.status = 'negotiating'; this.changed();
const generation = this.generation;
try {
// xzbt is advisory metadata (Contract §6.5); emit '5.3' to signal NGN is 5.3-aware.
const hello = await this.request('hello', { host: { name: 'XZBT-NGN', version: '0.1.0' }, supportedContractMajors: [5] });
if (generation !== this.generation) return;
check(hello.contract?.major === 5 && counter(hello.contract?.minor), 'Exhibit negotiated an unsupported contract.', 'UNSUPPORTED_VERSION');
check(typeof hello.sessionId === 'string' && hello.sessionId.length > 0 && record(hello.exhibit), 'Invalid hello result.');
this.sessionId = hello.sessionId; this.contract = hello.contract; this.exhibit = hello.exhibit;
this.contractMinor = hello.contract.minor;
this.status = 'connected'; this.log('SESSION', 'Exhibit session established.', { sessionId: this.sessionId });
await this.refresh(true);
} catch (error) {
@@ -60,7 +62,7 @@ export class ExhibitHost {
}, this.timeoutMs);
this.pending.set(requestId, { resolve, reject, timer, type });
try {
this.transport.send({ xzbt: '5.2', type, requestId, ...(type === 'hello' ? {} : { sessionId: this.sessionId }), ...payload });
this.transport.send({ xzbt: '5.3', type, requestId, ...(type === 'hello' ? {} : { sessionId: this.sessionId }), ...payload });
} catch (error) { clearTimeout(timer); this.pending.delete(requestId); reject(error); }
});
}
@@ -149,6 +151,7 @@ export class ExhibitHost {
if (generation !== this.generation) return;
validateCatalog(description);
this.catalog = description.targets; this.capabilities = description.capabilities;
this.surfaces = validateSurfaceCatalog(description, diag => this.log('SURFACE_CATALOG', diag));
this.registryRevision = description.registryRevision; this.exhibit = description.exhibit;
this.changed();
}
+25
View File
@@ -8,6 +8,7 @@ const byId = id => document.getElementById(id);
const json = value => JSON.stringify(value, null, 2);
const rows = new Map();
let renderedCatalog = null;
let renderedSurfaces = null;
const connection = new ExhibitConnection({ host, base: location.href, transport: postMessageTransport, changed: render,
createFrame() {
const frame = document.createElement('iframe'); frame.title = 'Connected exhibit';
@@ -100,6 +101,29 @@ function buildCatalog() {
rows.set(target.id, row);
}
}
function buildSurfaces() {
const container = byId('surfaces'); container.replaceChildren();
byId('surface-count').textContent = host.surfaces.length ? `(${host.surfaces.length})` : '';
if (!host.surfaces.length) {
element('p', host.sessionId ? 'No presentation surfaces advertised.' : 'No presentation surfaces advertised.', container);
return;
}
for (const surface of host.surfaces) {
const card = element('article', undefined, container); card.className = 'surface';
element('h3', surface.label, card);
element('code', surface.id, card);
const badges = element('div', undefined, card);
if (surface.primary) { const b = element('span', 'Primary', badges); b.className = 'surface-badge primary'; }
if (surface.role) { const b = element('span', `Role: ${surface.role}`, badges); b.className = 'surface-badge'; }
if (surface.category) { const b = element('span', `Category: ${surface.category}`, badges); b.className = 'surface-badge'; }
const meta = [];
if (surface.description) meta.push(surface.description);
if (surface.url) meta.push(`URL: ${surface.url}`);
if (surface.aspectRatio) meta.push(`Aspect ratio: ${surface.aspectRatio}`);
if (surface.requires?.length) meta.push(`Requires: ${surface.requires.join(', ')}`);
if (meta.length) { const p = element('p', meta.join(' · '), card); p.className = 'surface-meta'; }
}
}
function render() {
byId('status').textContent = connection.loading ? 'Loading exhibit…' : `${host.status} · ${host.sync}`;
byId('status').dataset.state = host.status;
@@ -123,6 +147,7 @@ function render() {
byId('reconnect').disabled = !connection.url || connection.loading || host.status === 'negotiating';
byId('disconnect').disabled = !connection.frame;
if (host.catalog !== renderedCatalog) { renderedCatalog = host.catalog; buildCatalog(); }
if (host.surfaces !== renderedSurfaces) { renderedSurfaces = host.surfaces; buildSurfaces(); }
for (const row of rows.values()) {
row.current.textContent = host.values.has(row.target.id) ? `Reported value: ${json(host.values.get(row.target.id))}` : 'No persistent value reported.';
if (!row.initialized && row.valueEditor && host.values.has(row.target.id)) {
+66
View File
@@ -80,6 +80,72 @@ export function validateReportedValue(target, value) {
catch (error) { throw new ProtocolError('INVALID_MESSAGE', `${target.id}: ${error.message}`); }
}
}
// Contract 5.3 §31.4 — url must be relative/query/fragment, never absolute or protocol-relative.
function isRelativeSurfaceUrl(url) {
if (typeof url !== 'string' || url.length === 0) return false;
if (url.indexOf('//') === 0) return false; // protocol-relative
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) return false; // has a scheme
return true;
}
// Contract 5.3 §31.2 individual descriptor validation. Returns a diagnostic string or null.
function invalidSurfaceEntryReason(d, seenIds) {
if (!record(d)) return 'entry is not an object';
if (typeof d.id !== 'string' || !/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/.test(d.id))
return 'id is missing or does not conform to the canonical dotted grammar (§8.1)';
if (seenIds.has(d.id)) return `duplicate id "${d.id}"`;
if (typeof d.label !== 'string' || d.label.length === 0) return 'label is required';
if (d.kind !== 'surface') return 'kind must be the constant "surface"';
if (typeof d.primary !== 'boolean') return 'primary must be a boolean';
if (!isRelativeSurfaceUrl(d.url)) return 'url must be a same-origin-relative reference (§31.4)';
return null;
}
/**
* Validates the optional `surfaces` field of a describe.result per Contract 5.3 §§31.231.5.
*
* Normative validation order (§31.3):
* 1. Validate each individual descriptor; discard individually-invalid entries.
* 2. If the working set is empty return [] (absent-equivalent, form 1/2).
* 3. Count primary:true in the working set:
* - Exactly 1 conformant; return the working set.
* - 0 or >1 reject the whole catalog; log a diagnostic; return [].
*
* @param {object} message The describe.result message.
* @param {function} logFn Optional function(diagnostic:string) called for each diagnostic.
* @returns {Array} Validated surface descriptors, or [] when the catalog is absent/empty/rejected.
*/
export function validateSurfaceCatalog(message, logFn) {
const log = typeof logFn === 'function' ? logFn : () => {};
const raw = message.surfaces;
if (!Array.isArray(raw) || raw.length === 0) return []; // absent or empty → treated identically
// Step 1: validate individually, discard invalids.
const seenIds = new Set();
const valid = [];
for (let i = 0; i < raw.length; i++) {
const reason = invalidSurfaceEntryReason(raw[i], seenIds);
if (reason) {
log(`SURFACE_INVALID: Discarded surfaces[${i}]: ${reason}.`);
continue;
}
seenIds.add(raw[i].id);
valid.push(raw[i]);
}
// Step 2: empty working set → absent-equivalent.
if (valid.length === 0) return [];
// Step 3: evaluate primary invariant against the working set only.
const primaries = valid.filter(d => d.primary === true);
if (primaries.length !== 1) {
log(`SURFACE_CATALOG_REJECTED: Expected exactly one primary:true among ${valid.length} valid entries, found ${primaries.length}. Falling back to implicit single-surface behavior (Contract 5.3 §31.3).`);
return [];
}
return valid;
}
export function validateCatalog(message) {
check(record(message.exhibit) && record(message.contract) && message.contract.major === 5,
'Invalid description metadata.');
+496
View File
@@ -0,0 +1,496 @@
/**
* Phase 6.3 Surface catalog validation and lifecycle tests.
*
* Covers Contract 5.3 §§31.2-31.5 normative validation order,
* host state lifecycle, registry refresh behavior, and 5.2 backward
* compatibility. No exhibit-specific IDs appear in this file.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { validateSurfaceCatalog, validateCatalog } from '../src/validation.js';
import { ExhibitHost } from '../src/host.js';
/* ------------------------------------------------------------------ *
* Helpers
* ------------------------------------------------------------------ */
/** Minimal valid surface descriptor; caller may override fields. */
function validSurface(overrides = {}) {
return {
id: 'surface.primary-view',
label: 'Primary View',
kind: 'surface',
primary: true,
url: 'primary.html',
...overrides,
};
}
/** Run validateSurfaceCatalog, capturing diagnostics. */
function validate(surfaces) {
const diags = [];
const result = validateSurfaceCatalog({ surfaces }, msg => diags.push(msg));
return { result, diags };
}
/* ------------------------------------------------------------------ *
* Absent / empty array forms (Contract 5.3 §31.3 forms 1 & 2)
* ------------------------------------------------------------------ */
test('absent surfaces field is accepted and treated as empty', () => {
const { result, diags } = validate(undefined);
assert.deepEqual(result, []);
assert.equal(diags.length, 0);
});
test('surfaces: [] is accepted and treated identically to absent', () => {
const { result, diags } = validate([]);
assert.deepEqual(result, []);
assert.equal(diags.length, 0);
});
test('non-array surfaces field treated as absent', () => {
const { result } = validate('not-an-array');
assert.deepEqual(result, []);
});
/* ------------------------------------------------------------------ *
* Individual entry validation required fields (§31.2)
* ------------------------------------------------------------------ */
test('valid single-entry catalog (one primary) is accepted', () => {
const { result } = validate([validSurface()]);
assert.equal(result.length, 1);
assert.equal(result[0].id, 'surface.primary-view');
});
test('entry missing id is discarded', () => {
const s = validSurface(); delete s.id;
const { result, diags } = validate([s]);
assert.deepEqual(result, []);
assert.ok(diags.some(d => /Discarded/.test(d)));
});
test('entry with non-string id is discarded', () => {
const { result } = validate([validSurface({ id: 42 })]);
assert.deepEqual(result, []);
});
test('bare single-segment id (no dot) is discarded', () => {
const { result, diags } = validate([validSurface({ id: 'nodot' })]);
assert.deepEqual(result, []);
assert.ok(diags.some(d => /Discarded/.test(d)));
});
test('id with uppercase is discarded', () => {
const { result } = validate([validSurface({ id: 'Surface.control' })]);
assert.deepEqual(result, []);
});
test('id with leading digit is discarded', () => {
const { result } = validate([validSurface({ id: '1surface.control' })]);
assert.deepEqual(result, []);
});
test('id with empty segment is discarded', () => {
const { result } = validate([validSurface({ id: 'surface..control' })]);
assert.deepEqual(result, []);
});
test('id with underscore is discarded', () => {
const { result } = validate([validSurface({ id: 'surface_x.control' })]);
assert.deepEqual(result, []);
});
test('entry missing label is discarded', () => {
const s = validSurface(); delete s.label;
const { result, diags } = validate([s]);
assert.deepEqual(result, []);
assert.ok(diags.some(d => /label/.test(d)));
});
test('entry with empty string label is discarded', () => {
const { result } = validate([validSurface({ label: '' })]);
assert.deepEqual(result, []);
});
test('entry with non-string label is discarded', () => {
const { result } = validate([validSurface({ label: 123 })]);
assert.deepEqual(result, []);
});
test('entry with wrong kind is discarded', () => {
const { result, diags } = validate([validSurface({ kind: 'state' })]);
assert.deepEqual(result, []);
assert.ok(diags.some(d => /kind/.test(d)));
});
test('entry with kind impulse is discarded', () => {
const { result } = validate([validSurface({ kind: 'impulse' })]);
assert.deepEqual(result, []);
});
test('entry missing kind is discarded', () => {
const s = validSurface(); delete s.kind;
const { result } = validate([s]);
assert.deepEqual(result, []);
});
test('entry with non-boolean primary (string) is discarded', () => {
const { result, diags } = validate([validSurface({ primary: 'true' })]);
assert.deepEqual(result, []);
assert.ok(diags.some(d => /primary/.test(d)));
});
test('entry with primary: 1 (number) is discarded', () => {
const { result } = validate([validSurface({ primary: 1 })]);
assert.deepEqual(result, []);
});
test('entry with primary: null is discarded', () => {
const { result } = validate([validSurface({ primary: null })]);
assert.deepEqual(result, []);
});
test('entry missing url is discarded', () => {
const s = validSurface(); delete s.url;
const { result, diags } = validate([s]);
assert.deepEqual(result, []);
assert.ok(diags.some(d => /url/.test(d)));
});
test('absolute url is rejected as individually-invalid', () => {
const { result, diags } = validate([validSurface({ url: 'https://evil.example/page.html' })]);
assert.deepEqual(result, []);
assert.ok(diags.some(d => /url/.test(d)));
});
test('protocol-relative url is rejected as individually-invalid', () => {
const { result, diags } = validate([validSurface({ url: '//evil.example/page.html' })]);
assert.deepEqual(result, []);
assert.ok(diags.some(d => /url/.test(d)));
});
test('url with scheme (http:) is rejected', () => {
const { result } = validate([validSurface({ url: 'http:page.html' })]);
assert.deepEqual(result, []);
});
test('relative path url is accepted', () => {
const { result } = validate([validSurface({ url: 'views/primary.html' })]);
assert.equal(result.length, 1);
});
test('relative path with query string is accepted', () => {
const { result } = validate([validSurface({ url: 'primary.html?surface=main' })]);
assert.equal(result.length, 1);
});
test('bare query string is accepted (single-page exhibit)', () => {
const { result } = validate([validSurface({ url: '?surface=main' })]);
assert.equal(result.length, 1);
});
test('fragment-only url is accepted', () => {
const { result } = validate([validSurface({ url: '#surface-main' })]);
assert.equal(result.length, 1);
});
/* ------------------------------------------------------------------ *
* Primary invariant evaluated after individual validation (§31.3)
* ------------------------------------------------------------------ */
test('individually-invalid primary discarded; remaining valid primary => catalog valid', () => {
const entries = [
validSurface({ id: 'INVALID_ID', primary: true }),
validSurface({ id: 'surface.secondary', label: 'Secondary', primary: true, url: 'secondary.html' }),
];
const { result } = validate(entries);
assert.equal(result.length, 1);
assert.equal(result[0].id, 'surface.secondary');
});
test('individually-invalid primary discarded; zero valid primaries => whole catalog fallback', () => {
const entries = [
validSurface({ id: 'BAD', primary: true }),
validSurface({ id: 'surface.secondary', label: 'Secondary', primary: false, url: 'secondary.html' }),
];
const { result, diags } = validate(entries);
assert.deepEqual(result, []);
assert.ok(diags.some(d => /SURFACE_CATALOG_REJECTED/.test(d) || /exactly one/.test(d)));
});
test('zero primaries among valid entries => whole catalog rejected', () => {
const entries = [
validSurface({ id: 'surface.a', label: 'A', primary: false, url: 'a.html' }),
validSurface({ id: 'surface.b', label: 'B', primary: false, url: 'b.html' }),
];
const { result, diags } = validate(entries);
assert.deepEqual(result, []);
assert.ok(diags.some(d => /SURFACE_CATALOG_REJECTED/.test(d) || /exactly one/.test(d)));
});
test('multiple valid primaries => whole catalog rejected', () => {
const entries = [
validSurface({ id: 'surface.a', label: 'A', primary: true, url: 'a.html' }),
validSurface({ id: 'surface.b', label: 'B', primary: true, url: 'b.html' }),
];
const { result, diags } = validate(entries);
assert.deepEqual(result, []);
assert.ok(diags.some(d => /SURFACE_CATALOG_REJECTED/.test(d) || /exactly one/.test(d)));
});
test('all entries individually invalid => absent-equivalent, not malformed-primary', () => {
const entries = [
{ id: 'BAD ID', label: 'x', kind: 'surface', primary: true, url: 'x.html' },
{ id: 'surface.y', label: '', kind: 'surface', primary: true, url: 'y.html' },
];
const { result, diags } = validate(entries);
assert.deepEqual(result, []);
assert.ok(!diags.some(d => /SURFACE_CATALOG_REJECTED/.test(d)), 'empty valid set is absent-equivalent, not a primary violation');
});
test('non-empty valid catalog with one primary is fully returned', () => {
const entries = [
validSurface({ id: 'surface.ctrl', label: 'Control', primary: true, url: 'ctrl.html' }),
validSurface({ id: 'surface.display', label: 'Display', primary: false, url: 'display.html' }),
validSurface({ id: 'surface.info', label: 'Info', primary: false, url: 'info.html' }),
];
const { result } = validate(entries);
assert.equal(result.length, 3);
assert.equal(result.filter(s => s.primary).length, 1);
});
/* ------------------------------------------------------------------ *
* Optional fields preserved generically (§31.2)
* ------------------------------------------------------------------ */
test('optional fields are preserved in validated descriptors', () => {
const s = validSurface({
description: 'The operator control surface.',
role: 'control',
aspectRatio: '16:9',
category: 'operator',
requires: ['render'],
});
const { result } = validate([s]);
assert.equal(result.length, 1);
assert.equal(result[0].description, 'The operator control surface.');
assert.equal(result[0].role, 'control');
assert.equal(result[0].aspectRatio, '16:9');
assert.equal(result[0].category, 'operator');
assert.deepEqual(result[0].requires, ['render']);
});
/* ------------------------------------------------------------------ *
* Duplicate id handling
* ------------------------------------------------------------------ */
test('duplicate id: second entry with same id is discarded', () => {
const entries = [
validSurface({ id: 'surface.ctrl', label: 'First', primary: true, url: 'ctrl.html' }),
validSurface({ id: 'surface.ctrl', label: 'Duplicate', primary: false, url: 'ctrl2.html' }),
];
const { result, diags } = validate(entries);
assert.equal(result.length, 1);
assert.equal(result[0].label, 'First');
assert.ok(diags.some(d => /duplicate/.test(d)));
});
/* ------------------------------------------------------------------ *
* Malformed surfaces do NOT invalidate targets/capabilities
* ------------------------------------------------------------------ */
test('malformed surfaces field does not throw and does not affect validateCatalog', () => {
const message = {
exhibit: {}, contract: { major: 5 },
registryRevision: 0, stateRevision: 0,
capabilities: [],
targets: [{ id: 'sample.level', kind: 'state', valueType: 'boolean', readable: true, writable: true, requires: [] }],
surfaces: [{ id: 'BAD', label: '', kind: 'not-surface', primary: 'yes', url: 'https://evil.example' }],
};
assert.doesNotThrow(() => validateCatalog(message));
const { result } = validate(message.surfaces);
assert.deepEqual(result, []);
});
/* ------------------------------------------------------------------ *
* ExhibitHost lifecycle surfaces cleared on disconnect, refreshed on
* registry.changed (Parts 3 & 4)
* ------------------------------------------------------------------ */
function peer53() {
let receive, session = 0, sequence = 0;
let currentSurfaces = [
{ id: 'surface.primary', label: 'Primary', kind: 'surface', primary: true, url: 'primary.html' },
{ id: 'surface.secondary', label: 'Secondary', kind: 'surface', primary: false, url: 'secondary.html' },
];
const fixture = {
transport: {
subscribe(fn) { receive = fn; return () => {}; },
close() {},
send(m) {
const out = { xzbt: '5.3', requestId: m.requestId, sessionId: `s${session}` };
if (m.type === 'hello') {
session++; sequence = 0;
receive({ ...out, type: 'hello.result', sessionId: `s${session}`, contract: { major: 5, minor: 3 }, exhibit: { product: 'Synthetic 5.3 peer' } });
} else if (m.type === 'describe') {
receive({ ...out, type: 'describe.result', exhibit: { product: 'Synthetic 5.3 peer' },
contract: { major: 5, minor: 3 }, targets: [{ id: 'sample.state', kind: 'state', valueType: 'boolean',
readable: true, writable: true, requires: [] }], capabilities: [],
registryRevision: 1, stateRevision: 0, surfaces: currentSurfaces });
} else if (m.type === 'state.get') {
receive({ ...out, type: 'state.result', stateRevision: 0, values: { 'sample.state': true } });
}
}
},
event(type, payload = {}) {
receive({ xzbt: '5.3', type, sessionId: `s${session}`, sequence: ++sequence, timestamp: Date.now(), ...payload });
},
setSurfaces(list) { currentSurfaces = list; }
};
return fixture;
}
function peer52() {
let receive, session = 0;
const fixture = {
transport: {
subscribe(fn) { receive = fn; return () => {}; },
close() {},
send(m) {
const out = { xzbt: '5.2', requestId: m.requestId, sessionId: `s${session}` };
if (m.type === 'hello') {
session++;
receive({ ...out, type: 'hello.result', sessionId: `s${session}`, contract: { major: 5, minor: 2 }, exhibit: { product: 'Synthetic 5.2 peer' } });
} else if (m.type === 'describe') {
receive({ ...out, type: 'describe.result', exhibit: { product: 'Synthetic 5.2 peer' },
contract: { major: 5, minor: 2 }, targets: [{ id: 'sample.state', kind: 'state', valueType: 'boolean',
readable: true, writable: true, requires: [] }], capabilities: [],
registryRevision: 1, stateRevision: 0 });
} else if (m.type === 'state.get') {
receive({ ...out, type: 'state.result', stateRevision: 0, values: { 'sample.state': true } });
}
}
}
};
return fixture;
}
const settle = () => new Promise(resolve => setTimeout(resolve, 25));
test('Contract 5.3 exhibit: surfaces discovered after connect', async t => {
const fixture = peer53();
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
t.after(() => host.disconnect());
await host.connect(fixture.transport);
assert.equal(host.contractMinor, 3);
assert.equal(host.surfaces.length, 2);
assert.equal(host.surfaces.filter(s => s.primary).length, 1);
assert.equal(host.surfaces[0].id, 'surface.primary');
});
test('disconnect clears surface catalog', async t => {
const fixture = peer53();
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
t.after(() => host.disconnect());
await host.connect(fixture.transport);
assert.equal(host.surfaces.length, 2);
host.disconnect();
assert.equal(host.surfaces.length, 0, 'surfaces must be empty after disconnect');
});
test('reconnect after disconnect rediscovers surface catalog', async t => {
const fixture = peer53();
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
t.after(() => host.disconnect());
await host.connect(fixture.transport);
assert.equal(host.surfaces.length, 2);
host.disconnect();
assert.equal(host.surfaces.length, 0);
await host.connect(fixture.transport);
assert.equal(host.surfaces.length, 2, 'surfaces restored after reconnect');
});
test('registry.changed refreshes surface catalog when it changes', async t => {
const fixture = peer53();
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
t.after(() => host.disconnect());
await host.connect(fixture.transport);
assert.equal(host.surfaces.length, 2);
fixture.setSurfaces([
{ id: 'surface.primary', label: 'Primary Updated', kind: 'surface', primary: true, url: 'primary.html' }
]);
fixture.event('registry.changed');
await settle();
assert.equal(host.surfaces.length, 1);
assert.equal(host.surfaces[0].label, 'Primary Updated');
});
test('registry.changed can clear surfaces entirely', async t => {
const fixture = peer53();
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
t.after(() => host.disconnect());
await host.connect(fixture.transport);
assert.equal(host.surfaces.length, 2);
fixture.setSurfaces([]);
fixture.event('registry.changed');
await settle();
assert.equal(host.surfaces.length, 0);
});
/* ------------------------------------------------------------------ *
* Contract 5.2 backward compatibility
* ------------------------------------------------------------------ */
test('Contract 5.2 exhibit: surfaces stays empty, no error', async t => {
const fixture = peer52();
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
t.after(() => host.disconnect());
await host.connect(fixture.transport);
assert.equal(host.contractMinor, 2);
assert.equal(host.surfaces.length, 0);
assert.ok(!host.logs.some(l => l.code === 'SURFACE_CATALOG'));
});
test('Contract 5.2 exhibit: controls, state and events still work', async t => {
const fixture = peer52();
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
t.after(() => host.disconnect());
await host.connect(fixture.transport);
assert.equal(host.status, 'connected');
assert.equal(host.sync, 'synchronized');
assert.equal(host.catalog.length, 1);
assert.equal(host.surfaces.length, 0);
});
test('NGN emits xzbt 5.3 advisory on hello; 5.2 exhibit negotiates normally', async t => {
const sentMessages = [];
let receive, session = 0;
const transport = {
subscribe(fn) { receive = fn; return () => {}; },
close() {},
send(m) {
sentMessages.push(m);
const out = { xzbt: '5.2', requestId: m.requestId, sessionId: `s${session}` };
if (m.type === 'hello') {
session++;
receive({ ...out, type: 'hello.result', sessionId: `s${session}`, contract: { major: 5, minor: 2 }, exhibit: { product: 'Test 5.2' } });
} else if (m.type === 'describe') {
receive({ ...out, type: 'describe.result', exhibit: { product: 'Test 5.2' }, contract: { major: 5, minor: 2 },
targets: [], capabilities: [], registryRevision: 0, stateRevision: 0 });
} else if (m.type === 'state.get') {
receive({ ...out, type: 'state.result', stateRevision: 0, values: {} });
}
}
};
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
t.after(() => host.disconnect());
await host.connect(transport);
const helloMsg = sentMessages.find(m => m.type === 'hello');
assert.equal(helloMsg.xzbt, '5.3', 'NGN emits xzbt 5.3 advisory');
assert.equal(host.contractMinor, 2, 'negotiated minor reflects what 5.2 exhibit reported');
assert.equal(host.status, 'connected');
});