Steps 6.4-6.7B — Local surfaces, reference-exhibit validation, SciFi Observation surface

One commit for the work accumulated in the working tree since Step 6.3,
which had never been split into per-step commits:

- src/local-surfaces.js + src/surface-url.js (new); src/ui.js,
  src/validation.js, src/connection.js and public/index.html updated for
  local-surface hosting and generic surface rendering
- tests: local-surfaces (20), scifi-surfaces (24) and postmessage-interop (7)
  new; connection/museum-gallery/surface-validation suites updated
- reference exhibits: shared/contract-core.js defaults to Contract 5.3
  (major 5, minor 3, xzbt 5.3); museum-gallery advertises its surface
  catalog; aquarium/haunted-house/planetarium adapters updated
- SciFi-XZBT (Step 6.7A/6.7B): surface-mode.js + surface-bus.js,
  Observation-surface boot branch, local-change hooks, view.pillars /
  view.warp-flight targets; fixture byte-identical to G:/.vibe/SciFi-XZBT
- SciFi-XZBT contract adapter handshake fix: the inbound bridge filter no
  longer gates on an exact advisory xzbt value (Contract 5.3 §6.5), only on
  its presence/type, matching the host's own envelope validation; the
  adapter now advertises contract minor 3 / version 5.3.0, which it already
  implemented via the 5.3 surfaces field. Root cause of the five failing
  postmessage-interop tests (host hello was silently dropped).
- docs: architecture 6.4 and 6.7A, reference 6.6 and 6.7; evidence logs;
  test-fixtures/PROVENANCE.md resync record

Test results: NGN 154/154 (was 149/154); postmessage-interop 7/7 (was 2/7);
SciFi contract harness 21/21, real-adapter suite 32/32. git diff --check
clean for changed files; two pre-existing trailing-whitespace lines remain
in test-fixtures/reference-exhibits/scifi/index.html, copied verbatim from
the authoritative SciFi source.

Step 6.7 live verification (browser Observation, packaged standalone) is
still pending and is not claimed here.
This commit is contained in:
2026-09-14 19:45:27 -07:00
parent ed76cf6189
commit 745912e451
40 changed files with 5346 additions and 210 deletions
@@ -1,5 +1,5 @@
/**
* XZBT Contract Adapter — Specification v5.2
* XZBT Contract Adapter — Specification v5.3
*
* Implements the normalized contract interface for SciFi-XZBT:
* - Canonical external target catalog & descriptors
@@ -15,10 +15,10 @@
class XZBTContractAdapter {
constructor(options = {}) {
this.product = options.product || 'SciFi-XZBT';
this.version = options.version || '5.2.0';
this.version = options.version || '5.3.0';
this.build = options.build || 'production';
this.contractMajor = 5;
this.contractMinor = 2;
this.contractMinor = 3;
this.sessionId = null;
this.sessionActive = false;
@@ -28,6 +28,17 @@
this.operationQueue = Promise.resolve();
this.eventSink = null;
// Surface catalog (Contract 5.3 §31.2/§31.3). Advertised in describe()
// only when non-empty, so absence stays §31.3 form 1 ("absent").
this.surfaces = Array.isArray(options.surfaces) ? options.surfaces : null;
this.instanceId = options.instanceId || null;
// Session-independent local-change hooks (Step 6.7B). Unlike
// _emitEvent, these fire regardless of sessionActive so an
// exhibit-internal surface mirror keeps working with no NGN attached.
this.onLocalChange = typeof options.onLocalChange === 'function' ? options.onLocalChange : null;
this.onLocalAction = typeof options.onLocalAction === 'function' ? options.onLocalAction : null;
// Transport hooks
this.onMessage = options.onMessage || (() => {});
this.hostOrigin = options.hostOrigin || null; // optional origin filter
@@ -355,6 +366,31 @@
requires: ['observation']
});
reg({
id: 'view.pillars',
legacyId: 'observation-pillars',
label: 'Window Pillars / Mullions',
kind: 'state',
valueType: 'boolean',
readable: true,
writable: true,
restorable: true,
category: 'observation',
requires: ['observation']
});
reg({
id: 'view.warp-flight',
label: 'Warp Flight Mode',
kind: 'state',
valueType: 'boolean',
readable: true,
writable: true,
restorable: true,
category: 'observation',
requires: ['observation']
});
reg({
id: 'alert.active',
label: 'Active Tactical Alert Status',
@@ -416,9 +452,9 @@
restorable: false,
category: 'speech',
requires: ['speech'],
args: {
text: { type: 'string', required: true, maxLength: 2000 }
}
arguments: [
{ name: 'text', type: 'string', required: true, maxLength: 2000 }
]
});
reg({
@@ -442,9 +478,9 @@
restorable: false,
category: 'display',
requires: ['display-text'],
args: {
text: { type: 'string', required: true, maxLength: 512 }
}
arguments: [
{ name: 'text', type: 'string', required: true, maxLength: 512 }
]
});
// --- 4. Canonical Universe Event Impulses ---
@@ -555,7 +591,7 @@
return;
}
// Ignore non-XZBT messages
if (!e.data || typeof e.data !== 'object' || e.data.xzbt !== '5.2') {
if (!e.data || typeof e.data !== 'object' || typeof e.data.xzbt !== 'string') {
return;
}
if (e.data.sequence || e.data.type === 'error' || /\.result$/.test(e.data.type)) return;
@@ -616,6 +652,15 @@
stateRevision: this.stateRevision, target: target.id, value: after[target.id]
}, correlationId, source);
}
// Session-independent mirror feed (Step 6.7B). _emitEvent above is
// gated on sessionActive and therefore silent with no NGN attached;
// onLocalChange is not, so an exhibit-internal surface mirror keeps
// working standalone. Fires for every changed target, same as above.
if (this.onLocalChange) {
for (const target of changes) {
this.onLocalChange(target.id, after[target.id], this.stateRevision);
}
}
return true;
}
@@ -755,11 +800,13 @@
// Validate args schema & text length limits if defined
const sanitizedArgs = (args && typeof args === 'object' && !Array.isArray(args)) ? { ...args } : {};
if (Object.keys(sanitizedArgs).some(key => !target.args || !target.args[key])) {
const argumentSpecs = target.arguments || [];
if (Object.keys(sanitizedArgs).some(key => !argumentSpecs.some(spec => spec.name === key))) {
return { ok: false, code: 'INVALID_VALUE', message: 'Unexpected argument key' };
}
if (target.args) {
for (const [argKey, argSpec] of Object.entries(target.args)) {
if (argumentSpecs.length) {
for (const argSpec of argumentSpecs) {
const argKey = argSpec.name;
if (argSpec.required && (sanitizedArgs[argKey] === undefined || sanitizedArgs[argKey] === null)) {
return { ok: false, code: 'INVALID_VALUE', message: `Missing required argument '${argKey}'` };
}
@@ -823,6 +870,11 @@
args: sanitizedArgs
}, correlationId, source);
// Session-independent mirror feed (Step 6.7B); see _commitChanges.
if (this.onLocalAction) {
this.onLocalAction(canonicalId, sanitizedArgs);
}
return { ok: true };
}
@@ -836,7 +888,7 @@
// Public API: Protocol Description
describe() {
return {
const description = {
exhibit: {
product: this.product,
version: this.version,
@@ -851,6 +903,10 @@
capabilities: Array.from(this.capabilities.values()),
targets: Array.from(this.targets.values())
};
if (this.surfaces && this.surfaces.length) {
description.surfaces = this.surfaces;
}
return description;
}
// Update Capability state and emit capability.changed