From a2422e9b5eb3381eee9033e9299fce5766ca56b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 11:33:20 -0700 Subject: [PATCH 001/258] test(reference-host): define durable repository contract --- src/referenceHostSyntheticRepository.test.ts | 42 ++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/referenceHostSyntheticRepository.test.ts diff --git a/src/referenceHostSyntheticRepository.test.ts b/src/referenceHostSyntheticRepository.test.ts new file mode 100644 index 00000000..fa3b1b72 --- /dev/null +++ b/src/referenceHostSyntheticRepository.test.ts @@ -0,0 +1,42 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const fixturePath = resolve( + process.cwd(), + 'examples/reference-host/synthetic-document-repository.mjs', +); + +describe('reference-host synthetic durable repository contract', () => { + it('ships one executable reference-only repository fixture outside the published runtime', () => { + expect(existsSync(fixturePath)).toBe(true); + }); + + it('keeps the host persistence fixture source-independent and network-free', () => { + if (!existsSync(fixturePath)) return; + const source = readFileSync(fixturePath, 'utf8'); + + expect(source).not.toMatch(/(?:from|import\()\s*['"][^'"]*src\//u); + expect(source).not.toMatch(/\b(?:fetch|XMLHttpRequest|WebSocket)\b/u); + expect(source).toContain('REFERENCE_ONLY'); + expect(source).toContain('If-Match'); + }); + + it('proves ambiguous failure cannot advance the validator and stale writes conflict', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync(process.execPath, [fixturePath, '--self-test'], { + encoding: 'utf8', + }); + + expect(JSON.parse(output)).toEqual({ + afterAmbiguousValidator: '"v1"', + conflictCurrentValidator: '"v2"', + finalDocument: 'Buyer draft v2', + finalValidator: '"v2"', + initialValidator: '"v1"', + savedValidator: '"v2"', + }); + }); +}); From d0f948a7da4aded1e85b9e1c46404ac4cf26237c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 11:34:15 -0700 Subject: [PATCH 002/258] feat(reference-host): add synthetic strong-validator repository --- .../synthetic-document-repository.mjs | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 examples/reference-host/synthetic-document-repository.mjs diff --git a/examples/reference-host/synthetic-document-repository.mjs b/examples/reference-host/synthetic-document-repository.mjs new file mode 100644 index 00000000..f849e8e7 --- /dev/null +++ b/examples/reference-host/synthetic-document-repository.mjs @@ -0,0 +1,197 @@ +const MAX_DOCUMENT_ID_CODE_UNITS = 256; +const MAX_DOCUMENT_CODE_UNITS = 65_536; + +/** Marker used by repository contracts to prevent this fixture being mistaken for a production adapter. */ +export const REFERENCE_ONLY = true; + +/** Stable failure raised by the synthetic reference persistence adapter. */ +export class ReferencePersistenceError extends Error { + constructor(code) { + super(`Reference persistence ${code}.`); + this.name = 'ReferencePersistenceError'; + this.code = code; + Object.freeze(this); + } +} + +function requireBoundedString(value, maximumCodeUnits, code) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > maximumCodeUnits + ) { + throw new ReferencePersistenceError(code); + } + return value; +} + +function validatorForVersion(version) { + return `"v${version}"`; +} + +function frozenRead(document, validator) { + return Object.freeze({ document, validator }); +} + +function frozenSave(status, validator) { + return Object.freeze({ status, validator }); +} + +function frozenConflict(currentValidator) { + return Object.freeze({ status: 'conflict', currentValidator }); +} + +/** + * Create an in-memory host-owned reference repository with exact If-Match semantics. + * + * This adapter is synthetic acquisition/support evidence only. Buyers must replace + * it with an authorized atomic durable store. Ambiguous or failed operations never + * mutate the document or advance the strong validator. + */ +export function createSyntheticDocumentRepository(options) { + if ( + typeof options !== 'object' || + options === null || + Object.getPrototypeOf(options) !== Object.prototype + ) { + throw new ReferencePersistenceError('invalid_options'); + } + + const documentId = requireBoundedString( + options.documentId, + MAX_DOCUMENT_ID_CODE_UNITS, + 'invalid_document_id', + ); + let document = requireBoundedString( + options.initialDocument, + MAX_DOCUMENT_CODE_UNITS, + 'invalid_document', + ); + let version = 1; + let validator = validatorForVersion(version); + + function assertDocumentId(candidate) { + if (candidate !== documentId) { + throw new ReferencePersistenceError('document_not_found'); + } + } + + function read(candidateDocumentId) { + assertDocumentId(candidateDocumentId); + return frozenRead(document, validator); + } + + function save(request) { + if ( + typeof request !== 'object' || + request === null || + Object.getPrototypeOf(request) !== Object.prototype + ) { + throw new ReferencePersistenceError('invalid_request'); + } + + assertDocumentId(request.documentId); + const nextDocument = requireBoundedString( + request.document, + MAX_DOCUMENT_CODE_UNITS, + 'invalid_document', + ); + const ifMatch = requireBoundedString( + request.ifMatch, + 256, + 'invalid_if_match', + ); + const outcome = request.outcome ?? 'saved'; + if ( + outcome !== 'saved' && + outcome !== 'ambiguous_failure' && + outcome !== 'failure' + ) { + throw new ReferencePersistenceError('invalid_outcome'); + } + + if (outcome === 'ambiguous_failure') { + throw new ReferencePersistenceError('ambiguous_failure'); + } + if (outcome === 'failure') { + throw new ReferencePersistenceError('failure'); + } + if (ifMatch !== validator) { + return frozenConflict(validator); + } + + document = nextDocument; + version += 1; + validator = validatorForVersion(version); + return frozenSave('saved', validator); + } + + return Object.freeze({ read, save }); +} + +function runSelfTest() { + const repository = createSyntheticDocumentRepository({ + documentId: 'buyer-document', + initialDocument: 'Buyer draft v1', + }); + const initial = repository.read('buyer-document'); + + let ambiguousFailureObserved = false; + try { + repository.save({ + documentId: 'buyer-document', + document: 'Uncertain write', + ifMatch: initial.validator, + outcome: 'ambiguous_failure', + }); + } catch (error) { + ambiguousFailureObserved = + error instanceof ReferencePersistenceError && + error.code === 'ambiguous_failure'; + } + if (!ambiguousFailureObserved) { + throw new Error('Synthetic ambiguous-failure evidence was not observed.'); + } + + const afterAmbiguous = repository.read('buyer-document'); + if ( + afterAmbiguous.document !== initial.document || + afterAmbiguous.validator !== initial.validator + ) { + throw new Error('Ambiguous failure advanced synthetic durable state.'); + } + + const saved = repository.save({ + documentId: 'buyer-document', + document: 'Buyer draft v2', + ifMatch: initial.validator, + }); + if (saved.status !== 'saved') { + throw new Error('Synthetic save did not report success.'); + } + + const conflict = repository.save({ + documentId: 'buyer-document', + document: 'Stale overwrite', + ifMatch: initial.validator, + }); + if (conflict.status !== 'conflict') { + throw new Error('Synthetic stale If-Match write did not conflict.'); + } + + const finalState = repository.read('buyer-document'); + process.stdout.write( + `${JSON.stringify({ + afterAmbiguousValidator: afterAmbiguous.validator, + conflictCurrentValidator: conflict.currentValidator, + finalDocument: finalState.document, + finalValidator: finalState.validator, + initialValidator: initial.validator, + savedValidator: saved.validator, + })}\n`, + ); +} + +if (process.argv.includes('--self-test')) { + runSelfTest(); +} From a37cd8cd21ba0a569f85c666555689a44d2c0026 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 11:34:46 -0700 Subject: [PATCH 003/258] test(reference-host): define delayed proposal revision gate --- src/referenceHostDelayedProposal.test.ts | 40 ++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/referenceHostDelayedProposal.test.ts diff --git a/src/referenceHostDelayedProposal.test.ts b/src/referenceHostDelayedProposal.test.ts new file mode 100644 index 00000000..7580cf81 --- /dev/null +++ b/src/referenceHostDelayedProposal.test.ts @@ -0,0 +1,40 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const fixturePath = resolve( + process.cwd(), + 'examples/reference-host/delayed-proposal.mjs', +); + +describe('reference-host delayed proposal contract', () => { + it('ships one deterministic local proposal fixture outside the published runtime', () => { + expect(existsSync(fixturePath)).toBe(true); + }); + + it('keeps proposal generation provider-free and network-free', () => { + if (!existsSync(fixturePath)) return; + const source = readFileSync(fixturePath, 'utf8'); + + expect(source).not.toMatch(/(?:from|import\()\s*['"][^'"]*src\//u); + expect(source).not.toMatch(/\b(?:fetch|XMLHttpRequest|WebSocket)\b/u); + expect(source).toContain('REFERENCE_ONLY'); + expect(source).toContain('expectedRevision'); + }); + + it('conflicts stale delayed proposals instead of overwriting newer content', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync(process.execPath, [fixturePath, '--self-test'], { + encoding: 'utf8', + }); + + expect(JSON.parse(output)).toEqual({ + acceptedDocument: 'Accepted proposal', + acceptedStatus: 'applied', + staleDocument: 'User typed newer text', + staleStatus: 'conflict', + }); + }); +}); From 9333fd2fbeec7a3f7e35a727d0125fb4aa1da473 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 11:35:07 -0700 Subject: [PATCH 004/258] feat(reference-host): add revision-bound delayed proposal fixture --- examples/reference-host/delayed-proposal.mjs | 124 +++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 examples/reference-host/delayed-proposal.mjs diff --git a/examples/reference-host/delayed-proposal.mjs b/examples/reference-host/delayed-proposal.mjs new file mode 100644 index 00000000..64bb47cd --- /dev/null +++ b/examples/reference-host/delayed-proposal.mjs @@ -0,0 +1,124 @@ +const MAX_REVISION_CODE_UNITS = 256; +const MAX_PROPOSAL_CODE_UNITS = 65_536; + +/** Marker used by repository contracts to prevent this fixture being mistaken for a production model adapter. */ +export const REFERENCE_ONLY = true; + +function requireBoundedString(value, maximumCodeUnits, label) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > maximumCodeUnits + ) { + throw new TypeError(`${label} is invalid.`); + } + return value; +} + +/** + * Produce one deterministic asynchronous proposal bound to the revision captured by the host. + * + * This fixture deliberately contains no provider SDK, credential, prompt log, or remote call. + * Real hosts replace proposal generation with an approved model boundary while preserving the + * expectedRevision conflict gate before applying untrusted proposal data. + */ +export async function createDelayedProposal({ expectedRevision, replacement }) { + const boundedRevision = requireBoundedString( + expectedRevision, + MAX_REVISION_CODE_UNITS, + 'expectedRevision', + ); + const boundedReplacement = requireBoundedString( + replacement, + MAX_PROPOSAL_CODE_UNITS, + 'replacement', + ); + + await Promise.resolve(); + return Object.freeze({ + expectedRevision: boundedRevision, + replacement: boundedReplacement, + }); +} + +/** + * Apply one untrusted proposal only when the host's current revision still matches its capture. + */ +export function applyDelayedProposal({ proposal, currentRevision, apply }) { + if ( + typeof proposal !== 'object' || + proposal === null || + typeof apply !== 'function' + ) { + throw new TypeError('proposal application is invalid.'); + } + const boundedCurrentRevision = requireBoundedString( + currentRevision, + MAX_REVISION_CODE_UNITS, + 'currentRevision', + ); + const expectedRevision = requireBoundedString( + proposal.expectedRevision, + MAX_REVISION_CODE_UNITS, + 'expectedRevision', + ); + const replacement = requireBoundedString( + proposal.replacement, + MAX_PROPOSAL_CODE_UNITS, + 'replacement', + ); + + if (expectedRevision !== boundedCurrentRevision) { + return Object.freeze({ status: 'conflict' }); + } + + apply(replacement); + return Object.freeze({ status: 'applied' }); +} + +async function runSelfTest() { + let staleDocument = 'Original draft'; + let staleRevision = 'revision-v1'; + const staleProposalPromise = createDelayedProposal({ + expectedRevision: staleRevision, + replacement: 'Stale proposal', + }); + + staleDocument = 'User typed newer text'; + staleRevision = 'revision-v2'; + const staleProposal = await staleProposalPromise; + const staleResult = applyDelayedProposal({ + proposal: staleProposal, + currentRevision: staleRevision, + apply(replacement) { + staleDocument = replacement; + }, + }); + + let acceptedDocument = 'Current draft'; + const acceptedRevision = 'revision-v3'; + const acceptedProposal = await createDelayedProposal({ + expectedRevision: acceptedRevision, + replacement: 'Accepted proposal', + }); + const acceptedResult = applyDelayedProposal({ + proposal: acceptedProposal, + currentRevision: acceptedRevision, + apply(replacement) { + acceptedDocument = replacement; + }, + }); + + process.stdout.write( + `${JSON.stringify({ + acceptedDocument, + acceptedStatus: acceptedResult.status, + staleDocument, + staleStatus: staleResult.status, + })}\n`, + ); +} + +if (process.argv.includes('--self-test')) { + await runSelfTest(); +} From e85f636166cf6452582710b2bf0af3732439de47 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 11:35:51 -0700 Subject: [PATCH 005/258] docs(reference-host): add ownership and replacement guide --- examples/reference-host/README.md | 57 +++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 examples/reference-host/README.md diff --git a/examples/reference-host/README.md b/examples/reference-host/README.md new file mode 100644 index 00000000..7f049fea --- /dev/null +++ b/examples/reference-host/README.md @@ -0,0 +1,57 @@ +# Inkspan reference host + +Status: Active PR / partial reference-host implementation + +This directory is buyer-facing integration evidence for issue #377. It is intentionally **host code**, not a new Inkspan runtime surface. Protected `main` remains the shipped product authority, and this example is not production-ready until its remaining SSR/package/collaboration/accessibility/Office acceptance work is implemented and the release boundary permits integration. + +The current slice contains two executable, deterministic fixtures: + +- `synthetic-document-repository.mjs` demonstrates host-owned strong-validator / `If-Match` persistence behavior. Ambiguous and failed writes do not mutate durable state or advance the validator; a stale validator returns a conflict. +- `delayed-proposal.mjs` demonstrates a provider-free delayed proposal captured against one expected revision. If the current revision changes before application, the proposal returns a conflict instead of overwriting newer content. + +Both fixtures are marked `REFERENCE_ONLY`, require no service, database, credential, provider SDK, or network connection, and are exercised by repository tests. They are deliberately outside the package `files` inventory so example host logic cannot silently become published Inkspan runtime authority. + +## Copy this, replace that + +| Reference element | Buyer action | +| --- | --- | +| synthetic document repository | Replace with an authorized atomic durable store that enforces the host's RFC 9110 `If-Match` policy and returns a new strong validator only after confirmed success. | +| deterministic delayed proposal | Replace proposal generation with a host-approved model gateway and data-use policy while preserving exact-revision conflict checks before applying untrusted proposal data. | +| synthetic document and revision identifiers | Replace with authenticated/authorized host context; never infer tenant or actor authority from an Inkspan digest, form value, or example identifier. | +| reference error handling | Map stable machine outcomes to localized host UX and audited host operations without copying document bodies, prompts, credentials, or private causes into generic telemetry. | + +## Ownership map + +```mermaid +flowchart LR + User[Author / reviewer] --> Host[Embedding host] + Host --> Inkspan[Inkspan editor + deterministic evidence] + Host --> Repo[Host document repository] + Host --> Provider[Host collaboration provider] + Host --> Model[Host-approved model gateway] + Inkspan --> Proposal[Untrusted proposal data] + Proposal --> Host + Repo -->|strong validator / conflict| Host + + classDef host stroke-width:2px; + class Host,Repo,Provider,Model host; +``` + +Inkspan owns deterministic editor/revision/autosave/conversion/package behavior. The host owns authenticated transport, authorization, tenancy, durable persistence, collaboration-provider lifecycle, credentials, model policy, retention, deployment, and durable audit. A successful local editor operation, Yjs update, model response, or status check is not durable authorization or persistence evidence. + +## Executable fixture checks + +From a clean repository checkout with the supported Node runtime, these reference-only fixtures can be exercised directly: + +```sh +node examples/reference-host/synthetic-document-repository.mjs --self-test +node examples/reference-host/delayed-proposal.mjs --self-test +``` + +The root test suite independently invokes those commands and asserts the expected conflict/no-silent-advancement behavior. These commands do **not** yet satisfy #377's complete packed-tarball application acceptance. + +## Deliberate omissions in this partial slice + +Still required before #377 can close: a packed-artifact application (preferably a supported Next.js App Router host), deterministic SSR/hydration proof, native form journeys, Inkspan autosave-state UI composition, host-created Yjs lifecycle/reconnect evidence, package CSS and both font options, real Chromium/Firefox/WebKit acceptance, read-only and forced-colors/print/narrow-viewport journeys, converter/Office handoff, and one documented clean-checkout command that builds the tarball before installing it into the example. + +Do not use the synthetic repository, synthetic identifiers, or deterministic proposal fixture as a production persistence, authentication, collaboration, or model implementation. From c00f0803476a0426b6f527a740e34083393b76d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 11:36:26 -0700 Subject: [PATCH 006/258] test(reference-host): enforce package ownership boundary --- src/referenceHostPackagingBoundary.test.ts | 35 ++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/referenceHostPackagingBoundary.test.ts diff --git a/src/referenceHostPackagingBoundary.test.ts b/src/referenceHostPackagingBoundary.test.ts new file mode 100644 index 00000000..acc494e9 --- /dev/null +++ b/src/referenceHostPackagingBoundary.test.ts @@ -0,0 +1,35 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const referenceHostDirectory = resolve(process.cwd(), 'examples/reference-host'); + +function repositoryFile(path: string): string { + return readFileSync(resolve(process.cwd(), path), 'utf8'); +} + +describe('reference-host package authority boundary', () => { + it('keeps every reference-host file outside the npm publish inventory', () => { + const packageMetadata = JSON.parse(repositoryFile('package.json')) as { + files: string[]; + }; + + expect(packageMetadata.files.some((path) => path.startsWith('examples'))).toBe( + false, + ); + }); + + it('rejects source-relative and workspace-alias imports in executable reference files', () => { + const executableFiles = readdirSync(referenceHostDirectory).filter((path) => + path.endsWith('.mjs'), + ); + + expect(executableFiles.length).toBeGreaterThan(0); + for (const file of executableFiles) { + const source = readFileSync(resolve(referenceHostDirectory, file), 'utf8'); + expect(source).not.toMatch(/(?:from|import\()\s*['"][^'"]*src\//u); + expect(source).not.toContain('workspace:'); + } + }); +}); From 8cca314c0b118912ea99a671c6103be1a9ed50b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:35:52 -0700 Subject: [PATCH 007/258] test(reference-host): define autosave lifecycle presentation contract --- src/referenceHostAutosaveViewModel.test.ts | 47 ++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/referenceHostAutosaveViewModel.test.ts diff --git a/src/referenceHostAutosaveViewModel.test.ts b/src/referenceHostAutosaveViewModel.test.ts new file mode 100644 index 00000000..d9170a01 --- /dev/null +++ b/src/referenceHostAutosaveViewModel.test.ts @@ -0,0 +1,47 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const fixturePath = resolve( + process.cwd(), + 'examples/reference-host/autosave-view-model.mjs', +); + +describe('reference-host autosave presentation contract', () => { + it('ships one host-owned autosave view-model fixture outside the published runtime', () => { + expect(existsSync(fixturePath)).toBe(true); + }); + + it('maps programmatic lifecycle state to localization keys without exposing validators', () => { + if (!existsSync(fixturePath)) return; + const source = readFileSync(fixturePath, 'utf8'); + + expect(source).not.toMatch(/(?:from|import\()\s*['"][^'"]*src\//u); + expect(source).not.toMatch(/\b(?:fetch|XMLHttpRequest|WebSocket)\b/u); + expect(source).toContain('REFERENCE_ONLY'); + expect(source).toContain('messageKey'); + expect(source).toContain('blockedReason'); + expect(source).not.toContain('document body'); + }); + + it('derives clean, saving, queued, conflict, failed, retrying, recovered, closing, and closed states', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync(process.execPath, [fixturePath, '--self-test'], { + encoding: 'utf8', + }); + + expect(JSON.parse(output)).toEqual({ + clean: 'clean', + closed: 'closed', + closing: 'closing', + conflict: 'conflict', + failed: 'failed', + queued: 'queued', + recovered: 'recovered', + retrying: 'retrying', + saving: 'saving', + }); + }); +}); From 530a79d089be5f4c400fa94c9479a020cf7fe611 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:36:30 -0700 Subject: [PATCH 008/258] feat(reference-host): compose autosave lifecycle presentation --- .../reference-host/autosave-view-model.mjs | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 examples/reference-host/autosave-view-model.mjs diff --git a/examples/reference-host/autosave-view-model.mjs b/examples/reference-host/autosave-view-model.mjs new file mode 100644 index 00000000..2a10b1cc --- /dev/null +++ b/examples/reference-host/autosave-view-model.mjs @@ -0,0 +1,167 @@ +const AUTOSAVE_STATES = new Set([ + 'idle', + 'saving', + 'blocked', + 'closing', + 'closed', +]); +const BLOCKED_REASONS = new Set(['conflict', 'failure']); +const RECOVERY_PHASES = new Set(['none', 'retrying', 'recovered']); + +/** Marker used by repository contracts to prevent this host fixture becoming runtime authority. */ +export const REFERENCE_ONLY = true; + +function requireNullableString(value, label) { + if (value !== null && typeof value !== 'string') { + throw new TypeError(`${label} is invalid.`); + } + return value; +} + +function readSnapshot(snapshot) { + if ( + typeof snapshot !== 'object' || + snapshot === null || + !AUTOSAVE_STATES.has(snapshot.state) || + (snapshot.blockedReason !== null && + !BLOCKED_REASONS.has(snapshot.blockedReason)) + ) { + throw new TypeError('autosave snapshot is invalid.'); + } + + if ( + (snapshot.state === 'blocked' && snapshot.blockedReason === null) || + (snapshot.state !== 'blocked' && snapshot.blockedReason !== null) + ) { + throw new TypeError('autosave snapshot lifecycle is inconsistent.'); + } + + return Object.freeze({ + state: snapshot.state, + blockedReason: snapshot.blockedReason, + activeStrongEntityTag: requireNullableString( + snapshot.activeStrongEntityTag, + 'activeStrongEntityTag', + ), + pendingStrongEntityTag: requireNullableString( + snapshot.pendingStrongEntityTag, + 'pendingStrongEntityTag', + ), + lastSavedStrongEntityTag: requireNullableString( + snapshot.lastSavedStrongEntityTag, + 'lastSavedStrongEntityTag', + ), + }); +} + +function readRecoveryPhase(recoveryPhase) { + if (!RECOVERY_PHASES.has(recoveryPhase)) { + throw new TypeError('recoveryPhase is invalid.'); + } + return recoveryPhase; +} + +function presentation(viewState) { + return Object.freeze({ + viewState, + messageKey: `referenceHost.autosave.${viewState}`, + busy: viewState === 'saving' || viewState === 'queued' || viewState === 'retrying', + canRetry: viewState === 'conflict' || viewState === 'failed', + }); +} + +/** + * Convert one Inkspan autosave lifecycle snapshot into host-owned presentation metadata. + * + * The result intentionally excludes local and durable validators. Hosts localize + * `messageKey` and keep authenticated recovery controls outside Inkspan. + */ +export function createAutosaveViewModel({ snapshot, recoveryPhase = 'none' }) { + const current = readSnapshot(snapshot); + const phase = readRecoveryPhase(recoveryPhase); + + if (current.state === 'blocked') { + return presentation( + current.blockedReason === 'conflict' ? 'conflict' : 'failed', + ); + } + if (current.state === 'closing') return presentation('closing'); + if (current.state === 'closed') return presentation('closed'); + if (current.state === 'saving' && phase === 'retrying') { + return presentation('retrying'); + } + if (current.state === 'saving' && current.pendingStrongEntityTag !== null) { + return presentation('queued'); + } + if (current.state === 'saving') return presentation('saving'); + if (phase === 'recovered') return presentation('recovered'); + return presentation('clean'); +} + +function snapshot({ + state, + blockedReason = null, + activeStrongEntityTag = null, + pendingStrongEntityTag = null, + lastSavedStrongEntityTag = null, +}) { + return Object.freeze({ + state, + blockedReason, + activeStrongEntityTag, + pendingStrongEntityTag, + lastSavedStrongEntityTag, + }); +} + +function runSelfTest() { + const states = { + clean: createAutosaveViewModel({ + snapshot: snapshot({ state: 'idle' }), + }).viewState, + saving: createAutosaveViewModel({ + snapshot: snapshot({ + state: 'saving', + activeStrongEntityTag: '"local-active"', + }), + }).viewState, + queued: createAutosaveViewModel({ + snapshot: snapshot({ + state: 'saving', + activeStrongEntityTag: '"local-active"', + pendingStrongEntityTag: '"local-pending"', + }), + }).viewState, + conflict: createAutosaveViewModel({ + snapshot: snapshot({ state: 'blocked', blockedReason: 'conflict' }), + }).viewState, + failed: createAutosaveViewModel({ + snapshot: snapshot({ state: 'blocked', blockedReason: 'failure' }), + }).viewState, + retrying: createAutosaveViewModel({ + snapshot: snapshot({ + state: 'saving', + activeStrongEntityTag: '"local-retry"', + }), + recoveryPhase: 'retrying', + }).viewState, + recovered: createAutosaveViewModel({ + snapshot: snapshot({ + state: 'idle', + lastSavedStrongEntityTag: '"local-saved"', + }), + recoveryPhase: 'recovered', + }).viewState, + closing: createAutosaveViewModel({ + snapshot: snapshot({ state: 'closing' }), + }).viewState, + closed: createAutosaveViewModel({ + snapshot: snapshot({ state: 'closed' }), + }).viewState, + }; + process.stdout.write(`${JSON.stringify(states)}\n`); +} + +if (process.argv.includes('--self-test')) { + runSelfTest(); +} From f4dd07faf8fe8bc8a6e1426b164a5498379a7c8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:37:12 -0700 Subject: [PATCH 009/258] test(reference-host): define collaboration lifecycle ownership contract --- ...eferenceHostCollaborationLifecycle.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/referenceHostCollaborationLifecycle.test.ts diff --git a/src/referenceHostCollaborationLifecycle.test.ts b/src/referenceHostCollaborationLifecycle.test.ts new file mode 100644 index 00000000..dcb21af2 --- /dev/null +++ b/src/referenceHostCollaborationLifecycle.test.ts @@ -0,0 +1,52 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const fixturePath = resolve( + process.cwd(), + 'examples/reference-host/collaboration-provider-lifecycle.mjs', +); + +describe('reference-host collaboration lifecycle contract', () => { + it('ships one host-owned collaboration lifecycle fixture outside the published runtime', () => { + expect(existsSync(fixturePath)).toBe(true); + }); + + it('keeps provider creation and teardown host-owned and provider-neutral', () => { + if (!existsSync(fixturePath)) return; + const source = readFileSync(fixturePath, 'utf8'); + + expect(source).not.toMatch(/(?:from|import\()\s*['"][^'"]*src\//u); + expect(source).not.toMatch(/\b(?:fetch|XMLHttpRequest|WebSocket|process\.env)\b/u); + expect(source).toContain('REFERENCE_ONLY'); + expect(source).toContain('providerFactory'); + expect(source).toContain('reconnect'); + expect(source).toContain('dispose'); + }); + + it('disconnects and destroys replaced providers and tears down the host document exactly once', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync(process.execPath, [fixturePath, '--self-test'], { + encoding: 'utf8', + }); + + expect(JSON.parse(output)).toEqual({ + events: [ + 'document:create', + 'provider:create:1', + 'provider:connect:1', + 'provider:disconnect:1', + 'provider:destroy:1', + 'provider:create:2', + 'provider:connect:2', + 'provider:disconnect:2', + 'provider:destroy:2', + 'document:destroy', + ], + providerGeneration: 2, + status: 'disposed', + }); + }); +}); From 8f8a55ed8bdaa302531650e7a2f8bace1af3a675 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:37:40 -0700 Subject: [PATCH 010/258] feat(reference-host): demonstrate host collaboration lifecycle --- .../collaboration-provider-lifecycle.mjs | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 examples/reference-host/collaboration-provider-lifecycle.mjs diff --git a/examples/reference-host/collaboration-provider-lifecycle.mjs b/examples/reference-host/collaboration-provider-lifecycle.mjs new file mode 100644 index 00000000..128a6233 --- /dev/null +++ b/examples/reference-host/collaboration-provider-lifecycle.mjs @@ -0,0 +1,187 @@ +const MAX_CONTEXT_CODE_UNITS = 256; + +/** Marker used by repository contracts to keep this lifecycle example out of runtime authority. */ +export const REFERENCE_ONLY = true; + +function requireContextString(value, label) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > MAX_CONTEXT_CODE_UNITS + ) { + throw new TypeError(`${label} is invalid.`); + } + return value; +} + +function requireFactory(value, label) { + if (typeof value !== 'function') { + throw new TypeError(`${label} is invalid.`); + } + return value; +} + +function requireDocument(value) { + if ( + typeof value !== 'object' || + value === null || + typeof value.destroy !== 'function' + ) { + throw new TypeError('documentFactory returned an invalid document.'); + } + return value; +} + +function requireProvider(value) { + if ( + typeof value !== 'object' || + value === null || + typeof value.connect !== 'function' || + typeof value.disconnect !== 'function' || + typeof value.destroy !== 'function' + ) { + throw new TypeError('providerFactory returned an invalid provider.'); + } + return value; +} + +/** + * Create one provider-neutral collaboration lifecycle owned entirely by the host. + * + * The host supplies the document/provider factories and authorized room/actor + * context. Inkspan receives the resulting stable document/provider references; + * it does not create, reconnect, disconnect, or destroy either resource. + */ +export function createHostCollaborationLifecycle({ + documentFactory, + providerFactory, + roomId, + actorId, +}) { + const createDocument = requireFactory(documentFactory, 'documentFactory'); + const createProvider = requireFactory(providerFactory, 'providerFactory'); + const boundedRoomId = requireContextString(roomId, 'roomId'); + const boundedActorId = requireContextString(actorId, 'actorId'); + const document = requireDocument(createDocument()); + + let providerGeneration = 0; + let provider = null; + let connected = false; + let disposed = false; + + function makeProvider() { + providerGeneration += 1; + provider = requireProvider( + createProvider({ + document, + roomId: boundedRoomId, + actorId: boundedActorId, + generation: providerGeneration, + }), + ); + connected = false; + } + + function requireLive() { + if (disposed) { + throw new Error('collaboration lifecycle is disposed.'); + } + } + + function connect() { + requireLive(); + if (connected) return false; + provider.connect(); + connected = true; + return true; + } + + function teardownProvider() { + if (provider === null) return; + if (connected) { + provider.disconnect(); + connected = false; + } + provider.destroy(); + provider = null; + } + + function reconnect() { + requireLive(); + teardownProvider(); + makeProvider(); + connect(); + return getSnapshot(); + } + + function dispose() { + if (disposed) return false; + teardownProvider(); + document.destroy(); + disposed = true; + return true; + } + + function getSnapshot() { + return Object.freeze({ + status: disposed ? 'disposed' : connected ? 'connected' : 'disconnected', + providerGeneration, + }); + } + + makeProvider(); + + return Object.freeze({ + document, + connect, + reconnect, + dispose, + getSnapshot, + }); +} + +function runSelfTest() { + const events = []; + let providerCounter = 0; + const lifecycle = createHostCollaborationLifecycle({ + documentFactory() { + events.push('document:create'); + return { + destroy() { + events.push('document:destroy'); + }, + }; + }, + providerFactory() { + providerCounter += 1; + const generation = providerCounter; + events.push(`provider:create:${generation}`); + return { + connect() { + events.push(`provider:connect:${generation}`); + }, + disconnect() { + events.push(`provider:disconnect:${generation}`); + }, + destroy() { + events.push(`provider:destroy:${generation}`); + }, + }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + + lifecycle.connect(); + lifecycle.reconnect(); + lifecycle.dispose(); + lifecycle.dispose(); + + process.stdout.write( + `${JSON.stringify({ events, ...lifecycle.getSnapshot() })}\n`, + ); +} + +if (process.argv.includes('--self-test')) { + runSelfTest(); +} From 25c847da5a06a1688a1d9bb6101cdbd44fe13c63 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:38:41 -0700 Subject: [PATCH 011/258] test(reference-host): derive recovery UX from Inkspan transitions --- src/referenceHostAutosaveViewModel.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/referenceHostAutosaveViewModel.test.ts b/src/referenceHostAutosaveViewModel.test.ts index d9170a01..a7edb47c 100644 --- a/src/referenceHostAutosaveViewModel.test.ts +++ b/src/referenceHostAutosaveViewModel.test.ts @@ -14,7 +14,7 @@ describe('reference-host autosave presentation contract', () => { expect(existsSync(fixturePath)).toBe(true); }); - it('maps programmatic lifecycle state to localization keys without exposing validators', () => { + it('maps programmatic lifecycle transitions to localization keys without exposing validators', () => { if (!existsSync(fixturePath)) return; const source = readFileSync(fixturePath, 'utf8'); @@ -23,6 +23,8 @@ describe('reference-host autosave presentation contract', () => { expect(source).toContain('REFERENCE_ONLY'); expect(source).toContain('messageKey'); expect(source).toContain('blockedReason'); + expect(source).toContain('observe'); + expect(source).not.toContain('recoveryPhase'); expect(source).not.toContain('document body'); }); From d68bee4b5a4691522d84ba3901241626f40a6d62 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:39:10 -0700 Subject: [PATCH 012/258] fix(reference-host): derive autosave recovery from snapshots --- .../reference-host/autosave-view-model.mjs | 177 ++++++++++-------- 1 file changed, 101 insertions(+), 76 deletions(-) diff --git a/examples/reference-host/autosave-view-model.mjs b/examples/reference-host/autosave-view-model.mjs index 2a10b1cc..432fb1a5 100644 --- a/examples/reference-host/autosave-view-model.mjs +++ b/examples/reference-host/autosave-view-model.mjs @@ -6,7 +6,6 @@ const AUTOSAVE_STATES = new Set([ 'closed', ]); const BLOCKED_REASONS = new Set(['conflict', 'failure']); -const RECOVERY_PHASES = new Set(['none', 'retrying', 'recovered']); /** Marker used by repository contracts to prevent this host fixture becoming runtime authority. */ export const REFERENCE_ONLY = true; @@ -54,48 +53,63 @@ function readSnapshot(snapshot) { }); } -function readRecoveryPhase(recoveryPhase) { - if (!RECOVERY_PHASES.has(recoveryPhase)) { - throw new TypeError('recoveryPhase is invalid.'); - } - return recoveryPhase; -} - function presentation(viewState) { return Object.freeze({ viewState, messageKey: `referenceHost.autosave.${viewState}`, - busy: viewState === 'saving' || viewState === 'queued' || viewState === 'retrying', + busy: + viewState === 'saving' || + viewState === 'queued' || + viewState === 'retrying', + blocked: viewState === 'conflict' || viewState === 'failed', canRetry: viewState === 'conflict' || viewState === 'failed', }); } /** - * Convert one Inkspan autosave lifecycle snapshot into host-owned presentation metadata. + * Create one host-owned projection of Inkspan autosave lifecycle transitions. * - * The result intentionally excludes local and durable validators. Hosts localize - * `messageKey` and keep authenticated recovery controls outside Inkspan. + * `observe()` consumes only programmatic queue/session snapshots. A blocked to + * saving transition is presented as retrying, and its next idle transition is + * presented as recovered. The projection never returns local or durable + * validators; hosts localize `messageKey` and keep authenticated recovery + * controls outside Inkspan. */ -export function createAutosaveViewModel({ snapshot, recoveryPhase = 'none' }) { - const current = readSnapshot(snapshot); - const phase = readRecoveryPhase(recoveryPhase); - - if (current.state === 'blocked') { - return presentation( - current.blockedReason === 'conflict' ? 'conflict' : 'failed', - ); - } - if (current.state === 'closing') return presentation('closing'); - if (current.state === 'closed') return presentation('closed'); - if (current.state === 'saving' && phase === 'retrying') { - return presentation('retrying'); - } - if (current.state === 'saving' && current.pendingStrongEntityTag !== null) { - return presentation('queued'); +export function createAutosaveViewModel() { + let recovering = false; + + function observe(snapshot) { + const current = readSnapshot(snapshot); + + if (current.state === 'blocked') { + recovering = true; + return presentation( + current.blockedReason === 'conflict' ? 'conflict' : 'failed', + ); + } + if (current.state === 'closing') { + recovering = false; + return presentation('closing'); + } + if (current.state === 'closed') { + recovering = false; + return presentation('closed'); + } + if (current.state === 'saving' && recovering) { + return presentation('retrying'); + } + if (current.state === 'saving' && current.pendingStrongEntityTag !== null) { + return presentation('queued'); + } + if (current.state === 'saving') return presentation('saving'); + if (recovering) { + recovering = false; + return presentation('recovered'); + } + return presentation('clean'); } - if (current.state === 'saving') return presentation('saving'); - if (phase === 'recovered') return presentation('recovered'); - return presentation('clean'); + + return Object.freeze({ observe }); } function snapshot({ @@ -115,51 +129,62 @@ function snapshot({ } function runSelfTest() { - const states = { - clean: createAutosaveViewModel({ - snapshot: snapshot({ state: 'idle' }), - }).viewState, - saving: createAutosaveViewModel({ - snapshot: snapshot({ - state: 'saving', - activeStrongEntityTag: '"local-active"', - }), - }).viewState, - queued: createAutosaveViewModel({ - snapshot: snapshot({ - state: 'saving', - activeStrongEntityTag: '"local-active"', - pendingStrongEntityTag: '"local-pending"', - }), - }).viewState, - conflict: createAutosaveViewModel({ - snapshot: snapshot({ state: 'blocked', blockedReason: 'conflict' }), - }).viewState, - failed: createAutosaveViewModel({ - snapshot: snapshot({ state: 'blocked', blockedReason: 'failure' }), - }).viewState, - retrying: createAutosaveViewModel({ - snapshot: snapshot({ - state: 'saving', - activeStrongEntityTag: '"local-retry"', - }), - recoveryPhase: 'retrying', - }).viewState, - recovered: createAutosaveViewModel({ - snapshot: snapshot({ - state: 'idle', - lastSavedStrongEntityTag: '"local-saved"', - }), - recoveryPhase: 'recovered', - }).viewState, - closing: createAutosaveViewModel({ - snapshot: snapshot({ state: 'closing' }), - }).viewState, - closed: createAutosaveViewModel({ - snapshot: snapshot({ state: 'closed' }), - }).viewState, - }; - process.stdout.write(`${JSON.stringify(states)}\n`); + const steady = createAutosaveViewModel(); + const clean = steady.observe(snapshot({ state: 'idle' })).viewState; + const saving = steady.observe( + snapshot({ + state: 'saving', + activeStrongEntityTag: '"local-active"', + }), + ).viewState; + const queued = steady.observe( + snapshot({ + state: 'saving', + activeStrongEntityTag: '"local-active"', + pendingStrongEntityTag: '"local-pending"', + }), + ).viewState; + + const recovery = createAutosaveViewModel(); + const conflict = recovery.observe( + snapshot({ state: 'blocked', blockedReason: 'conflict' }), + ).viewState; + const retrying = recovery.observe( + snapshot({ + state: 'saving', + activeStrongEntityTag: '"local-retry"', + }), + ).viewState; + const recovered = recovery.observe( + snapshot({ + state: 'idle', + lastSavedStrongEntityTag: '"local-saved"', + }), + ).viewState; + + const failed = createAutosaveViewModel().observe( + snapshot({ state: 'blocked', blockedReason: 'failure' }), + ).viewState; + const closing = createAutosaveViewModel().observe( + snapshot({ state: 'closing' }), + ).viewState; + const closed = createAutosaveViewModel().observe( + snapshot({ state: 'closed' }), + ).viewState; + + process.stdout.write( + `${JSON.stringify({ + clean, + closed, + closing, + conflict, + failed, + queued, + recovered, + retrying, + saving, + })}\n`, + ); } if (process.argv.includes('--self-test')) { From 508576ce8e8175b06ab9d82ac079a2354cc90d35 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:39:44 -0700 Subject: [PATCH 013/258] docs(reference-host): align guide with executable fixtures --- examples/reference-host/README.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/examples/reference-host/README.md b/examples/reference-host/README.md index 7f049fea..88baac85 100644 --- a/examples/reference-host/README.md +++ b/examples/reference-host/README.md @@ -4,12 +4,14 @@ Status: Active PR / partial reference-host implementation This directory is buyer-facing integration evidence for issue #377. It is intentionally **host code**, not a new Inkspan runtime surface. Protected `main` remains the shipped product authority, and this example is not production-ready until its remaining SSR/package/collaboration/accessibility/Office acceptance work is implemented and the release boundary permits integration. -The current slice contains two executable, deterministic fixtures: +The current slice contains four executable, deterministic fixtures: - `synthetic-document-repository.mjs` demonstrates host-owned strong-validator / `If-Match` persistence behavior. Ambiguous and failed writes do not mutate durable state or advance the validator; a stale validator returns a conflict. - `delayed-proposal.mjs` demonstrates a provider-free delayed proposal captured against one expected revision. If the current revision changes before application, the proposal returns a conflict instead of overwriting newer content. +- `autosave-view-model.mjs` projects Inkspan autosave lifecycle snapshots into host-localizable `clean`, `saving`, `queued`, `conflict`, `failed`, `retrying`, `recovered`, `closing`, and `closed` presentation states. Recovery presentation is derived from observed blocked → saving → idle transitions, and validators are never returned as UI data. +- `collaboration-provider-lifecycle.mjs` demonstrates that the embedding host creates, reconnects, disconnects, and destroys its collaboration provider and final document lifecycle. The deterministic fixture has no provider SDK or network transport and does not treat room or actor identifiers as authorization evidence. -Both fixtures are marked `REFERENCE_ONLY`, require no service, database, credential, provider SDK, or network connection, and are exercised by repository tests. They are deliberately outside the package `files` inventory so example host logic cannot silently become published Inkspan runtime authority. +All fixtures are marked `REFERENCE_ONLY`, require no service, database, credential, provider SDK, or network connection for their self-tests, and are exercised by repository tests. They are deliberately outside the package `files` inventory so example host logic cannot silently become published Inkspan runtime authority. ## Copy this, replace that @@ -17,6 +19,8 @@ Both fixtures are marked `REFERENCE_ONLY`, require no service, database, credent | --- | --- | | synthetic document repository | Replace with an authorized atomic durable store that enforces the host's RFC 9110 `If-Match` policy and returns a new strong validator only after confirmed success. | | deterministic delayed proposal | Replace proposal generation with a host-approved model gateway and data-use policy while preserving exact-revision conflict checks before applying untrusted proposal data. | +| autosave presentation projection | Wire the packed Inkspan autosave session observer into localized host UI and authenticated recovery actions; do not display revision or durable validators as user-facing status. | +| collaboration lifecycle fixture | Replace the deterministic provider factory with the host's authorized Yjs transport provider while preserving host-owned reconnect, teardown, credential, and room-authorization policy. | | synthetic document and revision identifiers | Replace with authenticated/authorized host context; never infer tenant or actor authority from an Inkspan digest, form value, or example identifier. | | reference error handling | Map stable machine outcomes to localized host UX and audited host operations without copying document bodies, prompts, credentials, or private causes into generic telemetry. | @@ -46,12 +50,14 @@ From a clean repository checkout with the supported Node runtime, these referenc ```sh node examples/reference-host/synthetic-document-repository.mjs --self-test node examples/reference-host/delayed-proposal.mjs --self-test +node examples/reference-host/autosave-view-model.mjs --self-test +node examples/reference-host/collaboration-provider-lifecycle.mjs --self-test ``` -The root test suite independently invokes those commands and asserts the expected conflict/no-silent-advancement behavior. These commands do **not** yet satisfy #377's complete packed-tarball application acceptance. +The root test suite independently invokes those commands and asserts the expected conflict, lifecycle, recovery, and teardown behavior. These commands do **not** yet satisfy #377's complete packed-tarball application acceptance. ## Deliberate omissions in this partial slice -Still required before #377 can close: a packed-artifact application (preferably a supported Next.js App Router host), deterministic SSR/hydration proof, native form journeys, Inkspan autosave-state UI composition, host-created Yjs lifecycle/reconnect evidence, package CSS and both font options, real Chromium/Firefox/WebKit acceptance, read-only and forced-colors/print/narrow-viewport journeys, converter/Office handoff, and one documented clean-checkout command that builds the tarball before installing it into the example. +Still required before #377 can close: a packed-artifact application (preferably a supported Next.js App Router host), deterministic SSR/hydration proof, native form journeys, packed-package wiring of the autosave observer, a real host-created `Y.Doc` plus provider lifecycle/reconnect journey, package CSS and both font options, real Chromium/Firefox/WebKit acceptance, read-only and forced-colors/print/narrow-viewport journeys, converter/Office handoff, and one documented clean-checkout command that builds the tarball before installing it into the example. -Do not use the synthetic repository, synthetic identifiers, or deterministic proposal fixture as a production persistence, authentication, collaboration, or model implementation. +Do not use the synthetic repository, synthetic identifiers, deterministic proposal fixture, presentation projection, or collaboration lifecycle fixture as a production persistence, authentication, collaboration, or model implementation. From 675c89765b95b1327e73a0e1bdd80fd75832ca36 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:03:04 -0700 Subject: [PATCH 014/258] test(reference-host): reject persistence accessors --- src/referenceHostSyntheticRepository.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/referenceHostSyntheticRepository.test.ts b/src/referenceHostSyntheticRepository.test.ts index fa3b1b72..5a7bce33 100644 --- a/src/referenceHostSyntheticRepository.test.ts +++ b/src/referenceHostSyntheticRepository.test.ts @@ -39,4 +39,20 @@ describe('reference-host synthetic durable repository contract', () => { savedValidator: '"v2"', }); }); + + it('fails closed without invoking caller-owned option or save-request accessors', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync( + process.execPath, + [fixturePath, '--hostile-accessor-self-test'], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + optionErrorCode: 'invalid_options', + optionGetterCalls: 0, + requestErrorCode: 'invalid_request', + requestGetterCalls: 0, + }); + }); }); From 733937d0b6821ac21c7b69ae3d644ab8549158a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:03:48 -0700 Subject: [PATCH 015/258] fix(reference-host): contain persistence accessors --- .../synthetic-document-repository.mjs | 138 +++++++++++++++--- 1 file changed, 115 insertions(+), 23 deletions(-) diff --git a/examples/reference-host/synthetic-document-repository.mjs b/examples/reference-host/synthetic-document-repository.mjs index f849e8e7..28c4f9b5 100644 --- a/examples/reference-host/synthetic-document-repository.mjs +++ b/examples/reference-host/synthetic-document-repository.mjs @@ -25,6 +25,44 @@ function requireBoundedString(value, maximumCodeUnits, code) { return value; } +function readPlainDataRecord(source, requiredKeys, optionalKeys, code) { + try { + if ( + typeof source !== 'object' || + source === null || + Object.getPrototypeOf(source) !== Object.prototype + ) { + throw new ReferencePersistenceError(code); + } + + const values = Object.create(null); + for (const key of requiredKeys) { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if ( + descriptor === undefined || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new ReferencePersistenceError(code); + } + values[key] = descriptor.value; + } + for (const key of optionalKeys) { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if (descriptor === undefined) continue; + if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new ReferencePersistenceError(code); + } + values[key] = descriptor.value; + } + return values; + } catch (error) { + if (error instanceof ReferencePersistenceError && error.code === code) { + throw error; + } + throw new ReferencePersistenceError(code); + } +} + function validatorForVersion(version) { return `"v${version}"`; } @@ -46,24 +84,24 @@ function frozenConflict(currentValidator) { * * This adapter is synthetic acquisition/support evidence only. Buyers must replace * it with an authorized atomic durable store. Ambiguous or failed operations never - * mutate the document or advance the strong validator. + * mutate the document or advance the strong validator. Configuration and save + * request fields are snapshotted from own data properties without invoking + * caller-owned accessors. */ export function createSyntheticDocumentRepository(options) { - if ( - typeof options !== 'object' || - options === null || - Object.getPrototypeOf(options) !== Object.prototype - ) { - throw new ReferencePersistenceError('invalid_options'); - } - + const configuration = readPlainDataRecord( + options, + ['documentId', 'initialDocument'], + [], + 'invalid_options', + ); const documentId = requireBoundedString( - options.documentId, + configuration.documentId, MAX_DOCUMENT_ID_CODE_UNITS, 'invalid_document_id', ); let document = requireBoundedString( - options.initialDocument, + configuration.initialDocument, MAX_DOCUMENT_CODE_UNITS, 'invalid_document', ); @@ -82,26 +120,25 @@ export function createSyntheticDocumentRepository(options) { } function save(request) { - if ( - typeof request !== 'object' || - request === null || - Object.getPrototypeOf(request) !== Object.prototype - ) { - throw new ReferencePersistenceError('invalid_request'); - } + const candidate = readPlainDataRecord( + request, + ['documentId', 'document', 'ifMatch'], + ['outcome'], + 'invalid_request', + ); - assertDocumentId(request.documentId); + assertDocumentId(candidate.documentId); const nextDocument = requireBoundedString( - request.document, + candidate.document, MAX_DOCUMENT_CODE_UNITS, 'invalid_document', ); const ifMatch = requireBoundedString( - request.ifMatch, + candidate.ifMatch, 256, 'invalid_if_match', ); - const outcome = request.outcome ?? 'saved'; + const outcome = candidate.outcome ?? 'saved'; if ( outcome !== 'saved' && outcome !== 'ambiguous_failure' && @@ -192,6 +229,61 @@ function runSelfTest() { ); } -if (process.argv.includes('--self-test')) { +function runHostileAccessorSelfTest() { + let optionGetterCalls = 0; + let optionErrorCode = null; + const hostileOptions = { initialDocument: 'Buyer draft v1' }; + Object.defineProperty(hostileOptions, 'documentId', { + enumerable: true, + get() { + optionGetterCalls += 1; + return 'buyer-document'; + }, + }); + try { + createSyntheticDocumentRepository(hostileOptions); + } catch (error) { + optionErrorCode = + error instanceof ReferencePersistenceError ? error.code : 'unexpected_error'; + } + + const repository = createSyntheticDocumentRepository({ + documentId: 'buyer-document', + initialDocument: 'Buyer draft v1', + }); + const initial = repository.read('buyer-document'); + let requestGetterCalls = 0; + let requestErrorCode = null; + const hostileRequest = { + documentId: 'buyer-document', + ifMatch: initial.validator, + }; + Object.defineProperty(hostileRequest, 'document', { + enumerable: true, + get() { + requestGetterCalls += 1; + return 'Hostile write'; + }, + }); + try { + repository.save(hostileRequest); + } catch (error) { + requestErrorCode = + error instanceof ReferencePersistenceError ? error.code : 'unexpected_error'; + } + + process.stdout.write( + `${JSON.stringify({ + optionErrorCode, + optionGetterCalls, + requestErrorCode, + requestGetterCalls, + })}\n`, + ); +} + +if (process.argv.includes('--hostile-accessor-self-test')) { + runHostileAccessorSelfTest(); +} else if (process.argv.includes('--self-test')) { runSelfTest(); } From 0e0503bbc8125c2b13258cf4d43d598aabfaf534 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:04:28 -0700 Subject: [PATCH 016/258] test(reference-host): reject proposal accessors --- src/referenceHostDelayedProposal.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/referenceHostDelayedProposal.test.ts b/src/referenceHostDelayedProposal.test.ts index 7580cf81..4079832c 100644 --- a/src/referenceHostDelayedProposal.test.ts +++ b/src/referenceHostDelayedProposal.test.ts @@ -37,4 +37,22 @@ describe('reference-host delayed proposal contract', () => { staleStatus: 'conflict', }); }); + + it('rejects accessor-backed untrusted proposal inputs without invoking them', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync( + process.execPath, + [fixturePath, '--hostile-accessor-self-test'], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + applicationError: 'proposal application is invalid.', + applicationGetterCalls: 0, + creationError: 'proposal creation is invalid.', + creationGetterCalls: 0, + proposalError: 'proposal application is invalid.', + proposalGetterCalls: 0, + }); + }); }); From d98401e445949537909d146acf3da1d75c234b07 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:04:53 -0700 Subject: [PATCH 017/258] fix(reference-host): contain proposal accessors --- examples/reference-host/delayed-proposal.mjs | 142 +++++++++++++++++-- 1 file changed, 129 insertions(+), 13 deletions(-) diff --git a/examples/reference-host/delayed-proposal.mjs b/examples/reference-host/delayed-proposal.mjs index 64bb47cd..68062df2 100644 --- a/examples/reference-host/delayed-proposal.mjs +++ b/examples/reference-host/delayed-proposal.mjs @@ -15,21 +15,55 @@ function requireBoundedString(value, maximumCodeUnits, label) { return value; } +function readPlainDataRecord(source, keys, message) { + try { + if ( + typeof source !== 'object' || + source === null || + Object.getPrototypeOf(source) !== Object.prototype + ) { + throw new TypeError(message); + } + + const values = Object.create(null); + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if ( + descriptor === undefined || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new TypeError(message); + } + values[key] = descriptor.value; + } + return values; + } catch (error) { + if (error instanceof TypeError && error.message === message) throw error; + throw new TypeError(message); + } +} + /** * Produce one deterministic asynchronous proposal bound to the revision captured by the host. * * This fixture deliberately contains no provider SDK, credential, prompt log, or remote call. * Real hosts replace proposal generation with an approved model boundary while preserving the - * expectedRevision conflict gate before applying untrusted proposal data. + * expectedRevision conflict gate before applying untrusted proposal data. Candidate fields are + * snapshotted from own data properties without invoking caller-owned accessors. */ -export async function createDelayedProposal({ expectedRevision, replacement }) { +export async function createDelayedProposal(source) { + const input = readPlainDataRecord( + source, + ['expectedRevision', 'replacement'], + 'proposal creation is invalid.', + ); const boundedRevision = requireBoundedString( - expectedRevision, + input.expectedRevision, MAX_REVISION_CODE_UNITS, 'expectedRevision', ); const boundedReplacement = requireBoundedString( - replacement, + input.replacement, MAX_PROPOSAL_CODE_UNITS, 'replacement', ); @@ -43,17 +77,25 @@ export async function createDelayedProposal({ expectedRevision, replacement }) { /** * Apply one untrusted proposal only when the host's current revision still matches its capture. + * Top-level application metadata and model proposal fields must be own data properties so + * validation never executes accessor-backed untrusted proposal data. */ -export function applyDelayedProposal({ proposal, currentRevision, apply }) { - if ( - typeof proposal !== 'object' || - proposal === null || - typeof apply !== 'function' - ) { +export function applyDelayedProposal(source) { + const application = readPlainDataRecord( + source, + ['proposal', 'currentRevision', 'apply'], + 'proposal application is invalid.', + ); + if (typeof application.apply !== 'function') { throw new TypeError('proposal application is invalid.'); } + const proposal = readPlainDataRecord( + application.proposal, + ['expectedRevision', 'replacement'], + 'proposal application is invalid.', + ); const boundedCurrentRevision = requireBoundedString( - currentRevision, + application.currentRevision, MAX_REVISION_CODE_UNITS, 'currentRevision', ); @@ -72,7 +114,7 @@ export function applyDelayedProposal({ proposal, currentRevision, apply }) { return Object.freeze({ status: 'conflict' }); } - apply(replacement); + application.apply(replacement); return Object.freeze({ status: 'applied' }); } @@ -119,6 +161,80 @@ async function runSelfTest() { ); } -if (process.argv.includes('--self-test')) { +async function runHostileAccessorSelfTest() { + let creationGetterCalls = 0; + let creationError = null; + const hostileCreation = { replacement: 'Hostile proposal' }; + Object.defineProperty(hostileCreation, 'expectedRevision', { + enumerable: true, + get() { + creationGetterCalls += 1; + return 'revision-v1'; + }, + }); + try { + await createDelayedProposal(hostileCreation); + } catch (error) { + creationError = error instanceof Error ? error.message : 'unexpected error'; + } + + const validProposal = await createDelayedProposal({ + expectedRevision: 'revision-v1', + replacement: 'Valid proposal', + }); + let applicationGetterCalls = 0; + let applicationError = null; + const hostileApplication = { + proposal: validProposal, + currentRevision: 'revision-v1', + }; + Object.defineProperty(hostileApplication, 'apply', { + enumerable: true, + get() { + applicationGetterCalls += 1; + return () => undefined; + }, + }); + try { + applyDelayedProposal(hostileApplication); + } catch (error) { + applicationError = error instanceof Error ? error.message : 'unexpected error'; + } + + let proposalGetterCalls = 0; + let proposalError = null; + const hostileProposal = { replacement: 'Hostile proposal' }; + Object.defineProperty(hostileProposal, 'expectedRevision', { + enumerable: true, + get() { + proposalGetterCalls += 1; + return 'revision-v1'; + }, + }); + try { + applyDelayedProposal({ + proposal: hostileProposal, + currentRevision: 'revision-v1', + apply() {}, + }); + } catch (error) { + proposalError = error instanceof Error ? error.message : 'unexpected error'; + } + + process.stdout.write( + `${JSON.stringify({ + applicationError, + applicationGetterCalls, + creationError, + creationGetterCalls, + proposalError, + proposalGetterCalls, + })}\n`, + ); +} + +if (process.argv.includes('--hostile-accessor-self-test')) { + await runHostileAccessorSelfTest(); +} else if (process.argv.includes('--self-test')) { await runSelfTest(); } From c0c2d1a424d56885301b2585ed103d3104bdec72 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:05:49 -0700 Subject: [PATCH 018/258] test(reference-host): reject autosave accessors --- src/referenceHostAutosaveViewModel.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/referenceHostAutosaveViewModel.test.ts b/src/referenceHostAutosaveViewModel.test.ts index a7edb47c..fe372237 100644 --- a/src/referenceHostAutosaveViewModel.test.ts +++ b/src/referenceHostAutosaveViewModel.test.ts @@ -46,4 +46,18 @@ describe('reference-host autosave presentation contract', () => { saving: 'saving', }); }); + + it('rejects accessor-backed lifecycle snapshots without invoking them', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync( + process.execPath, + [fixturePath, '--hostile-accessor-self-test'], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + error: 'autosave snapshot is invalid.', + getterCalls: 0, + }); + }); }); From da17ddb1f4202522ef17405fe638a8b95495998d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:06:14 -0700 Subject: [PATCH 019/258] fix(reference-host): contain autosave accessors --- .../reference-host/autosave-view-model.mjs | 77 +++++++++++++++++-- 1 file changed, 69 insertions(+), 8 deletions(-) diff --git a/examples/reference-host/autosave-view-model.mjs b/examples/reference-host/autosave-view-model.mjs index 432fb1a5..0b0ec377 100644 --- a/examples/reference-host/autosave-view-model.mjs +++ b/examples/reference-host/autosave-view-model.mjs @@ -6,6 +6,13 @@ const AUTOSAVE_STATES = new Set([ 'closed', ]); const BLOCKED_REASONS = new Set(['conflict', 'failure']); +const SNAPSHOT_KEYS = [ + 'state', + 'blockedReason', + 'activeStrongEntityTag', + 'pendingStrongEntityTag', + 'lastSavedStrongEntityTag', +]; /** Marker used by repository contracts to prevent this host fixture becoming runtime authority. */ export const REFERENCE_ONLY = true; @@ -17,10 +24,37 @@ function requireNullableString(value, label) { return value; } -function readSnapshot(snapshot) { +function snapshotData(source) { + try { + if (typeof source !== 'object' || source === null) { + throw new TypeError('autosave snapshot is invalid.'); + } + const values = Object.create(null); + for (const key of SNAPSHOT_KEYS) { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if ( + descriptor === undefined || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new TypeError('autosave snapshot is invalid.'); + } + values[key] = descriptor.value; + } + return values; + } catch (error) { + if ( + error instanceof TypeError && + error.message === 'autosave snapshot is invalid.' + ) { + throw error; + } + throw new TypeError('autosave snapshot is invalid.'); + } +} + +function readSnapshot(source) { + const snapshot = snapshotData(source); if ( - typeof snapshot !== 'object' || - snapshot === null || !AUTOSAVE_STATES.has(snapshot.state) || (snapshot.blockedReason !== null && !BLOCKED_REASONS.has(snapshot.blockedReason)) @@ -69,10 +103,11 @@ function presentation(viewState) { /** * Create one host-owned projection of Inkspan autosave lifecycle transitions. * - * `observe()` consumes only programmatic queue/session snapshots. A blocked to - * saving transition is presented as retrying, and its next idle transition is - * presented as recovered. The projection never returns local or durable - * validators; hosts localize `messageKey` and keep authenticated recovery + * `observe()` consumes only programmatic queue/session snapshots. Snapshot fields + * must be own data properties so presentation never invokes caller-owned accessors. + * A blocked to saving transition is presented as retrying, and its next idle + * transition is presented as recovered. The projection never returns local or + * durable validators; hosts localize `messageKey` and keep authenticated recovery * controls outside Inkspan. */ export function createAutosaveViewModel() { @@ -187,6 +222,32 @@ function runSelfTest() { ); } -if (process.argv.includes('--self-test')) { +function runHostileAccessorSelfTest() { + let getterCalls = 0; + let error = null; + const hostile = { + blockedReason: null, + activeStrongEntityTag: null, + pendingStrongEntityTag: null, + lastSavedStrongEntityTag: null, + }; + Object.defineProperty(hostile, 'state', { + enumerable: true, + get() { + getterCalls += 1; + return 'idle'; + }, + }); + try { + createAutosaveViewModel().observe(hostile); + } catch (failure) { + error = failure instanceof Error ? failure.message : 'unexpected error'; + } + process.stdout.write(`${JSON.stringify({ error, getterCalls })}\n`); +} + +if (process.argv.includes('--hostile-accessor-self-test')) { + runHostileAccessorSelfTest(); +} else if (process.argv.includes('--self-test')) { runSelfTest(); } From b59080031a6d570050e14a3c83f45f924c514c98 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:06:39 -0700 Subject: [PATCH 020/258] test(reference-host): reject collaboration accessors --- ...referenceHostCollaborationLifecycle.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/referenceHostCollaborationLifecycle.test.ts b/src/referenceHostCollaborationLifecycle.test.ts index dcb21af2..3f5cf6ad 100644 --- a/src/referenceHostCollaborationLifecycle.test.ts +++ b/src/referenceHostCollaborationLifecycle.test.ts @@ -49,4 +49,22 @@ describe('reference-host collaboration lifecycle contract', () => { status: 'disposed', }); }); + + it('rejects accessor-backed lifecycle options and resource methods without invoking them', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync( + process.execPath, + [fixturePath, '--hostile-accessor-self-test'], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + documentError: 'documentFactory returned an invalid document.', + documentGetterCalls: 0, + optionsError: 'collaboration options are invalid.', + optionsGetterCalls: 0, + providerError: 'providerFactory returned an invalid provider.', + providerGetterCalls: 0, + }); + }); }); From c2a10b12b8686627c757cd4a96c166de69498864 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:07:05 -0700 Subject: [PATCH 021/258] fix(reference-host): contain collaboration accessors --- .../collaboration-provider-lifecycle.mjs | 211 ++++++++++++++---- 1 file changed, 172 insertions(+), 39 deletions(-) diff --git a/examples/reference-host/collaboration-provider-lifecycle.mjs b/examples/reference-host/collaboration-provider-lifecycle.mjs index 128a6233..97ccc982 100644 --- a/examples/reference-host/collaboration-provider-lifecycle.mjs +++ b/examples/reference-host/collaboration-provider-lifecycle.mjs @@ -21,28 +21,71 @@ function requireFactory(value, label) { return value; } -function requireDocument(value) { - if ( - typeof value !== 'object' || - value === null || - typeof value.destroy !== 'function' - ) { - throw new TypeError('documentFactory returned an invalid document.'); +function readOwnDataRecord(source, keys, message) { + try { + if (typeof source !== 'object' || source === null) { + throw new TypeError(message); + } + const values = Object.create(null); + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if ( + descriptor === undefined || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new TypeError(message); + } + values[key] = descriptor.value; + } + return values; + } catch (error) { + if (error instanceof TypeError && error.message === message) throw error; + throw new TypeError(message); } - return value; } -function requireProvider(value) { - if ( - typeof value !== 'object' || - value === null || - typeof value.connect !== 'function' || - typeof value.disconnect !== 'function' || - typeof value.destroy !== 'function' - ) { - throw new TypeError('providerFactory returned an invalid provider.'); +function findDataMethod(source, key, message) { + try { + if ((typeof source !== 'object' && typeof source !== 'function') || source === null) { + throw new TypeError(message); + } + let cursor = source; + while (cursor !== null) { + const descriptor = Object.getOwnPropertyDescriptor(cursor, key); + if (descriptor !== undefined) { + if ( + !Object.prototype.hasOwnProperty.call(descriptor, 'value') || + typeof descriptor.value !== 'function' + ) { + throw new TypeError(message); + } + return descriptor.value; + } + cursor = Object.getPrototypeOf(cursor); + } + throw new TypeError(message); + } catch (error) { + if (error instanceof TypeError && error.message === message) throw error; + throw new TypeError(message); } - return value; +} + +function requireDocument(value) { + const message = 'documentFactory returned an invalid document.'; + return Object.freeze({ + value, + destroy: findDataMethod(value, 'destroy', message), + }); +} + +function requireProvider(value) { + const message = 'providerFactory returned an invalid provider.'; + return Object.freeze({ + value, + connect: findDataMethod(value, 'connect', message), + disconnect: findDataMethod(value, 'disconnect', message), + destroy: findDataMethod(value, 'destroy', message), + }); } /** @@ -50,28 +93,31 @@ function requireProvider(value) { * * The host supplies the document/provider factories and authorized room/actor * context. Inkspan receives the resulting stable document/provider references; - * it does not create, reconnect, disconnect, or destroy either resource. + * it does not create, reconnect, disconnect, or destroy either resource. Option + * fields and resource methods are captured from data descriptors so lifecycle + * validation never executes accessor-backed host objects. */ -export function createHostCollaborationLifecycle({ - documentFactory, - providerFactory, - roomId, - actorId, -}) { - const createDocument = requireFactory(documentFactory, 'documentFactory'); - const createProvider = requireFactory(providerFactory, 'providerFactory'); - const boundedRoomId = requireContextString(roomId, 'roomId'); - const boundedActorId = requireContextString(actorId, 'actorId'); - const document = requireDocument(createDocument()); +export function createHostCollaborationLifecycle(source) { + const options = readOwnDataRecord( + source, + ['documentFactory', 'providerFactory', 'roomId', 'actorId'], + 'collaboration options are invalid.', + ); + const createDocument = requireFactory(options.documentFactory, 'documentFactory'); + const createProvider = requireFactory(options.providerFactory, 'providerFactory'); + const boundedRoomId = requireContextString(options.roomId, 'roomId'); + const boundedActorId = requireContextString(options.actorId, 'actorId'); + const documentResource = requireDocument(createDocument()); + const document = documentResource.value; let providerGeneration = 0; - let provider = null; + let providerResource = null; let connected = false; let disposed = false; function makeProvider() { providerGeneration += 1; - provider = requireProvider( + providerResource = requireProvider( createProvider({ document, roomId: boundedRoomId, @@ -91,19 +137,19 @@ export function createHostCollaborationLifecycle({ function connect() { requireLive(); if (connected) return false; - provider.connect(); + providerResource.connect.call(providerResource.value); connected = true; return true; } function teardownProvider() { - if (provider === null) return; + if (providerResource === null) return; if (connected) { - provider.disconnect(); + providerResource.disconnect.call(providerResource.value); connected = false; } - provider.destroy(); - provider = null; + providerResource.destroy.call(providerResource.value); + providerResource = null; } function reconnect() { @@ -117,7 +163,7 @@ export function createHostCollaborationLifecycle({ function dispose() { if (disposed) return false; teardownProvider(); - document.destroy(); + documentResource.destroy.call(document); disposed = true; return true; } @@ -182,6 +228,93 @@ function runSelfTest() { ); } -if (process.argv.includes('--self-test')) { +function runHostileAccessorSelfTest() { + let optionsGetterCalls = 0; + let optionsError = null; + const hostileOptions = { + providerFactory() { + return { connect() {}, disconnect() {}, destroy() {} }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }; + Object.defineProperty(hostileOptions, 'documentFactory', { + enumerable: true, + get() { + optionsGetterCalls += 1; + return () => ({ destroy() {} }); + }, + }); + try { + createHostCollaborationLifecycle(hostileOptions); + } catch (error) { + optionsError = error instanceof Error ? error.message : 'unexpected error'; + } + + let documentGetterCalls = 0; + let documentError = null; + try { + createHostCollaborationLifecycle({ + documentFactory() { + const hostileDocument = {}; + Object.defineProperty(hostileDocument, 'destroy', { + enumerable: true, + get() { + documentGetterCalls += 1; + return () => undefined; + }, + }); + return hostileDocument; + }, + providerFactory() { + return { connect() {}, disconnect() {}, destroy() {} }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + } catch (error) { + documentError = error instanceof Error ? error.message : 'unexpected error'; + } + + let providerGetterCalls = 0; + let providerError = null; + try { + createHostCollaborationLifecycle({ + documentFactory() { + return { destroy() {} }; + }, + providerFactory() { + const hostileProvider = { disconnect() {}, destroy() {} }; + Object.defineProperty(hostileProvider, 'connect', { + enumerable: true, + get() { + providerGetterCalls += 1; + return () => undefined; + }, + }); + return hostileProvider; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + } catch (error) { + providerError = error instanceof Error ? error.message : 'unexpected error'; + } + + process.stdout.write( + `${JSON.stringify({ + documentError, + documentGetterCalls, + optionsError, + optionsGetterCalls, + providerError, + providerGetterCalls, + })}\n`, + ); +} + +if (process.argv.includes('--hostile-accessor-self-test')) { + runHostileAccessorSelfTest(); +} else if (process.argv.includes('--self-test')) { runSelfTest(); } From 33148388e5cb7f421290f896ab75619585d5dad8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:08:09 -0700 Subject: [PATCH 022/258] test(reference-host): contain teardown failures --- ...eferenceHostCollaborationLifecycle.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/referenceHostCollaborationLifecycle.test.ts b/src/referenceHostCollaborationLifecycle.test.ts index 3f5cf6ad..c8b1b9d0 100644 --- a/src/referenceHostCollaborationLifecycle.test.ts +++ b/src/referenceHostCollaborationLifecycle.test.ts @@ -67,4 +67,25 @@ describe('reference-host collaboration lifecycle contract', () => { providerGetterCalls: 0, }); }); + + it('attempts provider and document cleanup after teardown failure without leaking private causes', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync( + process.execPath, + [fixturePath, '--cleanup-failure-self-test'], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + error: 'collaboration lifecycle teardown failed.', + events: [ + 'provider:connect', + 'provider:disconnect', + 'provider:destroy', + 'document:destroy', + ], + leakedPrivateCause: false, + status: 'disposed', + }); + }); }); From 43864c63af32a8d5b265fab8168492034e82f8a4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:08:40 -0700 Subject: [PATCH 023/258] fix(reference-host): finish cleanup after teardown failure --- .../collaboration-provider-lifecycle.mjs | 90 +++++++++++++++++-- 1 file changed, 82 insertions(+), 8 deletions(-) diff --git a/examples/reference-host/collaboration-provider-lifecycle.mjs b/examples/reference-host/collaboration-provider-lifecycle.mjs index 97ccc982..99732ce9 100644 --- a/examples/reference-host/collaboration-provider-lifecycle.mjs +++ b/examples/reference-host/collaboration-provider-lifecycle.mjs @@ -1,4 +1,5 @@ const MAX_CONTEXT_CODE_UNITS = 256; +const TEARDOWN_FAILURE = 'collaboration lifecycle teardown failed.'; /** Marker used by repository contracts to keep this lifecycle example out of runtime authority. */ export const REFERENCE_ONLY = true; @@ -88,6 +89,10 @@ function requireProvider(value) { }); } +function teardownFailure() { + return new Error(TEARDOWN_FAILURE); +} + /** * Create one provider-neutral collaboration lifecycle owned entirely by the host. * @@ -95,7 +100,8 @@ function requireProvider(value) { * context. Inkspan receives the resulting stable document/provider references; * it does not create, reconnect, disconnect, or destroy either resource. Option * fields and resource methods are captured from data descriptors so lifecycle - * validation never executes accessor-backed host objects. + * validation never executes accessor-backed host objects. Cleanup failures are + * payload-redacted and do not prevent remaining teardown attempts. */ export function createHostCollaborationLifecycle(source) { const options = readOwnDataRecord( @@ -143,13 +149,24 @@ export function createHostCollaborationLifecycle(source) { } function teardownProvider() { - if (providerResource === null) return; + const resource = providerResource; + if (resource === null) return; + providerResource = null; + let failed = false; if (connected) { - providerResource.disconnect.call(providerResource.value); connected = false; + try { + resource.disconnect.call(resource.value); + } catch { + failed = true; + } } - providerResource.destroy.call(providerResource.value); - providerResource = null; + try { + resource.destroy.call(resource.value); + } catch { + failed = true; + } + if (failed) throw teardownFailure(); } function reconnect() { @@ -162,9 +179,19 @@ export function createHostCollaborationLifecycle(source) { function dispose() { if (disposed) return false; - teardownProvider(); - documentResource.destroy.call(document); + let failed = false; + try { + teardownProvider(); + } catch { + failed = true; + } + try { + documentResource.destroy.call(document); + } catch { + failed = true; + } disposed = true; + if (failed) throw teardownFailure(); return true; } @@ -313,7 +340,54 @@ function runHostileAccessorSelfTest() { ); } -if (process.argv.includes('--hostile-accessor-self-test')) { +function runCleanupFailureSelfTest() { + const privateCause = 'private-provider-disconnect-cause'; + const events = []; + const lifecycle = createHostCollaborationLifecycle({ + documentFactory() { + return { + destroy() { + events.push('document:destroy'); + }, + }; + }, + providerFactory() { + return { + connect() { + events.push('provider:connect'); + }, + disconnect() { + events.push('provider:disconnect'); + throw new Error(privateCause); + }, + destroy() { + events.push('provider:destroy'); + }, + }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + lifecycle.connect(); + let error = null; + try { + lifecycle.dispose(); + } catch (failure) { + error = failure instanceof Error ? failure.message : 'unexpected error'; + } + process.stdout.write( + `${JSON.stringify({ + error, + events, + leakedPrivateCause: typeof error === 'string' && error.includes(privateCause), + status: lifecycle.getSnapshot().status, + })}\n`, + ); +} + +if (process.argv.includes('--cleanup-failure-self-test')) { + runCleanupFailureSelfTest(); +} else if (process.argv.includes('--hostile-accessor-self-test')) { runHostileAccessorSelfTest(); } else if (process.argv.includes('--self-test')) { runSelfTest(); From da4f2b4ad816c2b38d53b5f250a7eb139cb9b6e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:13:57 -0700 Subject: [PATCH 024/258] test(reference-host): require real host-created Y.Doc lifecycle --- src/referenceHostCollaborationLifecycle.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/referenceHostCollaborationLifecycle.test.ts b/src/referenceHostCollaborationLifecycle.test.ts index c8b1b9d0..44811a7b 100644 --- a/src/referenceHostCollaborationLifecycle.test.ts +++ b/src/referenceHostCollaborationLifecycle.test.ts @@ -26,7 +26,7 @@ describe('reference-host collaboration lifecycle contract', () => { expect(source).toContain('dispose'); }); - it('disconnects and destroys replaced providers and tears down the host document exactly once', () => { + it('reuses one real host-created Y.Doc across provider reconnects and tears it down exactly once', () => { if (!existsSync(fixturePath)) return; const output = execFileSync(process.execPath, [fixturePath, '--self-test'], { encoding: 'utf8', @@ -45,8 +45,11 @@ describe('reference-host collaboration lifecycle contract', () => { 'provider:destroy:2', 'document:destroy', ], + hostDocumentIsYjs: true, providerGeneration: 2, + sameDocumentAcrossReconnect: true, status: 'disposed', + yjsText: 'Buyer draft', }); }); From d34c5266b1408bf16add11e40a044fc287fb868a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:14:38 -0700 Subject: [PATCH 025/258] feat(reference-host): exercise host-created Y.Doc lifecycle --- .../collaboration-provider-lifecycle.mjs | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/examples/reference-host/collaboration-provider-lifecycle.mjs b/examples/reference-host/collaboration-provider-lifecycle.mjs index 99732ce9..33f607e9 100644 --- a/examples/reference-host/collaboration-provider-lifecycle.mjs +++ b/examples/reference-host/collaboration-provider-lifecycle.mjs @@ -1,3 +1,5 @@ +import { Doc } from 'yjs'; + const MAX_CONTEXT_CODE_UNITS = 256; const TEARDOWN_FAILURE = 'collaboration lifecycle teardown failed.'; @@ -216,18 +218,29 @@ export function createHostCollaborationLifecycle(source) { function runSelfTest() { const events = []; let providerCounter = 0; + let firstProviderDocument = null; + let sameDocumentAcrossReconnect = true; const lifecycle = createHostCollaborationLifecycle({ documentFactory() { events.push('document:create'); - return { - destroy() { - events.push('document:destroy'); - }, - }; + const document = new Doc(); + document.getText('document').insert(0, 'Buyer draft'); + document.on('destroy', () => { + events.push('document:destroy'); + }); + return document; }, - providerFactory() { + providerFactory({ document }) { providerCounter += 1; const generation = providerCounter; + if (!(document instanceof Doc)) { + throw new TypeError('reference host must supply a Y.Doc.'); + } + if (firstProviderDocument === null) { + firstProviderDocument = document; + } else if (document !== firstProviderDocument) { + sameDocumentAcrossReconnect = false; + } events.push(`provider:create:${generation}`); return { connect() { @@ -247,11 +260,19 @@ function runSelfTest() { lifecycle.connect(); lifecycle.reconnect(); + const hostDocumentIsYjs = lifecycle.document instanceof Doc; + const yjsText = lifecycle.document.getText('document').toString(); lifecycle.dispose(); lifecycle.dispose(); process.stdout.write( - `${JSON.stringify({ events, ...lifecycle.getSnapshot() })}\n`, + `${JSON.stringify({ + events, + hostDocumentIsYjs, + ...lifecycle.getSnapshot(), + sameDocumentAcrossReconnect, + yjsText, + })}\n`, ); } From a0eb8afa86c236f4bac3ba24679772dc8a7f1256 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:15:08 -0700 Subject: [PATCH 026/258] docs(reference-host): record real Y.Doc lifecycle evidence --- examples/reference-host/README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/reference-host/README.md b/examples/reference-host/README.md index 88baac85..0ac2e811 100644 --- a/examples/reference-host/README.md +++ b/examples/reference-host/README.md @@ -9,7 +9,7 @@ The current slice contains four executable, deterministic fixtures: - `synthetic-document-repository.mjs` demonstrates host-owned strong-validator / `If-Match` persistence behavior. Ambiguous and failed writes do not mutate durable state or advance the validator; a stale validator returns a conflict. - `delayed-proposal.mjs` demonstrates a provider-free delayed proposal captured against one expected revision. If the current revision changes before application, the proposal returns a conflict instead of overwriting newer content. - `autosave-view-model.mjs` projects Inkspan autosave lifecycle snapshots into host-localizable `clean`, `saving`, `queued`, `conflict`, `failed`, `retrying`, `recovered`, `closing`, and `closed` presentation states. Recovery presentation is derived from observed blocked → saving → idle transitions, and validators are never returned as UI data. -- `collaboration-provider-lifecycle.mjs` demonstrates that the embedding host creates, reconnects, disconnects, and destroys its collaboration provider and final document lifecycle. The deterministic fixture has no provider SDK or network transport and does not treat room or actor identifiers as authorization evidence. +- `collaboration-provider-lifecycle.mjs` demonstrates that the embedding host creates one real `Y.Doc`, reuses that same document across provider reconnects, and owns provider/document teardown. The deterministic provider fixture has no provider SDK or network transport and does not treat room or actor identifiers as authorization evidence. All fixtures are marked `REFERENCE_ONLY`, require no service, database, credential, provider SDK, or network connection for their self-tests, and are exercised by repository tests. They are deliberately outside the package `files` inventory so example host logic cannot silently become published Inkspan runtime authority. @@ -20,7 +20,7 @@ All fixtures are marked `REFERENCE_ONLY`, require no service, database, credenti | synthetic document repository | Replace with an authorized atomic durable store that enforces the host's RFC 9110 `If-Match` policy and returns a new strong validator only after confirmed success. | | deterministic delayed proposal | Replace proposal generation with a host-approved model gateway and data-use policy while preserving exact-revision conflict checks before applying untrusted proposal data. | | autosave presentation projection | Wire the packed Inkspan autosave session observer into localized host UI and authenticated recovery actions; do not display revision or durable validators as user-facing status. | -| collaboration lifecycle fixture | Replace the deterministic provider factory with the host's authorized Yjs transport provider while preserving host-owned reconnect, teardown, credential, and room-authorization policy. | +| collaboration lifecycle fixture | Keep host-owned `Y.Doc` lifecycle control and replace the deterministic provider factory with the host's authorized Yjs transport provider while preserving reconnect, teardown, credential, and room-authorization policy. | | synthetic document and revision identifiers | Replace with authenticated/authorized host context; never infer tenant or actor authority from an Inkspan digest, form value, or example identifier. | | reference error handling | Map stable machine outcomes to localized host UX and audited host operations without copying document bodies, prompts, credentials, or private causes into generic telemetry. | @@ -41,7 +41,7 @@ flowchart LR class Host,Repo,Provider,Model host; ``` -Inkspan owns deterministic editor/revision/autosave/conversion/package behavior. The host owns authenticated transport, authorization, tenancy, durable persistence, collaboration-provider lifecycle, credentials, model policy, retention, deployment, and durable audit. A successful local editor operation, Yjs update, model response, or status check is not durable authorization or persistence evidence. +Inkspan owns deterministic editor/revision/autosave/conversion/package behavior. The host owns authenticated transport, authorization, tenancy, durable persistence, `Y.Doc` and collaboration-provider lifecycle, credentials, model policy, retention, deployment, and durable audit. A successful local editor operation, Yjs update, model response, or status check is not durable authorization or persistence evidence. ## Executable fixture checks @@ -54,10 +54,10 @@ node examples/reference-host/autosave-view-model.mjs --self-test node examples/reference-host/collaboration-provider-lifecycle.mjs --self-test ``` -The root test suite independently invokes those commands and asserts the expected conflict, lifecycle, recovery, and teardown behavior. These commands do **not** yet satisfy #377's complete packed-tarball application acceptance. +The root test suite independently invokes those commands and asserts the expected conflict, lifecycle, recovery, real host-created `Y.Doc`, reconnect, and teardown behavior. These commands do **not** yet satisfy #377's complete packed-tarball application acceptance. ## Deliberate omissions in this partial slice -Still required before #377 can close: a packed-artifact application (preferably a supported Next.js App Router host), deterministic SSR/hydration proof, native form journeys, packed-package wiring of the autosave observer, a real host-created `Y.Doc` plus provider lifecycle/reconnect journey, package CSS and both font options, real Chromium/Firefox/WebKit acceptance, read-only and forced-colors/print/narrow-viewport journeys, converter/Office handoff, and one documented clean-checkout command that builds the tarball before installing it into the example. +Still required before #377 can close: a packed-artifact application (preferably a supported Next.js App Router host), deterministic SSR/hydration proof, native form journeys, packed-package wiring of the autosave observer, an authorized transport-provider integration journey around the demonstrated host-owned `Y.Doc` lifecycle, package CSS and both font options, real Chromium/Firefox/WebKit acceptance, read-only and forced-colors/print/narrow-viewport journeys, converter/Office handoff, and one documented clean-checkout command that builds the tarball before installing it into the example. -Do not use the synthetic repository, synthetic identifiers, deterministic proposal fixture, presentation projection, or collaboration lifecycle fixture as a production persistence, authentication, collaboration, or model implementation. +Do not use the synthetic repository, synthetic identifiers, deterministic proposal fixture, presentation projection, or deterministic collaboration provider as a production persistence, authentication, collaboration, or model implementation. From a11a759f5c1c459100468a4b551b856cd2718234 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:17:55 -0700 Subject: [PATCH 027/258] test(reference-host): require initialization unwind --- src/referenceHostCollaborationLifecycle.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/referenceHostCollaborationLifecycle.test.ts b/src/referenceHostCollaborationLifecycle.test.ts index 44811a7b..48c817bb 100644 --- a/src/referenceHostCollaborationLifecycle.test.ts +++ b/src/referenceHostCollaborationLifecycle.test.ts @@ -71,6 +71,21 @@ describe('reference-host collaboration lifecycle contract', () => { }); }); + it('unwinds the acquired host document when initial provider construction fails', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync( + process.execPath, + [fixturePath, '--initialization-failure-self-test'], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + error: 'collaboration lifecycle initialization failed.', + events: ['document:create', 'provider:create', 'document:destroy'], + leakedPrivateCause: false, + }); + }); + it('attempts provider and document cleanup after teardown failure without leaking private causes', () => { if (!existsSync(fixturePath)) return; const output = execFileSync( From 091e73d26c62a1e8d69d06c14e635fe2a8dc2ea0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:18:51 -0700 Subject: [PATCH 028/258] fix(reference-host): unwind failed collaboration initialization --- .../collaboration-provider-lifecycle.mjs | 74 +++++++++++++++++-- 1 file changed, 66 insertions(+), 8 deletions(-) diff --git a/examples/reference-host/collaboration-provider-lifecycle.mjs b/examples/reference-host/collaboration-provider-lifecycle.mjs index 33f607e9..e0396ad0 100644 --- a/examples/reference-host/collaboration-provider-lifecycle.mjs +++ b/examples/reference-host/collaboration-provider-lifecycle.mjs @@ -1,11 +1,14 @@ import { Doc } from 'yjs'; const MAX_CONTEXT_CODE_UNITS = 256; +const INITIALIZATION_FAILURE = 'collaboration lifecycle initialization failed.'; const TEARDOWN_FAILURE = 'collaboration lifecycle teardown failed.'; /** Marker used by repository contracts to keep this lifecycle example out of runtime authority. */ export const REFERENCE_ONLY = true; +class ResourceValidationError extends TypeError {} + function requireContextString(value, label) { if ( typeof value !== 'string' || @@ -50,7 +53,7 @@ function readOwnDataRecord(source, keys, message) { function findDataMethod(source, key, message) { try { if ((typeof source !== 'object' && typeof source !== 'function') || source === null) { - throw new TypeError(message); + throw new ResourceValidationError(message); } let cursor = source; while (cursor !== null) { @@ -60,16 +63,18 @@ function findDataMethod(source, key, message) { !Object.prototype.hasOwnProperty.call(descriptor, 'value') || typeof descriptor.value !== 'function' ) { - throw new TypeError(message); + throw new ResourceValidationError(message); } return descriptor.value; } cursor = Object.getPrototypeOf(cursor); } - throw new TypeError(message); + throw new ResourceValidationError(message); } catch (error) { - if (error instanceof TypeError && error.message === message) throw error; - throw new TypeError(message); + if (error instanceof ResourceValidationError && error.message === message) { + throw error; + } + throw new ResourceValidationError(message); } } @@ -91,6 +96,10 @@ function requireProvider(value) { }); } +function initializationFailure() { + return new Error(INITIALIZATION_FAILURE); +} + function teardownFailure() { return new Error(TEARDOWN_FAILURE); } @@ -102,8 +111,9 @@ function teardownFailure() { * context. Inkspan receives the resulting stable document/provider references; * it does not create, reconnect, disconnect, or destroy either resource. Option * fields and resource methods are captured from data descriptors so lifecycle - * validation never executes accessor-backed host objects. Cleanup failures are - * payload-redacted and do not prevent remaining teardown attempts. + * validation never executes accessor-backed host objects. Initial provider + * failures unwind an already-created document, and cleanup failures are + * payload-redacted without preventing remaining teardown attempts. */ export function createHostCollaborationLifecycle(source) { const options = readOwnDataRecord( @@ -204,7 +214,20 @@ export function createHostCollaborationLifecycle(source) { }); } - makeProvider(); + try { + makeProvider(); + } catch (error) { + let cleanupFailed = false; + try { + documentResource.destroy.call(document); + } catch { + cleanupFailed = true; + } + if (error instanceof ResourceValidationError && !cleanupFailed) { + throw error; + } + throw initializationFailure(); + } return Object.freeze({ document, @@ -361,6 +384,39 @@ function runHostileAccessorSelfTest() { ); } +function runInitializationFailureSelfTest() { + const privateCause = 'private-provider-construction-cause'; + const events = []; + let error = null; + try { + createHostCollaborationLifecycle({ + documentFactory() { + events.push('document:create'); + return { + destroy() { + events.push('document:destroy'); + }, + }; + }, + providerFactory() { + events.push('provider:create'); + throw new Error(privateCause); + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + } catch (failure) { + error = failure instanceof Error ? failure.message : 'unexpected error'; + } + process.stdout.write( + `${JSON.stringify({ + error, + events, + leakedPrivateCause: typeof error === 'string' && error.includes(privateCause), + })}\n`, + ); +} + function runCleanupFailureSelfTest() { const privateCause = 'private-provider-disconnect-cause'; const events = []; @@ -408,6 +464,8 @@ function runCleanupFailureSelfTest() { if (process.argv.includes('--cleanup-failure-self-test')) { runCleanupFailureSelfTest(); +} else if (process.argv.includes('--initialization-failure-self-test')) { + runInitializationFailureSelfTest(); } else if (process.argv.includes('--hostile-accessor-self-test')) { runHostileAccessorSelfTest(); } else if (process.argv.includes('--self-test')) { From a135bbf90b22991e4deecbe8e037365cae080534 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:32:22 -0700 Subject: [PATCH 029/258] test(reference-host): require public presentation asset wiring --- src/referenceHostPresentationAssets.test.ts | 38 +++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/referenceHostPresentationAssets.test.ts diff --git a/src/referenceHostPresentationAssets.test.ts b/src/referenceHostPresentationAssets.test.ts new file mode 100644 index 00000000..be11a40a --- /dev/null +++ b/src/referenceHostPresentationAssets.test.ts @@ -0,0 +1,38 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +function repositoryFile(path: string): string { + return readFileSync(resolve(process.cwd(), path), 'utf8'); +} + +describe('reference-host public presentation assets', () => { + it('wires editor styles plus the complete multilingual font option through public package subpaths', () => { + const source = repositoryFile('examples/reference-host/presentation-full.css'); + + expect(source).toBe( + "@import '@contextualwisdomlab/cwl-editor/styles.css';\n" + + "@import '@contextualwisdomlab/cwl-editor/fonts.css';\n", + ); + }); + + it('wires editor styles plus the smaller Latin font option through public package subpaths', () => { + const source = repositoryFile('examples/reference-host/presentation-latin.css'); + + expect(source).toBe( + "@import '@contextualwisdomlab/cwl-editor/styles.css';\n" + + "@import '@contextualwisdomlab/cwl-editor/fonts-latin.css';\n", + ); + }); + + it('keeps every referenced presentation entrypoint in the published package export map', () => { + const packageMetadata = JSON.parse(repositoryFile('package.json')) as { + exports: Record; + }; + + expect(packageMetadata.exports).toHaveProperty('./styles.css'); + expect(packageMetadata.exports).toHaveProperty('./fonts.css'); + expect(packageMetadata.exports).toHaveProperty('./fonts-latin.css'); + }); +}); From 32e9e69a20f8810320a92e778de55b9852267c24 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:32:39 -0700 Subject: [PATCH 030/258] feat(reference-host): wire full multilingual presentation assets --- examples/reference-host/presentation-full.css | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 examples/reference-host/presentation-full.css diff --git a/examples/reference-host/presentation-full.css b/examples/reference-host/presentation-full.css new file mode 100644 index 00000000..34f8fdd9 --- /dev/null +++ b/examples/reference-host/presentation-full.css @@ -0,0 +1,2 @@ +@import '@contextualwisdomlab/cwl-editor/styles.css'; +@import '@contextualwisdomlab/cwl-editor/fonts.css'; From d021fcda5dfa7beba0cbf886c01a288eefc0b731 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:33:41 -0700 Subject: [PATCH 031/258] feat(reference-host): wire Latin presentation assets --- examples/reference-host/presentation-latin.css | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 examples/reference-host/presentation-latin.css diff --git a/examples/reference-host/presentation-latin.css b/examples/reference-host/presentation-latin.css new file mode 100644 index 00000000..17f95d3e --- /dev/null +++ b/examples/reference-host/presentation-latin.css @@ -0,0 +1,2 @@ +@import '@contextualwisdomlab/cwl-editor/styles.css'; +@import '@contextualwisdomlab/cwl-editor/fonts-latin.css'; From b2a7eba206e01074c6623067d75feef9fb403c71 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:34:35 -0700 Subject: [PATCH 032/258] docs(reference-host): document public presentation options --- examples/reference-host/README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/examples/reference-host/README.md b/examples/reference-host/README.md index 0ac2e811..01e7928a 100644 --- a/examples/reference-host/README.md +++ b/examples/reference-host/README.md @@ -4,14 +4,16 @@ Status: Active PR / partial reference-host implementation This directory is buyer-facing integration evidence for issue #377. It is intentionally **host code**, not a new Inkspan runtime surface. Protected `main` remains the shipped product authority, and this example is not production-ready until its remaining SSR/package/collaboration/accessibility/Office acceptance work is implemented and the release boundary permits integration. -The current slice contains four executable, deterministic fixtures: +The current slice contains four executable, deterministic fixtures plus two public-package presentation entrypoints: - `synthetic-document-repository.mjs` demonstrates host-owned strong-validator / `If-Match` persistence behavior. Ambiguous and failed writes do not mutate durable state or advance the validator; a stale validator returns a conflict. - `delayed-proposal.mjs` demonstrates a provider-free delayed proposal captured against one expected revision. If the current revision changes before application, the proposal returns a conflict instead of overwriting newer content. - `autosave-view-model.mjs` projects Inkspan autosave lifecycle snapshots into host-localizable `clean`, `saving`, `queued`, `conflict`, `failed`, `retrying`, `recovered`, `closing`, and `closed` presentation states. Recovery presentation is derived from observed blocked → saving → idle transitions, and validators are never returned as UI data. - `collaboration-provider-lifecycle.mjs` demonstrates that the embedding host creates one real `Y.Doc`, reuses that same document across provider reconnects, and owns provider/document teardown. The deterministic provider fixture has no provider SDK or network transport and does not treat room or actor identifiers as authorization evidence. +- `presentation-full.css` imports Inkspan's public `styles.css` and complete multilingual `fonts.css` subpaths for hosts that want the bundled offline multilingual font set. +- `presentation-latin.css` imports the same public editor stylesheet plus the smaller public `fonts-latin.css` option for Latin-only hosts. -All fixtures are marked `REFERENCE_ONLY`, require no service, database, credential, provider SDK, or network connection for their self-tests, and are exercised by repository tests. They are deliberately outside the package `files` inventory so example host logic cannot silently become published Inkspan runtime authority. +All executable fixtures are marked `REFERENCE_ONLY`, require no service, database, credential, provider SDK, or network connection for their self-tests, and are exercised by repository tests. The presentation entrypoints reference only public package subpaths. The complete reference-host directory is deliberately outside the package `files` inventory so example host logic cannot silently become published Inkspan runtime authority. ## Copy this, replace that @@ -21,6 +23,7 @@ All fixtures are marked `REFERENCE_ONLY`, require no service, database, credenti | deterministic delayed proposal | Replace proposal generation with a host-approved model gateway and data-use policy while preserving exact-revision conflict checks before applying untrusted proposal data. | | autosave presentation projection | Wire the packed Inkspan autosave session observer into localized host UI and authenticated recovery actions; do not display revision or durable validators as user-facing status. | | collaboration lifecycle fixture | Keep host-owned `Y.Doc` lifecycle control and replace the deterministic provider factory with the host's authorized Yjs transport provider while preserving reconnect, teardown, credential, and room-authorization policy. | +| presentation entrypoints | Choose the complete multilingual or Latin-only font entrypoint, keep imports on published package subpaths, and apply any host theme overrides without weakening Inkspan accessibility states. | | synthetic document and revision identifiers | Replace with authenticated/authorized host context; never infer tenant or actor authority from an Inkspan digest, form value, or example identifier. | | reference error handling | Map stable machine outcomes to localized host UX and audited host operations without copying document bodies, prompts, credentials, or private causes into generic telemetry. | @@ -54,10 +57,10 @@ node examples/reference-host/autosave-view-model.mjs --self-test node examples/reference-host/collaboration-provider-lifecycle.mjs --self-test ``` -The root test suite independently invokes those commands and asserts the expected conflict, lifecycle, recovery, real host-created `Y.Doc`, reconnect, and teardown behavior. These commands do **not** yet satisfy #377's complete packed-tarball application acceptance. +The root test suite independently invokes those commands and asserts the expected conflict, lifecycle, recovery, real host-created `Y.Doc`, reconnect, teardown, and public presentation-package behavior. These commands do **not** yet satisfy #377's complete packed-tarball application acceptance. ## Deliberate omissions in this partial slice -Still required before #377 can close: a packed-artifact application (preferably a supported Next.js App Router host), deterministic SSR/hydration proof, native form journeys, packed-package wiring of the autosave observer, an authorized transport-provider integration journey around the demonstrated host-owned `Y.Doc` lifecycle, package CSS and both font options, real Chromium/Firefox/WebKit acceptance, read-only and forced-colors/print/narrow-viewport journeys, converter/Office handoff, and one documented clean-checkout command that builds the tarball before installing it into the example. +Still required before #377 can close: a packed-artifact application (preferably a supported Next.js App Router host), deterministic SSR/hydration proof, native form journeys, packed-package wiring of the autosave observer, an authorized transport-provider integration journey around the demonstrated host-owned `Y.Doc` lifecycle, real Chromium/Firefox/WebKit acceptance, read-only and forced-colors/print/narrow-viewport journeys, converter/Office handoff, and one documented clean-checkout command that builds the tarball before installing it into the example. Do not use the synthetic repository, synthetic identifiers, deterministic proposal fixture, presentation projection, or deterministic collaboration provider as a production persistence, authentication, collaboration, or model implementation. From f198c8774bcf82efe8d646273707893f84982258 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:36:01 -0700 Subject: [PATCH 033/258] test(reference-host): require retry restore and fork recovery --- src/referenceHostSyntheticRepository.test.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/referenceHostSyntheticRepository.test.ts b/src/referenceHostSyntheticRepository.test.ts index 5a7bce33..7ee1ffd4 100644 --- a/src/referenceHostSyntheticRepository.test.ts +++ b/src/referenceHostSyntheticRepository.test.ts @@ -24,7 +24,7 @@ describe('reference-host synthetic durable repository contract', () => { expect(source).toContain('If-Match'); }); - it('proves ambiguous failure cannot advance the validator and stale writes conflict', () => { + it('proves failure-safe retry, restore, fork isolation, and stale-write conflict semantics', () => { if (!existsSync(fixturePath)) return; const output = execFileSync(process.execPath, [fixturePath, '--self-test'], { encoding: 'utf8', @@ -32,15 +32,22 @@ describe('reference-host synthetic durable repository contract', () => { expect(JSON.parse(output)).toEqual({ afterAmbiguousValidator: '"v1"', + afterFailureValidator: '"v2"', conflictCurrentValidator: '"v2"', - finalDocument: 'Buyer draft v2', - finalValidator: '"v2"', + forkDocument: 'Buyer draft v1', + forkFinalDocument: 'Fork-only edit', + forkInitialValidator: '"v1"', + forkSavedValidator: '"v2"', initialValidator: '"v1"', + restoredValidator: '"v4"', + retrySavedValidator: '"v3"', savedValidator: '"v2"', + sourceDocumentAfterFork: 'Buyer draft v1', + sourceValidatorAfterFork: '"v4"', }); }); - it('fails closed without invoking caller-owned option or save-request accessors', () => { + it('fails closed without invoking caller-owned option, save, or fork-request accessors', () => { if (!existsSync(fixturePath)) return; const output = execFileSync( process.execPath, @@ -49,6 +56,8 @@ describe('reference-host synthetic durable repository contract', () => { ); expect(JSON.parse(output)).toEqual({ + forkErrorCode: 'invalid_fork_request', + forkGetterCalls: 0, optionErrorCode: 'invalid_options', optionGetterCalls: 0, requestErrorCode: 'invalid_request', From 35dbe4f0b073feb4788a41d6ee83dd9a920f0b0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:39:30 -0700 Subject: [PATCH 034/258] feat(reference-host): prove retry restore and fork recovery --- .../synthetic-document-repository.mjs | 141 +++++++++++++++++- 1 file changed, 134 insertions(+), 7 deletions(-) diff --git a/examples/reference-host/synthetic-document-repository.mjs b/examples/reference-host/synthetic-document-repository.mjs index 28c4f9b5..9da48775 100644 --- a/examples/reference-host/synthetic-document-repository.mjs +++ b/examples/reference-host/synthetic-document-repository.mjs @@ -84,9 +84,11 @@ function frozenConflict(currentValidator) { * * This adapter is synthetic acquisition/support evidence only. Buyers must replace * it with an authorized atomic durable store. Ambiguous or failed operations never - * mutate the document or advance the strong validator. Configuration and save - * request fields are snapshotted from own data properties without invoking - * caller-owned accessors. + * mutate the document or advance the strong validator. A confirmed fork requires + * the current strong validator and starts an independent repository at a fresh + * validator so source and fork cannot silently share revision authority. + * Configuration, save, and fork request fields are snapshotted from own data + * properties without invoking caller-owned accessors. */ export function createSyntheticDocumentRepository(options) { const configuration = readPlainDataRecord( @@ -163,7 +165,42 @@ export function createSyntheticDocumentRepository(options) { return frozenSave('saved', validator); } - return Object.freeze({ read, save }); + function fork(request) { + const candidate = readPlainDataRecord( + request, + ['documentId', 'forkDocumentId', 'ifMatch'], + [], + 'invalid_fork_request', + ); + + assertDocumentId(candidate.documentId); + const forkDocumentId = requireBoundedString( + candidate.forkDocumentId, + MAX_DOCUMENT_ID_CODE_UNITS, + 'invalid_fork_document_id', + ); + const ifMatch = requireBoundedString( + candidate.ifMatch, + 256, + 'invalid_if_match', + ); + if (ifMatch !== validator) { + return frozenConflict(validator); + } + if (forkDocumentId === documentId) { + throw new ReferencePersistenceError('invalid_fork_document_id'); + } + + return Object.freeze({ + status: 'forked', + repository: createSyntheticDocumentRepository({ + documentId: forkDocumentId, + initialDocument: document, + }), + }); + } + + return Object.freeze({ fork, read, save }); } function runSelfTest() { @@ -216,15 +253,83 @@ function runSelfTest() { throw new Error('Synthetic stale If-Match write did not conflict.'); } - const finalState = repository.read('buyer-document'); + let failureObserved = false; + try { + repository.save({ + documentId: 'buyer-document', + document: 'Buyer draft v3', + ifMatch: saved.validator, + outcome: 'failure', + }); + } catch (error) { + failureObserved = + error instanceof ReferencePersistenceError && error.code === 'failure'; + } + if (!failureObserved) { + throw new Error('Synthetic failure evidence was not observed.'); + } + + const afterFailure = repository.read('buyer-document'); + if ( + afterFailure.document !== 'Buyer draft v2' || + afterFailure.validator !== saved.validator + ) { + throw new Error('Failure advanced synthetic durable state.'); + } + + const retried = repository.save({ + documentId: 'buyer-document', + document: 'Buyer draft v3', + ifMatch: afterFailure.validator, + }); + if (retried.status !== 'saved') { + throw new Error('Synthetic retry did not report success.'); + } + + const restored = repository.save({ + documentId: 'buyer-document', + document: initial.document, + ifMatch: retried.validator, + }); + if (restored.status !== 'saved') { + throw new Error('Synthetic restore did not report success.'); + } + + const forked = repository.fork({ + documentId: 'buyer-document', + forkDocumentId: 'buyer-document-fork', + ifMatch: restored.validator, + }); + if (forked.status !== 'forked') { + throw new Error('Synthetic fork did not report success.'); + } + const forkInitial = forked.repository.read('buyer-document-fork'); + const forkSaved = forked.repository.save({ + documentId: 'buyer-document-fork', + document: 'Fork-only edit', + ifMatch: forkInitial.validator, + }); + if (forkSaved.status !== 'saved') { + throw new Error('Synthetic fork save did not report success.'); + } + + const forkFinal = forked.repository.read('buyer-document-fork'); + const sourceAfterFork = repository.read('buyer-document'); process.stdout.write( `${JSON.stringify({ afterAmbiguousValidator: afterAmbiguous.validator, + afterFailureValidator: afterFailure.validator, conflictCurrentValidator: conflict.currentValidator, - finalDocument: finalState.document, - finalValidator: finalState.validator, + forkDocument: forkInitial.document, + forkFinalDocument: forkFinal.document, + forkInitialValidator: forkInitial.validator, + forkSavedValidator: forkSaved.validator, initialValidator: initial.validator, + restoredValidator: restored.validator, + retrySavedValidator: retried.validator, savedValidator: saved.validator, + sourceDocumentAfterFork: sourceAfterFork.document, + sourceValidatorAfterFork: sourceAfterFork.validator, })}\n`, ); } @@ -272,8 +377,30 @@ function runHostileAccessorSelfTest() { error instanceof ReferencePersistenceError ? error.code : 'unexpected_error'; } + let forkGetterCalls = 0; + let forkErrorCode = null; + const hostileFork = { + documentId: 'buyer-document', + ifMatch: initial.validator, + }; + Object.defineProperty(hostileFork, 'forkDocumentId', { + enumerable: true, + get() { + forkGetterCalls += 1; + return 'buyer-document-fork'; + }, + }); + try { + repository.fork(hostileFork); + } catch (error) { + forkErrorCode = + error instanceof ReferencePersistenceError ? error.code : 'unexpected_error'; + } + process.stdout.write( `${JSON.stringify({ + forkErrorCode, + forkGetterCalls, optionErrorCode, optionGetterCalls, requestErrorCode, From fc7479d5062d8a16a6830a3514ed1605312b9d2f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:42:43 -0700 Subject: [PATCH 035/258] test(reference-host): require recovery guide parity --- src/referenceHostSyntheticRepository.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/referenceHostSyntheticRepository.test.ts b/src/referenceHostSyntheticRepository.test.ts index 7ee1ffd4..83154bd3 100644 --- a/src/referenceHostSyntheticRepository.test.ts +++ b/src/referenceHostSyntheticRepository.test.ts @@ -8,6 +8,7 @@ const fixturePath = resolve( process.cwd(), 'examples/reference-host/synthetic-document-repository.mjs', ); +const guidePath = resolve(process.cwd(), 'examples/reference-host/README.md'); describe('reference-host synthetic durable repository contract', () => { it('ships one executable reference-only repository fixture outside the published runtime', () => { @@ -64,4 +65,18 @@ describe('reference-host synthetic durable repository contract', () => { requestGetterCalls: 0, }); }); + + it('keeps the buyer guide code-current for retry, restore, and independent fork semantics', () => { + const guide = readFileSync(guidePath, 'utf8'); + + expect(guide).toContain( + 'A confirmed failure can be retried with the unchanged current validator.', + ); + expect(guide).toContain( + 'A restore is a normal confirmed save against the current validator and advances it only after success.', + ); + expect(guide).toContain( + 'A fork requires the current validator, copies the current document into an independent reference repository, and starts that fork at a fresh validator.', + ); + }); }); From e604320de84ecbcbda400feda4c9466f90ceceb2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:45:12 -0700 Subject: [PATCH 036/258] docs(reference-host): document recovery semantics --- examples/reference-host/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/reference-host/README.md b/examples/reference-host/README.md index 01e7928a..cf8eecb9 100644 --- a/examples/reference-host/README.md +++ b/examples/reference-host/README.md @@ -6,7 +6,7 @@ This directory is buyer-facing integration evidence for issue #377. It is intent The current slice contains four executable, deterministic fixtures plus two public-package presentation entrypoints: -- `synthetic-document-repository.mjs` demonstrates host-owned strong-validator / `If-Match` persistence behavior. Ambiguous and failed writes do not mutate durable state or advance the validator; a stale validator returns a conflict. +- `synthetic-document-repository.mjs` demonstrates host-owned strong-validator / `If-Match` persistence behavior. Ambiguous and failed writes do not mutate durable state or advance the validator; a stale validator returns a conflict. A confirmed failure can be retried with the unchanged current validator. A restore is a normal confirmed save against the current validator and advances it only after success. A fork requires the current validator, copies the current document into an independent reference repository, and starts that fork at a fresh validator. - `delayed-proposal.mjs` demonstrates a provider-free delayed proposal captured against one expected revision. If the current revision changes before application, the proposal returns a conflict instead of overwriting newer content. - `autosave-view-model.mjs` projects Inkspan autosave lifecycle snapshots into host-localizable `clean`, `saving`, `queued`, `conflict`, `failed`, `retrying`, `recovered`, `closing`, and `closed` presentation states. Recovery presentation is derived from observed blocked → saving → idle transitions, and validators are never returned as UI data. - `collaboration-provider-lifecycle.mjs` demonstrates that the embedding host creates one real `Y.Doc`, reuses that same document across provider reconnects, and owns provider/document teardown. The deterministic provider fixture has no provider SDK or network transport and does not treat room or actor identifiers as authorization evidence. @@ -19,7 +19,7 @@ All executable fixtures are marked `REFERENCE_ONLY`, require no service, databas | Reference element | Buyer action | | --- | --- | -| synthetic document repository | Replace with an authorized atomic durable store that enforces the host's RFC 9110 `If-Match` policy and returns a new strong validator only after confirmed success. | +| synthetic document repository | Replace with an authorized atomic durable store that enforces the host's RFC 9110 `If-Match` policy, preserves validators across ambiguous/failed operations, supports explicit retry/restore/fork recovery under current-validator checks, isolates fork history, and returns a new strong validator only after confirmed success. | | deterministic delayed proposal | Replace proposal generation with a host-approved model gateway and data-use policy while preserving exact-revision conflict checks before applying untrusted proposal data. | | autosave presentation projection | Wire the packed Inkspan autosave session observer into localized host UI and authenticated recovery actions; do not display revision or durable validators as user-facing status. | | collaboration lifecycle fixture | Keep host-owned `Y.Doc` lifecycle control and replace the deterministic provider factory with the host's authorized Yjs transport provider while preserving reconnect, teardown, credential, and room-authorization policy. | @@ -57,7 +57,7 @@ node examples/reference-host/autosave-view-model.mjs --self-test node examples/reference-host/collaboration-provider-lifecycle.mjs --self-test ``` -The root test suite independently invokes those commands and asserts the expected conflict, lifecycle, recovery, real host-created `Y.Doc`, reconnect, teardown, and public presentation-package behavior. These commands do **not** yet satisfy #377's complete packed-tarball application acceptance. +The root test suite independently invokes those commands and asserts the expected stale-write conflict, failure-safe retry, restore, fork isolation, lifecycle recovery, real host-created `Y.Doc`, reconnect, teardown, and public presentation-package behavior. These commands do **not** yet satisfy #377's complete packed-tarball application acceptance. ## Deliberate omissions in this partial slice From 1c5ae7c9edb6926756192e18069ad8a3a304c9ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:53:22 -0700 Subject: [PATCH 037/258] test(reference-host): contain hostile reflection failures --- ...referenceHostReflectionContainment.test.ts | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/referenceHostReflectionContainment.test.ts diff --git a/src/referenceHostReflectionContainment.test.ts b/src/referenceHostReflectionContainment.test.ts new file mode 100644 index 00000000..f3174a56 --- /dev/null +++ b/src/referenceHostReflectionContainment.test.ts @@ -0,0 +1,82 @@ +import { execFileSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +function observeHostileReflectionFailure( + fixture: string, + invocation: string, +): string { + const fixtureUrl = pathToFileURL(resolve(process.cwd(), fixture)).href; + const script = ` + const module = await import(${JSON.stringify(fixtureUrl)}); + const privateSentinel = 'private-reflection-sentinel'; + const hostileError = new Proxy({}, { + getPrototypeOf() { + throw privateSentinel; + }, + }); + const hostileSource = new Proxy({}, ${invocation}); + let observed = 'no-error'; + try { + module.runReflectionBoundarySelfTest(hostileSource); + } catch (error) { + if (typeof error === 'object' && error !== null) { + const descriptor = Object.getOwnPropertyDescriptor(error, 'message'); + observed = descriptor && Object.prototype.hasOwnProperty.call(descriptor, 'value') + ? descriptor.value + : 'object-error'; + } else { + observed = String(error); + } + } + process.stdout.write(JSON.stringify({ observed })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + return (JSON.parse(output) as { observed: string }).observed; +} + +describe('reference-host hostile reflection containment', () => { + it('redacts hostile meta-object failures across every reference boundary', () => { + const prototypeTrap = `{ + getPrototypeOf() { + throw hostileError; + }, + }`; + const descriptorTrap = `{ + getOwnPropertyDescriptor() { + throw hostileError; + }, + }`; + + expect( + observeHostileReflectionFailure( + 'examples/reference-host/synthetic-document-repository.mjs', + prototypeTrap, + ), + ).toBe('Reference persistence invalid_options.'); + expect( + observeHostileReflectionFailure( + 'examples/reference-host/delayed-proposal.mjs', + prototypeTrap, + ), + ).toBe('proposal creation is invalid.'); + expect( + observeHostileReflectionFailure( + 'examples/reference-host/autosave-view-model.mjs', + descriptorTrap, + ), + ).toBe('autosave snapshot is invalid.'); + expect( + observeHostileReflectionFailure( + 'examples/reference-host/collaboration-provider-lifecycle.mjs', + descriptorTrap, + ), + ).toBe('collaboration options are invalid.'); + }); +}); From f9158f8e2a03b25d91ae0af62848a3e4b10725bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:54:04 -0700 Subject: [PATCH 038/258] test(reference-host): exercise real reflection boundaries --- src/referenceHostReflectionContainment.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/referenceHostReflectionContainment.test.ts b/src/referenceHostReflectionContainment.test.ts index f3174a56..723c3a6a 100644 --- a/src/referenceHostReflectionContainment.test.ts +++ b/src/referenceHostReflectionContainment.test.ts @@ -6,7 +6,8 @@ import { describe, expect, it } from 'vitest'; function observeHostileReflectionFailure( fixture: string, - invocation: string, + sourceTrap: string, + call: string, ): string { const fixtureUrl = pathToFileURL(resolve(process.cwd(), fixture)).href; const script = ` @@ -17,10 +18,10 @@ function observeHostileReflectionFailure( throw privateSentinel; }, }); - const hostileSource = new Proxy({}, ${invocation}); + const hostileSource = new Proxy({}, ${sourceTrap}); let observed = 'no-error'; try { - module.runReflectionBoundarySelfTest(hostileSource); + ${call} } catch (error) { if (typeof error === 'object' && error !== null) { const descriptor = Object.getOwnPropertyDescriptor(error, 'message'); @@ -58,24 +59,28 @@ describe('reference-host hostile reflection containment', () => { observeHostileReflectionFailure( 'examples/reference-host/synthetic-document-repository.mjs', prototypeTrap, + 'module.createSyntheticDocumentRepository(hostileSource);', ), ).toBe('Reference persistence invalid_options.'); expect( observeHostileReflectionFailure( 'examples/reference-host/delayed-proposal.mjs', prototypeTrap, + 'await module.createDelayedProposal(hostileSource);', ), ).toBe('proposal creation is invalid.'); expect( observeHostileReflectionFailure( 'examples/reference-host/autosave-view-model.mjs', descriptorTrap, + 'module.createAutosaveViewModel().observe(hostileSource);', ), ).toBe('autosave snapshot is invalid.'); expect( observeHostileReflectionFailure( 'examples/reference-host/collaboration-provider-lifecycle.mjs', descriptorTrap, + 'module.createHostCollaborationLifecycle(hostileSource);', ), ).toBe('collaboration options are invalid.'); }); From da5033bef5b32739cb8c0edb04ffe403835d2bdf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:06:04 -0700 Subject: [PATCH 039/258] fix(reference-host): contain hostile reflection errors --- examples/reference-host/synthetic-document-repository.mjs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/examples/reference-host/synthetic-document-repository.mjs b/examples/reference-host/synthetic-document-repository.mjs index 9da48775..2ad559bf 100644 --- a/examples/reference-host/synthetic-document-repository.mjs +++ b/examples/reference-host/synthetic-document-repository.mjs @@ -55,10 +55,7 @@ function readPlainDataRecord(source, requiredKeys, optionalKeys, code) { values[key] = descriptor.value; } return values; - } catch (error) { - if (error instanceof ReferencePersistenceError && error.code === code) { - throw error; - } + } catch { throw new ReferencePersistenceError(code); } } From d1636526677215368c9175a79e30001951f291d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:10:46 -0700 Subject: [PATCH 040/258] fix(reference-host): redact proposal reflection failures --- examples/reference-host/delayed-proposal.mjs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/reference-host/delayed-proposal.mjs b/examples/reference-host/delayed-proposal.mjs index 68062df2..56fe5189 100644 --- a/examples/reference-host/delayed-proposal.mjs +++ b/examples/reference-host/delayed-proposal.mjs @@ -37,8 +37,7 @@ function readPlainDataRecord(source, keys, message) { values[key] = descriptor.value; } return values; - } catch (error) { - if (error instanceof TypeError && error.message === message) throw error; + } catch { throw new TypeError(message); } } From dc3fcbd7e80cbff7543a8dfe2a08393c42e5d35b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:14:53 -0700 Subject: [PATCH 041/258] fix(reference-host): contain autosave reflection failures --- examples/reference-host/autosave-view-model.mjs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/examples/reference-host/autosave-view-model.mjs b/examples/reference-host/autosave-view-model.mjs index 0b0ec377..4aa55a28 100644 --- a/examples/reference-host/autosave-view-model.mjs +++ b/examples/reference-host/autosave-view-model.mjs @@ -41,13 +41,7 @@ function snapshotData(source) { values[key] = descriptor.value; } return values; - } catch (error) { - if ( - error instanceof TypeError && - error.message === 'autosave snapshot is invalid.' - ) { - throw error; - } + } catch { throw new TypeError('autosave snapshot is invalid.'); } } From 2b43b00c13f6e5f285fea1730cd2f86474e92c4b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:16:51 -0700 Subject: [PATCH 042/258] test(reference-host): cover hostile resource reflection --- src/referenceHostReflectionContainment.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/referenceHostReflectionContainment.test.ts b/src/referenceHostReflectionContainment.test.ts index 723c3a6a..7efa1630 100644 --- a/src/referenceHostReflectionContainment.test.ts +++ b/src/referenceHostReflectionContainment.test.ts @@ -83,5 +83,19 @@ describe('reference-host hostile reflection containment', () => { 'module.createHostCollaborationLifecycle(hostileSource);', ), ).toBe('collaboration options are invalid.'); + expect( + observeHostileReflectionFailure( + 'examples/reference-host/collaboration-provider-lifecycle.mjs', + descriptorTrap, + `module.createHostCollaborationLifecycle({ + documentFactory() { return hostileSource; }, + providerFactory() { + return { connect() {}, disconnect() {}, destroy() {} }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + });`, + ), + ).toBe('documentFactory returned an invalid document.'); }); }); From 7c837e763669852a244c59032442383c2a21b9c6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:18:35 -0700 Subject: [PATCH 043/258] test(reference-host): cover hostile provider failures --- src/referenceHostReflectionContainment.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/referenceHostReflectionContainment.test.ts b/src/referenceHostReflectionContainment.test.ts index 7efa1630..8cf45553 100644 --- a/src/referenceHostReflectionContainment.test.ts +++ b/src/referenceHostReflectionContainment.test.ts @@ -97,5 +97,17 @@ describe('reference-host hostile reflection containment', () => { });`, ), ).toBe('documentFactory returned an invalid document.'); + expect( + observeHostileReflectionFailure( + 'examples/reference-host/collaboration-provider-lifecycle.mjs', + descriptorTrap, + `module.createHostCollaborationLifecycle({ + documentFactory() { return { destroy() {} }; }, + providerFactory() { throw hostileError; }, + roomId: 'reference-room', + actorId: 'reference-actor', + });`, + ), + ).toBe('collaboration lifecycle initialization failed.'); }); }); From c6fd4f7f992c9b53204f1820705d7c00a02861fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:20:30 -0700 Subject: [PATCH 044/258] fix(reference-host): contain collaboration reflection failures --- .../collaboration-provider-lifecycle.mjs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/examples/reference-host/collaboration-provider-lifecycle.mjs b/examples/reference-host/collaboration-provider-lifecycle.mjs index e0396ad0..f091a2fd 100644 --- a/examples/reference-host/collaboration-provider-lifecycle.mjs +++ b/examples/reference-host/collaboration-provider-lifecycle.mjs @@ -3,11 +3,18 @@ import { Doc } from 'yjs'; const MAX_CONTEXT_CODE_UNITS = 256; const INITIALIZATION_FAILURE = 'collaboration lifecycle initialization failed.'; const TEARDOWN_FAILURE = 'collaboration lifecycle teardown failed.'; +const RESOURCE_VALIDATION_ERRORS = new WeakSet(); /** Marker used by repository contracts to keep this lifecycle example out of runtime authority. */ export const REFERENCE_ONLY = true; -class ResourceValidationError extends TypeError {} +class ResourceValidationError extends TypeError { + constructor(message) { + super(message); + this.name = 'ResourceValidationError'; + RESOURCE_VALIDATION_ERRORS.add(this); + } +} function requireContextString(value, label) { if ( @@ -44,8 +51,7 @@ function readOwnDataRecord(source, keys, message) { values[key] = descriptor.value; } return values; - } catch (error) { - if (error instanceof TypeError && error.message === message) throw error; + } catch { throw new TypeError(message); } } @@ -70,10 +76,7 @@ function findDataMethod(source, key, message) { cursor = Object.getPrototypeOf(cursor); } throw new ResourceValidationError(message); - } catch (error) { - if (error instanceof ResourceValidationError && error.message === message) { - throw error; - } + } catch { throw new ResourceValidationError(message); } } @@ -223,7 +226,7 @@ export function createHostCollaborationLifecycle(source) { } catch { cleanupFailed = true; } - if (error instanceof ResourceValidationError && !cleanupFailed) { + if (RESOURCE_VALIDATION_ERRORS.has(error) && !cleanupFailed) { throw error; } throw initializationFailure(); From 39da20a415e9d931f23851ee9d7b541348ae3d68 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:08:16 -0700 Subject: [PATCH 045/258] test(reference-host): retain failed provider for retry --- ...eferenceHostCollaborationLifecycle.test.ts | 69 ++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/src/referenceHostCollaborationLifecycle.test.ts b/src/referenceHostCollaborationLifecycle.test.ts index 48c817bb..1d2c7aec 100644 --- a/src/referenceHostCollaborationLifecycle.test.ts +++ b/src/referenceHostCollaborationLifecycle.test.ts @@ -1,5 +1,6 @@ import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; @@ -53,6 +54,72 @@ describe('reference-host collaboration lifecycle contract', () => { }); }); + it('retains a provider whose destroy failed so reconnect can retry cleanup before replacement', () => { + if (!existsSync(fixturePath)) return; + const moduleUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createHostCollaborationLifecycle } from ${moduleUrl}; + const events = []; + let generation = 0; + let firstDestroyAttempts = 0; + const lifecycle = createHostCollaborationLifecycle({ + documentFactory() { + return { destroy() { events.push('document:destroy'); } }; + }, + providerFactory() { + generation += 1; + const current = generation; + events.push('provider:create:' + current); + return { + connect() { events.push('provider:connect:' + current); }, + disconnect() { events.push('provider:disconnect:' + current); }, + destroy() { + events.push('provider:destroy:' + current); + if (current === 1 && firstDestroyAttempts === 0) { + firstDestroyAttempts += 1; + throw new Error('private transient destroy failure'); + } + }, + }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + lifecycle.connect(); + let firstError = null; + try { + lifecycle.reconnect(); + } catch (error) { + firstError = error instanceof Error ? error.message : 'unexpected error'; + } + lifecycle.reconnect(); + process.stdout.write(JSON.stringify({ + events, + firstError, + snapshot: lifecycle.getSnapshot(), + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + events: [ + 'provider:create:1', + 'provider:connect:1', + 'provider:disconnect:1', + 'provider:destroy:1', + 'provider:destroy:1', + 'provider:create:2', + 'provider:connect:2', + ], + firstError: 'collaboration lifecycle teardown failed.', + snapshot: { providerGeneration: 2, status: 'connected' }, + }); + }); + it('rejects accessor-backed lifecycle options and resource methods without invoking them', () => { if (!existsSync(fixturePath)) return; const output = execFileSync( @@ -106,4 +173,4 @@ describe('reference-host collaboration lifecycle contract', () => { status: 'disposed', }); }); -}); +}); \ No newline at end of file From 916fc9467c90e01c229307f2d11804feb0fdf994 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:11:15 -0700 Subject: [PATCH 046/258] fix(reference-host): retry failed provider destruction --- .../reference-host/collaboration-provider-lifecycle.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/reference-host/collaboration-provider-lifecycle.mjs b/examples/reference-host/collaboration-provider-lifecycle.mjs index f091a2fd..065a9e5b 100644 --- a/examples/reference-host/collaboration-provider-lifecycle.mjs +++ b/examples/reference-host/collaboration-provider-lifecycle.mjs @@ -166,8 +166,8 @@ export function createHostCollaborationLifecycle(source) { function teardownProvider() { const resource = providerResource; if (resource === null) return; - providerResource = null; let failed = false; + let destroyFailed = false; if (connected) { connected = false; try { @@ -180,6 +180,10 @@ export function createHostCollaborationLifecycle(source) { resource.destroy.call(resource.value); } catch { failed = true; + destroyFailed = true; + } + if (!destroyFailed) { + providerResource = null; } if (failed) throw teardownFailure(); } From f9cd1dc8fdecd6748aee37dc231389f9179fd91b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:33:52 -0700 Subject: [PATCH 047/258] test(reference-host): require empty document persistence --- src/referenceHostSyntheticRepository.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/referenceHostSyntheticRepository.test.ts b/src/referenceHostSyntheticRepository.test.ts index 83154bd3..f0698c06 100644 --- a/src/referenceHostSyntheticRepository.test.ts +++ b/src/referenceHostSyntheticRepository.test.ts @@ -48,6 +48,22 @@ describe('reference-host synthetic durable repository contract', () => { }); }); + it('accepts an empty document body while keeping document identifiers non-empty', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync( + process.execPath, + [fixturePath, '--empty-document-self-test'], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + clearedDocument: '', + clearedValidator: '"v2"', + emptyDocumentIdError: 'invalid_document_id', + initialEmptyDocument: '', + }); + }); + it('fails closed without invoking caller-owned option, save, or fork-request accessors', () => { if (!existsSync(fixturePath)) return; const output = execFileSync( From 5213b43f74409da1124187306f7d02f9e640b9ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:35:40 -0700 Subject: [PATCH 048/258] fix(reference-host): allow empty document bodies --- .../synthetic-document-repository.mjs | 60 +++++++++++++++++-- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/examples/reference-host/synthetic-document-repository.mjs b/examples/reference-host/synthetic-document-repository.mjs index 2ad559bf..06049c91 100644 --- a/examples/reference-host/synthetic-document-repository.mjs +++ b/examples/reference-host/synthetic-document-repository.mjs @@ -25,6 +25,13 @@ function requireBoundedString(value, maximumCodeUnits, code) { return value; } +function requireBoundedDocument(value, code) { + if (typeof value !== 'string' || value.length > MAX_DOCUMENT_CODE_UNITS) { + throw new ReferencePersistenceError(code); + } + return value; +} + function readPlainDataRecord(source, requiredKeys, optionalKeys, code) { try { if ( @@ -99,9 +106,8 @@ export function createSyntheticDocumentRepository(options) { MAX_DOCUMENT_ID_CODE_UNITS, 'invalid_document_id', ); - let document = requireBoundedString( + let document = requireBoundedDocument( configuration.initialDocument, - MAX_DOCUMENT_CODE_UNITS, 'invalid_document', ); let version = 1; @@ -127,9 +133,8 @@ export function createSyntheticDocumentRepository(options) { ); assertDocumentId(candidate.documentId); - const nextDocument = requireBoundedString( + const nextDocument = requireBoundedDocument( candidate.document, - MAX_DOCUMENT_CODE_UNITS, 'invalid_document', ); const ifMatch = requireBoundedString( @@ -331,6 +336,49 @@ function runSelfTest() { ); } +function runEmptyDocumentSelfTest() { + const emptyRepository = createSyntheticDocumentRepository({ + documentId: 'buyer-empty', + initialDocument: '', + }); + const initialEmptyDocument = emptyRepository.read('buyer-empty').document; + + const repository = createSyntheticDocumentRepository({ + documentId: 'buyer-document', + initialDocument: 'Not empty', + }); + const initial = repository.read('buyer-document'); + const cleared = repository.save({ + documentId: 'buyer-document', + document: '', + ifMatch: initial.validator, + }); + if (cleared.status !== 'saved') { + throw new Error('Synthetic empty-document save did not report success.'); + } + const afterClear = repository.read('buyer-document'); + + let emptyDocumentIdError = null; + try { + createSyntheticDocumentRepository({ + documentId: '', + initialDocument: '', + }); + } catch (error) { + emptyDocumentIdError = + error instanceof ReferencePersistenceError ? error.code : 'unexpected_error'; + } + + process.stdout.write( + `${JSON.stringify({ + clearedDocument: afterClear.document, + clearedValidator: afterClear.validator, + emptyDocumentIdError, + initialEmptyDocument, + })}\n`, + ); +} + function runHostileAccessorSelfTest() { let optionGetterCalls = 0; let optionErrorCode = null; @@ -406,7 +454,9 @@ function runHostileAccessorSelfTest() { ); } -if (process.argv.includes('--hostile-accessor-self-test')) { +if (process.argv.includes('--empty-document-self-test')) { + runEmptyDocumentSelfTest(); +} else if (process.argv.includes('--hostile-accessor-self-test')) { runHostileAccessorSelfTest(); } else if (process.argv.includes('--self-test')) { runSelfTest(); From db1195778f39aa4524ca879fab4ce25e902adce2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:38:04 -0700 Subject: [PATCH 049/258] test(reference-host): allow empty proposal replacement --- src/referenceHostDelayedProposal.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/referenceHostDelayedProposal.test.ts b/src/referenceHostDelayedProposal.test.ts index 4079832c..26481f59 100644 --- a/src/referenceHostDelayedProposal.test.ts +++ b/src/referenceHostDelayedProposal.test.ts @@ -38,6 +38,22 @@ describe('reference-host delayed proposal contract', () => { }); }); + it('permits an empty replacement without weakening non-empty revision identity', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync( + process.execPath, + [fixturePath, '--empty-proposal-self-test'], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + appliedDocument: '', + appliedStatus: 'applied', + emptyRevisionError: 'expectedRevision is invalid.', + proposalReplacement: '', + }); + }); + it('rejects accessor-backed untrusted proposal inputs without invoking them', () => { if (!existsSync(fixturePath)) return; const output = execFileSync( From 7ec85416da6bd9804e6aec9baa1ca360d753f289 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:38:37 -0700 Subject: [PATCH 050/258] fix(reference-host): permit empty proposal replacement --- examples/reference-host/delayed-proposal.mjs | 57 ++++++++++++++++---- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/examples/reference-host/delayed-proposal.mjs b/examples/reference-host/delayed-proposal.mjs index 56fe5189..a3eac30c 100644 --- a/examples/reference-host/delayed-proposal.mjs +++ b/examples/reference-host/delayed-proposal.mjs @@ -15,6 +15,13 @@ function requireBoundedString(value, maximumCodeUnits, label) { return value; } +function requireBoundedReplacement(value) { + if (typeof value !== 'string' || value.length > MAX_PROPOSAL_CODE_UNITS) { + throw new TypeError('replacement is invalid.'); + } + return value; +} + function readPlainDataRecord(source, keys, message) { try { if ( @@ -61,11 +68,7 @@ export async function createDelayedProposal(source) { MAX_REVISION_CODE_UNITS, 'expectedRevision', ); - const boundedReplacement = requireBoundedString( - input.replacement, - MAX_PROPOSAL_CODE_UNITS, - 'replacement', - ); + const boundedReplacement = requireBoundedReplacement(input.replacement); await Promise.resolve(); return Object.freeze({ @@ -103,11 +106,7 @@ export function applyDelayedProposal(source) { MAX_REVISION_CODE_UNITS, 'expectedRevision', ); - const replacement = requireBoundedString( - proposal.replacement, - MAX_PROPOSAL_CODE_UNITS, - 'replacement', - ); + const replacement = requireBoundedReplacement(proposal.replacement); if (expectedRevision !== boundedCurrentRevision) { return Object.freeze({ status: 'conflict' }); @@ -160,6 +159,40 @@ async function runSelfTest() { ); } +async function runEmptyProposalSelfTest() { + const proposal = await createDelayedProposal({ + expectedRevision: 'revision-v1', + replacement: '', + }); + let appliedDocument = 'Non-empty draft'; + const result = applyDelayedProposal({ + proposal, + currentRevision: 'revision-v1', + apply(replacement) { + appliedDocument = replacement; + }, + }); + + let emptyRevisionError = null; + try { + await createDelayedProposal({ + expectedRevision: '', + replacement: '', + }); + } catch (error) { + emptyRevisionError = error instanceof Error ? error.message : 'unexpected error'; + } + + process.stdout.write( + `${JSON.stringify({ + appliedDocument, + appliedStatus: result.status, + emptyRevisionError, + proposalReplacement: proposal.replacement, + })}\n`, + ); +} + async function runHostileAccessorSelfTest() { let creationGetterCalls = 0; let creationError = null; @@ -232,7 +265,9 @@ async function runHostileAccessorSelfTest() { ); } -if (process.argv.includes('--hostile-accessor-self-test')) { +if (process.argv.includes('--empty-proposal-self-test')) { + await runEmptyProposalSelfTest(); +} else if (process.argv.includes('--hostile-accessor-self-test')) { await runHostileAccessorSelfTest(); } else if (process.argv.includes('--self-test')) { await runSelfTest(); From 8e4993687e81c9fc09926b0952b13d258981d6ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:40:18 -0700 Subject: [PATCH 051/258] test(reference-host): reject empty autosave validators --- src/referenceHostAutosaveViewModel.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/referenceHostAutosaveViewModel.test.ts b/src/referenceHostAutosaveViewModel.test.ts index fe372237..d5b6a936 100644 --- a/src/referenceHostAutosaveViewModel.test.ts +++ b/src/referenceHostAutosaveViewModel.test.ts @@ -47,6 +47,21 @@ describe('reference-host autosave presentation contract', () => { }); }); + it('rejects empty non-null validator fields instead of treating them as lifecycle evidence', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync( + process.execPath, + [fixturePath, '--invalid-validator-self-test'], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + activeError: 'activeStrongEntityTag is invalid.', + lastSavedError: 'lastSavedStrongEntityTag is invalid.', + pendingError: 'pendingStrongEntityTag is invalid.', + }); + }); + it('rejects accessor-backed lifecycle snapshots without invoking them', () => { if (!existsSync(fixturePath)) return; const output = execFileSync( From 0cc48b6622c5bd8968803b51cdef1a9c91f876bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:40:54 -0700 Subject: [PATCH 052/258] fix(reference-host): reject empty autosave validators --- .../reference-host/autosave-view-model.mjs | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/examples/reference-host/autosave-view-model.mjs b/examples/reference-host/autosave-view-model.mjs index 4aa55a28..9f699a68 100644 --- a/examples/reference-host/autosave-view-model.mjs +++ b/examples/reference-host/autosave-view-model.mjs @@ -18,7 +18,7 @@ const SNAPSHOT_KEYS = [ export const REFERENCE_ONLY = true; function requireNullableString(value, label) { - if (value !== null && typeof value !== 'string') { + if (value !== null && (typeof value !== 'string' || value.length === 0)) { throw new TypeError(`${label} is invalid.`); } return value; @@ -216,6 +216,35 @@ function runSelfTest() { ); } +function runInvalidValidatorSelfTest() { + function observeError(candidate) { + try { + createAutosaveViewModel().observe(candidate); + return null; + } catch (error) { + return error instanceof Error ? error.message : 'unexpected error'; + } + } + + const activeError = observeError( + snapshot({ state: 'saving', activeStrongEntityTag: '' }), + ); + const pendingError = observeError( + snapshot({ + state: 'saving', + activeStrongEntityTag: '"local-active"', + pendingStrongEntityTag: '', + }), + ); + const lastSavedError = observeError( + snapshot({ state: 'idle', lastSavedStrongEntityTag: '' }), + ); + + process.stdout.write( + `${JSON.stringify({ activeError, lastSavedError, pendingError })}\n`, + ); +} + function runHostileAccessorSelfTest() { let getterCalls = 0; let error = null; @@ -240,7 +269,9 @@ function runHostileAccessorSelfTest() { process.stdout.write(`${JSON.stringify({ error, getterCalls })}\n`); } -if (process.argv.includes('--hostile-accessor-self-test')) { +if (process.argv.includes('--invalid-validator-self-test')) { + runInvalidValidatorSelfTest(); +} else if (process.argv.includes('--hostile-accessor-self-test')) { runHostileAccessorSelfTest(); } else if (process.argv.includes('--self-test')) { runSelfTest(); From b62af9b501369bddd104e808f914da8c3e2a33c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:10:33 -0700 Subject: [PATCH 053/258] test(reference-host): require observed retry before recovery --- src/referenceHostAutosaveViewModel.test.ts | 30 ++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/referenceHostAutosaveViewModel.test.ts b/src/referenceHostAutosaveViewModel.test.ts index d5b6a936..9adf3466 100644 --- a/src/referenceHostAutosaveViewModel.test.ts +++ b/src/referenceHostAutosaveViewModel.test.ts @@ -1,5 +1,6 @@ import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; @@ -47,6 +48,35 @@ describe('reference-host autosave presentation contract', () => { }); }); + it('does not claim recovery unless a retrying save was observed', () => { + if (!existsSync(fixturePath)) return; + const moduleUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createAutosaveViewModel } from ${moduleUrl}; + const snapshot = (state, blockedReason = null) => ({ + state, + blockedReason, + activeStrongEntityTag: null, + pendingStrongEntityTag: null, + lastSavedStrongEntityTag: null, + }); + const viewModel = createAutosaveViewModel(); + const blocked = viewModel.observe(snapshot('blocked', 'conflict')).viewState; + const idleWithoutRetry = viewModel.observe(snapshot('idle')).viewState; + process.stdout.write(JSON.stringify({ blocked, idleWithoutRetry })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + blocked: 'conflict', + idleWithoutRetry: 'clean', + }); + }); + it('rejects empty non-null validator fields instead of treating them as lifecycle evidence', () => { if (!existsSync(fixturePath)) return; const output = execFileSync( From a7f2c5a4c43fb5e58994626e9dc1d260a5f63342 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:11:34 -0700 Subject: [PATCH 054/258] fix(reference-host): require retry before recovery state --- .../reference-host/autosave-view-model.mjs | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/examples/reference-host/autosave-view-model.mjs b/examples/reference-host/autosave-view-model.mjs index 9f699a68..65393a2f 100644 --- a/examples/reference-host/autosave-view-model.mjs +++ b/examples/reference-host/autosave-view-model.mjs @@ -99,42 +99,54 @@ function presentation(viewState) { * * `observe()` consumes only programmatic queue/session snapshots. Snapshot fields * must be own data properties so presentation never invokes caller-owned accessors. - * A blocked to saving transition is presented as retrying, and its next idle - * transition is presented as recovered. The projection never returns local or - * durable validators; hosts localize `messageKey` and keep authenticated recovery - * controls outside Inkspan. + * A blocked to saving transition is presented as retrying, and only a later idle + * transition after that observed retry is presented as recovered. A blocked to + * idle transition without an intervening save returns to clean instead of + * manufacturing recovery evidence. The projection never returns local or durable + * validators; hosts localize `messageKey` and keep authenticated recovery controls + * outside Inkspan. */ export function createAutosaveViewModel() { - let recovering = false; + let retryPending = false; + let retryInFlight = false; + + function clearRetryEvidence() { + retryPending = false; + retryInFlight = false; + } function observe(snapshot) { const current = readSnapshot(snapshot); if (current.state === 'blocked') { - recovering = true; + retryPending = true; + retryInFlight = false; return presentation( current.blockedReason === 'conflict' ? 'conflict' : 'failed', ); } if (current.state === 'closing') { - recovering = false; + clearRetryEvidence(); return presentation('closing'); } if (current.state === 'closed') { - recovering = false; + clearRetryEvidence(); return presentation('closed'); } - if (current.state === 'saving' && recovering) { + if (current.state === 'saving' && (retryPending || retryInFlight)) { + retryPending = false; + retryInFlight = true; return presentation('retrying'); } if (current.state === 'saving' && current.pendingStrongEntityTag !== null) { return presentation('queued'); } if (current.state === 'saving') return presentation('saving'); - if (recovering) { - recovering = false; + if (retryInFlight) { + clearRetryEvidence(); return presentation('recovered'); } + retryPending = false; return presentation('clean'); } From 20e0be5468eb416c3bab5f604d74adfa1214b434 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:12:17 -0700 Subject: [PATCH 055/258] test(reference-host): redact reconnect provider failures --- ...eferenceHostCollaborationLifecycle.test.ts | 73 ++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/src/referenceHostCollaborationLifecycle.test.ts b/src/referenceHostCollaborationLifecycle.test.ts index 1d2c7aec..92c52088 100644 --- a/src/referenceHostCollaborationLifecycle.test.ts +++ b/src/referenceHostCollaborationLifecycle.test.ts @@ -120,6 +120,77 @@ describe('reference-host collaboration lifecycle contract', () => { }); }); + it('redacts reconnect provider construction failures and remains recoverable', () => { + if (!existsSync(fixturePath)) return; + const moduleUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createHostCollaborationLifecycle } from ${moduleUrl}; + const privateCause = 'private reconnect provider cause'; + const events = []; + let generation = 0; + const lifecycle = createHostCollaborationLifecycle({ + documentFactory() { + return { destroy() { events.push('document:destroy'); } }; + }, + providerFactory() { + generation += 1; + const current = generation; + events.push('provider:create:' + current); + if (current === 2) throw new Error(privateCause); + return { + connect() { events.push('provider:connect:' + current); }, + disconnect() { events.push('provider:disconnect:' + current); }, + destroy() { events.push('provider:destroy:' + current); }, + }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + lifecycle.connect(); + let reconnectError = null; + try { + lifecycle.reconnect(); + } catch (error) { + reconnectError = error instanceof Error ? error.message : 'unexpected error'; + } + const afterFailure = lifecycle.getSnapshot(); + const recovered = lifecycle.reconnect(); + lifecycle.dispose(); + process.stdout.write(JSON.stringify({ + afterFailure, + events, + leakedPrivateCause: + typeof reconnectError === 'string' && reconnectError.includes(privateCause), + reconnectError, + recovered, + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + afterFailure: { providerGeneration: 2, status: 'disconnected' }, + events: [ + 'provider:create:1', + 'provider:connect:1', + 'provider:disconnect:1', + 'provider:destroy:1', + 'provider:create:2', + 'provider:create:3', + 'provider:connect:3', + 'provider:disconnect:3', + 'provider:destroy:3', + 'document:destroy', + ], + leakedPrivateCause: false, + reconnectError: 'collaboration lifecycle reconnect failed.', + recovered: { providerGeneration: 3, status: 'connected' }, + }); + }); + it('rejects accessor-backed lifecycle options and resource methods without invoking them', () => { if (!existsSync(fixturePath)) return; const output = execFileSync( @@ -173,4 +244,4 @@ describe('reference-host collaboration lifecycle contract', () => { status: 'disposed', }); }); -}); \ No newline at end of file +}); From 783dc9ecfc74d9f2dcf31b6de65d633b9d6cf729 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:13:07 -0700 Subject: [PATCH 056/258] fix(reference-host): redact reconnect provider failures --- .../collaboration-provider-lifecycle.mjs | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/examples/reference-host/collaboration-provider-lifecycle.mjs b/examples/reference-host/collaboration-provider-lifecycle.mjs index 065a9e5b..19f5096f 100644 --- a/examples/reference-host/collaboration-provider-lifecycle.mjs +++ b/examples/reference-host/collaboration-provider-lifecycle.mjs @@ -2,6 +2,7 @@ import { Doc } from 'yjs'; const MAX_CONTEXT_CODE_UNITS = 256; const INITIALIZATION_FAILURE = 'collaboration lifecycle initialization failed.'; +const RECONNECT_FAILURE = 'collaboration lifecycle reconnect failed.'; const TEARDOWN_FAILURE = 'collaboration lifecycle teardown failed.'; const RESOURCE_VALIDATION_ERRORS = new WeakSet(); @@ -103,6 +104,10 @@ function initializationFailure() { return new Error(INITIALIZATION_FAILURE); } +function reconnectFailure() { + return new Error(RECONNECT_FAILURE); +} + function teardownFailure() { return new Error(TEARDOWN_FAILURE); } @@ -115,8 +120,9 @@ function teardownFailure() { * it does not create, reconnect, disconnect, or destroy either resource. Option * fields and resource methods are captured from data descriptors so lifecycle * validation never executes accessor-backed host objects. Initial provider - * failures unwind an already-created document, and cleanup failures are - * payload-redacted without preventing remaining teardown attempts. + * failures unwind an already-created document, reconnect provider-construction + * failures remain retryable, and private callback/cleanup causes are payload- + * redacted without preventing remaining teardown attempts. */ export function createHostCollaborationLifecycle(source) { const options = readOwnDataRecord( @@ -136,16 +142,20 @@ export function createHostCollaborationLifecycle(source) { let connected = false; let disposed = false; - function makeProvider() { + function makeProvider(privateFailure) { providerGeneration += 1; - providerResource = requireProvider( - createProvider({ + let candidate; + try { + candidate = createProvider({ document, roomId: boundedRoomId, actorId: boundedActorId, generation: providerGeneration, - }), - ); + }); + } catch { + throw privateFailure(); + } + providerResource = requireProvider(candidate); connected = false; } @@ -191,7 +201,7 @@ export function createHostCollaborationLifecycle(source) { function reconnect() { requireLive(); teardownProvider(); - makeProvider(); + makeProvider(reconnectFailure); connect(); return getSnapshot(); } @@ -222,7 +232,7 @@ export function createHostCollaborationLifecycle(source) { } try { - makeProvider(); + makeProvider(initializationFailure); } catch (error) { let cleanupFailed = false; try { From 577512a555d10b5c12b1788db269b48b7a4ab7d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:14:14 -0700 Subject: [PATCH 057/258] test(reference-host): redact host collaboration callback failures --- ...eHostCollaborationFailureRedaction.test.ts | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 src/referenceHostCollaborationFailureRedaction.test.ts diff --git a/src/referenceHostCollaborationFailureRedaction.test.ts b/src/referenceHostCollaborationFailureRedaction.test.ts new file mode 100644 index 00000000..cdd0e962 --- /dev/null +++ b/src/referenceHostCollaborationFailureRedaction.test.ts @@ -0,0 +1,116 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const fixturePath = resolve( + process.cwd(), + 'examples/reference-host/collaboration-provider-lifecycle.mjs', +); + +describe('reference-host collaboration callback failure redaction', () => { + it('redacts document factory failures at initialization', () => { + if (!existsSync(fixturePath)) return; + const moduleUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createHostCollaborationLifecycle } from ${moduleUrl}; + const privateCause = 'private document factory cause'; + let error = null; + try { + createHostCollaborationLifecycle({ + documentFactory() { throw new Error(privateCause); }, + providerFactory() { + return { connect() {}, disconnect() {}, destroy() {} }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + } catch (failure) { + error = failure instanceof Error ? failure.message : 'unexpected error'; + } + process.stdout.write(JSON.stringify({ + error, + leakedPrivateCause: typeof error === 'string' && error.includes(privateCause), + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + error: 'collaboration lifecycle initialization failed.', + leakedPrivateCause: false, + }); + }); + + it('redacts transient provider connect failures and permits an explicit retry', () => { + if (!existsSync(fixturePath)) return; + const moduleUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createHostCollaborationLifecycle } from ${moduleUrl}; + const privateCause = 'private provider connect cause'; + const events = []; + let connectAttempts = 0; + const lifecycle = createHostCollaborationLifecycle({ + documentFactory() { + return { destroy() { events.push('document:destroy'); } }; + }, + providerFactory() { + return { + connect() { + connectAttempts += 1; + events.push('provider:connect:' + connectAttempts); + if (connectAttempts === 1) throw new Error(privateCause); + }, + disconnect() { events.push('provider:disconnect'); }, + destroy() { events.push('provider:destroy'); }, + }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + let error = null; + try { + lifecycle.connect(); + } catch (failure) { + error = failure instanceof Error ? failure.message : 'unexpected error'; + } + const afterFailure = lifecycle.getSnapshot(); + const retryResult = lifecycle.connect(); + const afterRetry = lifecycle.getSnapshot(); + lifecycle.dispose(); + process.stdout.write(JSON.stringify({ + afterFailure, + afterRetry, + error, + events, + leakedPrivateCause: typeof error === 'string' && error.includes(privateCause), + retryResult, + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + afterFailure: { providerGeneration: 1, status: 'disconnected' }, + afterRetry: { providerGeneration: 1, status: 'connected' }, + error: 'collaboration lifecycle connection failed.', + events: [ + 'provider:connect:1', + 'provider:connect:2', + 'provider:disconnect', + 'provider:destroy', + 'document:destroy', + ], + leakedPrivateCause: false, + retryResult: true, + }); + }); +}); From 64212aeb49d3212a14b530ef6d4d10f101bf51c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:14:56 -0700 Subject: [PATCH 058/258] fix(reference-host): redact collaboration callback failures --- .../collaboration-provider-lifecycle.mjs | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/examples/reference-host/collaboration-provider-lifecycle.mjs b/examples/reference-host/collaboration-provider-lifecycle.mjs index 19f5096f..af034bd4 100644 --- a/examples/reference-host/collaboration-provider-lifecycle.mjs +++ b/examples/reference-host/collaboration-provider-lifecycle.mjs @@ -2,6 +2,7 @@ import { Doc } from 'yjs'; const MAX_CONTEXT_CODE_UNITS = 256; const INITIALIZATION_FAILURE = 'collaboration lifecycle initialization failed.'; +const CONNECTION_FAILURE = 'collaboration lifecycle connection failed.'; const RECONNECT_FAILURE = 'collaboration lifecycle reconnect failed.'; const TEARDOWN_FAILURE = 'collaboration lifecycle teardown failed.'; const RESOURCE_VALIDATION_ERRORS = new WeakSet(); @@ -104,6 +105,10 @@ function initializationFailure() { return new Error(INITIALIZATION_FAILURE); } +function connectionFailure() { + return new Error(CONNECTION_FAILURE); +} + function reconnectFailure() { return new Error(RECONNECT_FAILURE); } @@ -119,10 +124,11 @@ function teardownFailure() { * context. Inkspan receives the resulting stable document/provider references; * it does not create, reconnect, disconnect, or destroy either resource. Option * fields and resource methods are captured from data descriptors so lifecycle - * validation never executes accessor-backed host objects. Initial provider - * failures unwind an already-created document, reconnect provider-construction - * failures remain retryable, and private callback/cleanup causes are payload- - * redacted without preventing remaining teardown attempts. + * validation never executes accessor-backed host objects. Initial document and + * provider callback failures are payload-redacted, acquired documents unwind on + * initial provider failure, reconnect provider-construction failures remain + * retryable, connect failures preserve the disconnected provider for an explicit + * retry, and cleanup failures do not prevent remaining teardown attempts. */ export function createHostCollaborationLifecycle(source) { const options = readOwnDataRecord( @@ -134,7 +140,13 @@ export function createHostCollaborationLifecycle(source) { const createProvider = requireFactory(options.providerFactory, 'providerFactory'); const boundedRoomId = requireContextString(options.roomId, 'roomId'); const boundedActorId = requireContextString(options.actorId, 'actorId'); - const documentResource = requireDocument(createDocument()); + let documentCandidate; + try { + documentCandidate = createDocument(); + } catch { + throw initializationFailure(); + } + const documentResource = requireDocument(documentCandidate); const document = documentResource.value; let providerGeneration = 0; @@ -168,7 +180,11 @@ export function createHostCollaborationLifecycle(source) { function connect() { requireLive(); if (connected) return false; - providerResource.connect.call(providerResource.value); + try { + providerResource.connect.call(providerResource.value); + } catch { + throw connectionFailure(); + } connected = true; return true; } From c9b4a21c931a806b9012bf991634fec472b7825e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:08:52 -0700 Subject: [PATCH 059/258] test(reference-host): bound prototype traversal --- ...ostCollaborationPrototypeTraversal.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/referenceHostCollaborationPrototypeTraversal.test.ts diff --git a/src/referenceHostCollaborationPrototypeTraversal.test.ts b/src/referenceHostCollaborationPrototypeTraversal.test.ts new file mode 100644 index 00000000..515651ab --- /dev/null +++ b/src/referenceHostCollaborationPrototypeTraversal.test.ts @@ -0,0 +1,58 @@ +import { execFileSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const fixturePath = resolve( + process.cwd(), + 'examples/reference-host/collaboration-provider-lifecycle.mjs', +); + +describe('reference-host collaboration prototype traversal', () => { + it('bounds hostile resource prototype traversal before caller-controlled work can continue indefinitely', () => { + const fixtureUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createHostCollaborationLifecycle } from ${fixtureUrl}; + const privateCause = 'private prototype traversal cause'; + let prototypeReads = 0; + let hostileDocument; + hostileDocument = new Proxy({}, { + getPrototypeOf() { + prototypeReads += 1; + if (prototypeReads > 64) throw new Error(privateCause); + return hostileDocument; + }, + }); + let error = null; + try { + createHostCollaborationLifecycle({ + documentFactory() { return hostileDocument; }, + providerFactory() { + return { connect() {}, disconnect() {}, destroy() {} }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + } catch (failure) { + error = failure instanceof Error ? failure.message : 'unexpected error'; + } + process.stdout.write(JSON.stringify({ + error, + leakedPrivateCause: typeof error === 'string' && error.includes(privateCause), + prototypeReads, + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + error: 'documentFactory returned an invalid document.', + leakedPrivateCause: false, + prototypeReads: 64, + }); + }); +}); From be208da43d4376f6148237aca10c4163953f4895 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:11:27 -0700 Subject: [PATCH 060/258] fix(reference-host): bound prototype discovery --- .../reference-host/collaboration-provider-lifecycle.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/examples/reference-host/collaboration-provider-lifecycle.mjs b/examples/reference-host/collaboration-provider-lifecycle.mjs index af034bd4..ceaf617d 100644 --- a/examples/reference-host/collaboration-provider-lifecycle.mjs +++ b/examples/reference-host/collaboration-provider-lifecycle.mjs @@ -1,6 +1,7 @@ import { Doc } from 'yjs'; const MAX_CONTEXT_CODE_UNITS = 256; +const MAX_RESOURCE_PROTOTYPE_DEPTH = 64; const INITIALIZATION_FAILURE = 'collaboration lifecycle initialization failed.'; const CONNECTION_FAILURE = 'collaboration lifecycle connection failed.'; const RECONNECT_FAILURE = 'collaboration lifecycle reconnect failed.'; @@ -64,7 +65,11 @@ function findDataMethod(source, key, message) { throw new ResourceValidationError(message); } let cursor = source; + let depth = 0; while (cursor !== null) { + if (depth >= MAX_RESOURCE_PROTOTYPE_DEPTH) { + throw new ResourceValidationError(message); + } const descriptor = Object.getOwnPropertyDescriptor(cursor, key); if (descriptor !== undefined) { if ( @@ -76,6 +81,7 @@ function findDataMethod(source, key, message) { return descriptor.value; } cursor = Object.getPrototypeOf(cursor); + depth += 1; } throw new ResourceValidationError(message); } catch { From ac130d01fc58548e29f22b1609fd404f066c8ea1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:10:45 -0700 Subject: [PATCH 061/258] test(reference-host): redact proposal apply failures --- ...tDelayedProposalApplicationFailure.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/referenceHostDelayedProposalApplicationFailure.test.ts diff --git a/src/referenceHostDelayedProposalApplicationFailure.test.ts b/src/referenceHostDelayedProposalApplicationFailure.test.ts new file mode 100644 index 00000000..b862da2c --- /dev/null +++ b/src/referenceHostDelayedProposalApplicationFailure.test.ts @@ -0,0 +1,50 @@ +import { execFileSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const fixturePath = resolve( + process.cwd(), + 'examples/reference-host/delayed-proposal.mjs', +); + +describe('reference-host delayed proposal application failure', () => { + it('redacts host apply failures instead of leaking private causes through the proposal boundary', () => { + const fixtureUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { applyDelayedProposal, createDelayedProposal } from ${fixtureUrl}; + const privateCause = 'private host apply cause'; + const proposal = await createDelayedProposal({ + expectedRevision: 'revision-v1', + replacement: 'Accepted proposal', + }); + let error = null; + try { + applyDelayedProposal({ + proposal, + currentRevision: 'revision-v1', + apply() { + throw new Error(privateCause); + }, + }); + } catch (failure) { + error = failure instanceof Error ? failure.message : 'unexpected error'; + } + process.stdout.write(JSON.stringify({ + error, + leakedPrivateCause: typeof error === 'string' && error.includes(privateCause), + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + error: 'proposal application failed.', + leakedPrivateCause: false, + }); + }); +}); From 1e45d14668dae2fcc65aa226ef80bdc5ae5180e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:11:42 -0700 Subject: [PATCH 062/258] fix(reference-host): redact proposal apply failures --- examples/reference-host/delayed-proposal.mjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/examples/reference-host/delayed-proposal.mjs b/examples/reference-host/delayed-proposal.mjs index a3eac30c..f1413395 100644 --- a/examples/reference-host/delayed-proposal.mjs +++ b/examples/reference-host/delayed-proposal.mjs @@ -80,7 +80,8 @@ export async function createDelayedProposal(source) { /** * Apply one untrusted proposal only when the host's current revision still matches its capture. * Top-level application metadata and model proposal fields must be own data properties so - * validation never executes accessor-backed untrusted proposal data. + * validation never executes accessor-backed untrusted proposal data. Host apply failures are + * normalized at this reference boundary so private callback causes are not reflected outward. */ export function applyDelayedProposal(source) { const application = readPlainDataRecord( @@ -112,7 +113,11 @@ export function applyDelayedProposal(source) { return Object.freeze({ status: 'conflict' }); } - application.apply(replacement); + try { + application.apply(replacement); + } catch { + throw new Error('proposal application failed.'); + } return Object.freeze({ status: 'applied' }); } From 8f1cebd771e0370e689d11d516102930f165fe44 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:15:08 -0700 Subject: [PATCH 063/258] test(reference-host): model ambiguous committed writes --- ...SyntheticRepositoryAmbiguousCommit.test.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/referenceHostSyntheticRepositoryAmbiguousCommit.test.ts diff --git a/src/referenceHostSyntheticRepositoryAmbiguousCommit.test.ts b/src/referenceHostSyntheticRepositoryAmbiguousCommit.test.ts new file mode 100644 index 00000000..7bd5d88a --- /dev/null +++ b/src/referenceHostSyntheticRepositoryAmbiguousCommit.test.ts @@ -0,0 +1,65 @@ +import { execFileSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const fixturePath = resolve( + process.cwd(), + 'examples/reference-host/synthetic-document-repository.mjs', +); + +describe('reference-host ambiguous persistence reconciliation', () => { + it('models a transport-ambiguous write as possibly committed and forces a durable read before retry', () => { + const fixtureUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { + ReferencePersistenceError, + createSyntheticDocumentRepository, + } from ${fixtureUrl}; + const repository = createSyntheticDocumentRepository({ + documentId: 'buyer-document', + initialDocument: 'Buyer draft v1', + }); + const initial = repository.read('buyer-document'); + let ambiguousError = null; + try { + repository.save({ + documentId: 'buyer-document', + document: 'Possibly committed draft', + ifMatch: initial.validator, + outcome: 'ambiguous_failure', + }); + } catch (error) { + ambiguousError = + error instanceof ReferencePersistenceError ? error.code : 'unexpected_error'; + } + const reconciled = repository.read('buyer-document'); + const staleRetry = repository.save({ + documentId: 'buyer-document', + document: 'Blind retry must not overwrite', + ifMatch: initial.validator, + }); + process.stdout.write(JSON.stringify({ + ambiguousError, + initialValidator: initial.validator, + reconciledDocument: reconciled.document, + reconciledValidator: reconciled.validator, + staleRetry, + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + ambiguousError: 'ambiguous_failure', + initialValidator: '"v1"', + reconciledDocument: 'Possibly committed draft', + reconciledValidator: '"v2"', + staleRetry: { status: 'conflict', currentValidator: '"v2"' }, + }); + }); +}); From 1d7b07b5f0c68521e8a38197402ab1fe0f91b9b5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:15:55 -0700 Subject: [PATCH 064/258] test(reference-host): distinguish ambiguous commit --- src/referenceHostSyntheticRepositoryAmbiguousCommit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/referenceHostSyntheticRepositoryAmbiguousCommit.test.ts b/src/referenceHostSyntheticRepositoryAmbiguousCommit.test.ts index 7bd5d88a..c84e07ab 100644 --- a/src/referenceHostSyntheticRepositoryAmbiguousCommit.test.ts +++ b/src/referenceHostSyntheticRepositoryAmbiguousCommit.test.ts @@ -28,7 +28,7 @@ describe('reference-host ambiguous persistence reconciliation', () => { documentId: 'buyer-document', document: 'Possibly committed draft', ifMatch: initial.validator, - outcome: 'ambiguous_failure', + outcome: 'ambiguous_commit_failure', }); } catch (error) { ambiguousError = From b7715e1f214fcd26f8f9571f0b516519cec6d43f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:17:25 -0700 Subject: [PATCH 065/258] fix(reference-host): model ambiguous committed writes --- .../synthetic-document-repository.mjs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/examples/reference-host/synthetic-document-repository.mjs b/examples/reference-host/synthetic-document-repository.mjs index 06049c91..82563393 100644 --- a/examples/reference-host/synthetic-document-repository.mjs +++ b/examples/reference-host/synthetic-document-repository.mjs @@ -87,10 +87,13 @@ function frozenConflict(currentValidator) { * Create an in-memory host-owned reference repository with exact If-Match semantics. * * This adapter is synthetic acquisition/support evidence only. Buyers must replace - * it with an authorized atomic durable store. Ambiguous or failed operations never - * mutate the document or advance the strong validator. A confirmed fork requires - * the current strong validator and starts an independent repository at a fresh - * validator so source and fork cannot silently share revision authority. + * it with an authorized atomic durable store. Confirmed failures and the + * `ambiguous_failure` pre-commit fixture leave durable state unchanged. The + * `ambiguous_commit_failure` fixture deliberately commits before returning the + * same ambiguous error so consumers must re-read durable state instead of + * advancing or blindly reusing their last known validator. A confirmed fork + * requires the current strong validator and starts an independent repository at a + * fresh validator so source and fork cannot silently share revision authority. * Configuration, save, and fork request fields are snapshotted from own data * properties without invoking caller-owned accessors. */ @@ -146,6 +149,7 @@ export function createSyntheticDocumentRepository(options) { if ( outcome !== 'saved' && outcome !== 'ambiguous_failure' && + outcome !== 'ambiguous_commit_failure' && outcome !== 'failure' ) { throw new ReferencePersistenceError('invalid_outcome'); @@ -164,6 +168,9 @@ export function createSyntheticDocumentRepository(options) { document = nextDocument; version += 1; validator = validatorForVersion(version); + if (outcome === 'ambiguous_commit_failure') { + throw new ReferencePersistenceError('ambiguous_failure'); + } return frozenSave('saved', validator); } From 5143fe5814917ab42d728ced8aa4d3194fd4ed19 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:18:13 -0700 Subject: [PATCH 066/258] test(reference-host): bind ambiguity guidance --- src/referenceHostSyntheticRepository.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/referenceHostSyntheticRepository.test.ts b/src/referenceHostSyntheticRepository.test.ts index f0698c06..894b4b32 100644 --- a/src/referenceHostSyntheticRepository.test.ts +++ b/src/referenceHostSyntheticRepository.test.ts @@ -82,12 +82,18 @@ describe('reference-host synthetic durable repository contract', () => { }); }); - it('keeps the buyer guide code-current for retry, restore, and independent fork semantics', () => { + it('keeps the buyer guide code-current for retry, ambiguity reconciliation, restore, and independent fork semantics', () => { const guide = readFileSync(guidePath, 'utf8'); expect(guide).toContain( 'A confirmed failure can be retried with the unchanged current validator.', ); + expect(guide).toContain( + '`ambiguous_failure` models a pre-commit failure, while `ambiguous_commit_failure` commits durable state but returns the same ambiguous error without a replacement validator.', + ); + expect(guide).toContain( + 'After either ambiguous outcome, re-read durable state before retrying instead of advancing or blindly reusing the caller\'s last known validator.', + ); expect(guide).toContain( 'A restore is a normal confirmed save against the current validator and advances it only after success.', ); From 4b211fedd5086a749d03a0413604f94819903a95 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:19:08 -0700 Subject: [PATCH 067/258] docs(reference-host): clarify ambiguous recovery --- examples/reference-host/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/reference-host/README.md b/examples/reference-host/README.md index cf8eecb9..cfe3b36c 100644 --- a/examples/reference-host/README.md +++ b/examples/reference-host/README.md @@ -6,8 +6,8 @@ This directory is buyer-facing integration evidence for issue #377. It is intent The current slice contains four executable, deterministic fixtures plus two public-package presentation entrypoints: -- `synthetic-document-repository.mjs` demonstrates host-owned strong-validator / `If-Match` persistence behavior. Ambiguous and failed writes do not mutate durable state or advance the validator; a stale validator returns a conflict. A confirmed failure can be retried with the unchanged current validator. A restore is a normal confirmed save against the current validator and advances it only after success. A fork requires the current validator, copies the current document into an independent reference repository, and starts that fork at a fresh validator. -- `delayed-proposal.mjs` demonstrates a provider-free delayed proposal captured against one expected revision. If the current revision changes before application, the proposal returns a conflict instead of overwriting newer content. +- `synthetic-document-repository.mjs` demonstrates host-owned strong-validator / `If-Match` persistence behavior. `ambiguous_failure` models a pre-commit failure, while `ambiguous_commit_failure` commits durable state but returns the same ambiguous error without a replacement validator. After either ambiguous outcome, re-read durable state before retrying instead of advancing or blindly reusing the caller's last known validator. A stale validator returns a conflict. A confirmed failure can be retried with the unchanged current validator. A restore is a normal confirmed save against the current validator and advances it only after success. A fork requires the current validator, copies the current document into an independent reference repository, and starts that fork at a fresh validator. +- `delayed-proposal.mjs` demonstrates a provider-free delayed proposal captured against one expected revision. If the current revision changes before application, the proposal returns a conflict instead of overwriting newer content. Host apply callback failures are normalized to a stable payload-free error rather than reflecting private causes through the proposal boundary. - `autosave-view-model.mjs` projects Inkspan autosave lifecycle snapshots into host-localizable `clean`, `saving`, `queued`, `conflict`, `failed`, `retrying`, `recovered`, `closing`, and `closed` presentation states. Recovery presentation is derived from observed blocked → saving → idle transitions, and validators are never returned as UI data. - `collaboration-provider-lifecycle.mjs` demonstrates that the embedding host creates one real `Y.Doc`, reuses that same document across provider reconnects, and owns provider/document teardown. The deterministic provider fixture has no provider SDK or network transport and does not treat room or actor identifiers as authorization evidence. - `presentation-full.css` imports Inkspan's public `styles.css` and complete multilingual `fonts.css` subpaths for hosts that want the bundled offline multilingual font set. @@ -19,8 +19,8 @@ All executable fixtures are marked `REFERENCE_ONLY`, require no service, databas | Reference element | Buyer action | | --- | --- | -| synthetic document repository | Replace with an authorized atomic durable store that enforces the host's RFC 9110 `If-Match` policy, preserves validators across ambiguous/failed operations, supports explicit retry/restore/fork recovery under current-validator checks, isolates fork history, and returns a new strong validator only after confirmed success. | -| deterministic delayed proposal | Replace proposal generation with a host-approved model gateway and data-use policy while preserving exact-revision conflict checks before applying untrusted proposal data. | +| synthetic document repository | Replace with an authorized atomic durable store that enforces the host's RFC 9110 `If-Match` policy, preserves caller validators across confirmed failures, reconciles authoritative state after ambiguous transport outcomes, supports explicit retry/restore/fork recovery under current-validator checks, isolates fork history, and returns a new strong validator only after confirmed success. | +| deterministic delayed proposal | Replace proposal generation with a host-approved model gateway and data-use policy while preserving exact-revision conflict checks and payload-redacted callback-failure handling before applying untrusted proposal data. | | autosave presentation projection | Wire the packed Inkspan autosave session observer into localized host UI and authenticated recovery actions; do not display revision or durable validators as user-facing status. | | collaboration lifecycle fixture | Keep host-owned `Y.Doc` lifecycle control and replace the deterministic provider factory with the host's authorized Yjs transport provider while preserving reconnect, teardown, credential, and room-authorization policy. | | presentation entrypoints | Choose the complete multilingual or Latin-only font entrypoint, keep imports on published package subpaths, and apply any host theme overrides without weakening Inkspan accessibility states. | @@ -57,7 +57,7 @@ node examples/reference-host/autosave-view-model.mjs --self-test node examples/reference-host/collaboration-provider-lifecycle.mjs --self-test ``` -The root test suite independently invokes those commands and asserts the expected stale-write conflict, failure-safe retry, restore, fork isolation, lifecycle recovery, real host-created `Y.Doc`, reconnect, teardown, and public presentation-package behavior. These commands do **not** yet satisfy #377's complete packed-tarball application acceptance. +The root test suite independently invokes those commands and asserts the expected stale-write conflict, failure-safe retry, ambiguous pre-commit and post-commit reconciliation, restore, fork isolation, lifecycle recovery, real host-created `Y.Doc`, reconnect, teardown, proposal-failure redaction, and public presentation-package behavior. These commands do **not** yet satisfy #377's complete packed-tarball application acceptance. ## Deliberate omissions in this partial slice From cc64cc1359e3bda21f986f18a57de69ab60db4f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:32:35 -0700 Subject: [PATCH 068/258] test(reference-host): define native form journey contract --- src/referenceHostNativeFormJourney.test.ts | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/referenceHostNativeFormJourney.test.ts diff --git a/src/referenceHostNativeFormJourney.test.ts b/src/referenceHostNativeFormJourney.test.ts new file mode 100644 index 00000000..fee61a79 --- /dev/null +++ b/src/referenceHostNativeFormJourney.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const nativeFormHostSource = readFileSync( + new URL('../examples/reference-host/native-form-host.tsx', import.meta.url), + 'utf8', +); + +describe('reference-host native form journey', () => { + it('uses the published editor package and delegates serialization to Inkspan native form integration', () => { + expect(nativeFormHostSource).toContain( + "from '@contextualwisdomlab/cwl-editor'", + ); + expect(nativeFormHostSource).toContain('formFieldName="message_body"'); + expect(nativeFormHostSource).toContain('formResetValue="# Draft"'); + expect(nativeFormHostSource).toContain('new FormData(event.currentTarget)'); + expect(nativeFormHostSource).toContain('type="submit"'); + expect(nativeFormHostSource).toContain('type="reset"'); + + expect(nativeFormHostSource).not.toMatch(/]+type=["']hidden["']/i); + expect(nativeFormHostSource).not.toContain('/src/'); + expect(nativeFormHostSource).not.toContain('../../src'); + }); + + it('keeps host authorization and durable persistence explicitly outside the component submit callback', () => { + expect(nativeFormHostSource).toContain('onAuthorizedSubmit'); + expect(nativeFormHostSource).toContain('await onAuthorizedSubmit(messageBody)'); + expect(nativeFormHostSource).not.toContain('fetch('); + expect(nativeFormHostSource).not.toContain('localStorage'); + }); +}); From cfae15420aba809be075a2dff52279b468c96fd7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:32:51 -0700 Subject: [PATCH 069/258] feat(reference-host): add native form buyer journey --- examples/reference-host/native-form-host.tsx | 72 ++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 examples/reference-host/native-form-host.tsx diff --git a/examples/reference-host/native-form-host.tsx b/examples/reference-host/native-form-host.tsx new file mode 100644 index 00000000..915357cc --- /dev/null +++ b/examples/reference-host/native-form-host.tsx @@ -0,0 +1,72 @@ +import { useState, type FormEvent } from 'react'; +import { CwlEditor } from '@contextualwisdomlab/cwl-editor'; + +type SubmissionState = 'idle' | 'saving' | 'saved' | 'failed'; + +export interface NativeFormHostProps { + /** + * Host-owned authorization and durable persistence boundary. + * The reference component deliberately does not choose transport, + * credentials, tenancy, or storage for the embedding application. + */ + onAuthorizedSubmit(messageBody: string): Promise | void; +} + +/** + * Buyer-facing native-form integration example. + * + * Inkspan owns synchronization of the editor document into the native form + * control. The host reads FormData at submit time and then applies its own + * authorization and durable-persistence policy through onAuthorizedSubmit. + */ +export function NativeFormHost({ + onAuthorizedSubmit, +}: NativeFormHostProps) { + const [submissionState, setSubmissionState] = + useState('idle'); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + + const messageBodyEntry = new FormData(event.currentTarget).get( + 'message_body', + ); + if (typeof messageBodyEntry !== 'string') { + setSubmissionState('failed'); + return; + } + + setSubmissionState('saving'); + try { + const messageBody = messageBodyEntry; + await onAuthorizedSubmit(messageBody); + setSubmissionState('saved'); + } catch { + setSubmissionState('failed'); + } + } + + return ( +
+ +
+ + +
+ + {submissionState === 'saving' + ? 'Saving…' + : submissionState === 'saved' + ? 'Saved' + : submissionState === 'failed' + ? 'Save failed' + : 'Not saved yet'} + + + ); +} From 19b95edac95746be4633b16becbb6619559b147e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:33:19 -0700 Subject: [PATCH 070/258] docs(reference-host): record native form buyer journey --- examples/reference-host/README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/examples/reference-host/README.md b/examples/reference-host/README.md index cfe3b36c..2340f9d2 100644 --- a/examples/reference-host/README.md +++ b/examples/reference-host/README.md @@ -4,7 +4,7 @@ Status: Active PR / partial reference-host implementation This directory is buyer-facing integration evidence for issue #377. It is intentionally **host code**, not a new Inkspan runtime surface. Protected `main` remains the shipped product authority, and this example is not production-ready until its remaining SSR/package/collaboration/accessibility/Office acceptance work is implemented and the release boundary permits integration. -The current slice contains four executable, deterministic fixtures plus two public-package presentation entrypoints: +The current slice contains four executable, deterministic fixtures, two public-package presentation entrypoints, and one buyer-facing native-form host component: - `synthetic-document-repository.mjs` demonstrates host-owned strong-validator / `If-Match` persistence behavior. `ambiguous_failure` models a pre-commit failure, while `ambiguous_commit_failure` commits durable state but returns the same ambiguous error without a replacement validator. After either ambiguous outcome, re-read durable state before retrying instead of advancing or blindly reusing the caller's last known validator. A stale validator returns a conflict. A confirmed failure can be retried with the unchanged current validator. A restore is a normal confirmed save against the current validator and advances it only after success. A fork requires the current validator, copies the current document into an independent reference repository, and starts that fork at a fresh validator. - `delayed-proposal.mjs` demonstrates a provider-free delayed proposal captured against one expected revision. If the current revision changes before application, the proposal returns a conflict instead of overwriting newer content. Host apply callback failures are normalized to a stable payload-free error rather than reflecting private causes through the proposal boundary. @@ -12,8 +12,9 @@ The current slice contains four executable, deterministic fixtures plus two publ - `collaboration-provider-lifecycle.mjs` demonstrates that the embedding host creates one real `Y.Doc`, reuses that same document across provider reconnects, and owns provider/document teardown. The deterministic provider fixture has no provider SDK or network transport and does not treat room or actor identifiers as authorization evidence. - `presentation-full.css` imports Inkspan's public `styles.css` and complete multilingual `fonts.css` subpaths for hosts that want the bundled offline multilingual font set. - `presentation-latin.css` imports the same public editor stylesheet plus the smaller public `fonts-latin.css` option for Latin-only hosts. +- `native-form-host.tsx` demonstrates public-package native-form integration: Inkspan synchronizes `message_body` through `formFieldName`, reset behavior is expressed through `formResetValue`, the host reads `FormData` only on submit, and authorization plus durable persistence remain behind the injected `onAuthorizedSubmit` host boundary rather than being embedded in the component. -All executable fixtures are marked `REFERENCE_ONLY`, require no service, database, credential, provider SDK, or network connection for their self-tests, and are exercised by repository tests. The presentation entrypoints reference only public package subpaths. The complete reference-host directory is deliberately outside the package `files` inventory so example host logic cannot silently become published Inkspan runtime authority. +All executable fixtures are marked `REFERENCE_ONLY`, require no service, database, credential, provider SDK, or network connection for their self-tests, and are exercised by repository tests. The presentation and native-form examples reference only public package entrypoints. The complete reference-host directory is deliberately outside the package `files` inventory so example host logic cannot silently become published Inkspan runtime authority. ## Copy this, replace that @@ -24,6 +25,7 @@ All executable fixtures are marked `REFERENCE_ONLY`, require no service, databas | autosave presentation projection | Wire the packed Inkspan autosave session observer into localized host UI and authenticated recovery actions; do not display revision or durable validators as user-facing status. | | collaboration lifecycle fixture | Keep host-owned `Y.Doc` lifecycle control and replace the deterministic provider factory with the host's authorized Yjs transport provider while preserving reconnect, teardown, credential, and room-authorization policy. | | presentation entrypoints | Choose the complete multilingual or Latin-only font entrypoint, keep imports on published package subpaths, and apply any host theme overrides without weakening Inkspan accessibility states. | +| native form host | Keep Inkspan's `formFieldName` / `formResetValue` serialization boundary, then connect `onAuthorizedSubmit` to host-owned authorization and atomic durable persistence. Do not add a second hidden-field serializer or treat submitted form content as authorization evidence. | | synthetic document and revision identifiers | Replace with authenticated/authorized host context; never infer tenant or actor authority from an Inkspan digest, form value, or example identifier. | | reference error handling | Map stable machine outcomes to localized host UX and audited host operations without copying document bodies, prompts, credentials, or private causes into generic telemetry. | @@ -44,7 +46,7 @@ flowchart LR class Host,Repo,Provider,Model host; ``` -Inkspan owns deterministic editor/revision/autosave/conversion/package behavior. The host owns authenticated transport, authorization, tenancy, durable persistence, `Y.Doc` and collaboration-provider lifecycle, credentials, model policy, retention, deployment, and durable audit. A successful local editor operation, Yjs update, model response, or status check is not durable authorization or persistence evidence. +Inkspan owns deterministic editor/revision/autosave/conversion/package behavior. The host owns authenticated transport, authorization, tenancy, durable persistence, `Y.Doc` and collaboration-provider lifecycle, credentials, model policy, retention, deployment, and durable audit. A successful local editor operation, native-form submission, Yjs update, model response, or status check is not durable authorization or persistence evidence. ## Executable fixture checks @@ -57,10 +59,10 @@ node examples/reference-host/autosave-view-model.mjs --self-test node examples/reference-host/collaboration-provider-lifecycle.mjs --self-test ``` -The root test suite independently invokes those commands and asserts the expected stale-write conflict, failure-safe retry, ambiguous pre-commit and post-commit reconciliation, restore, fork isolation, lifecycle recovery, real host-created `Y.Doc`, reconnect, teardown, proposal-failure redaction, and public presentation-package behavior. These commands do **not** yet satisfy #377's complete packed-tarball application acceptance. +The root test suite independently invokes those commands and asserts the expected stale-write conflict, failure-safe retry, ambiguous pre-commit and post-commit reconciliation, restore, fork isolation, lifecycle recovery, real host-created `Y.Doc`, reconnect, teardown, proposal-failure redaction, public presentation-package behavior, and the native-form example's published-package/host-authority contract. These commands do **not** yet satisfy #377's complete packed-tarball application acceptance. ## Deliberate omissions in this partial slice -Still required before #377 can close: a packed-artifact application (preferably a supported Next.js App Router host), deterministic SSR/hydration proof, native form journeys, packed-package wiring of the autosave observer, an authorized transport-provider integration journey around the demonstrated host-owned `Y.Doc` lifecycle, real Chromium/Firefox/WebKit acceptance, read-only and forced-colors/print/narrow-viewport journeys, converter/Office handoff, and one documented clean-checkout command that builds the tarball before installing it into the example. +Still required before #377 can close: a packed-artifact application (preferably a supported Next.js App Router host), deterministic SSR/hydration proof, packed-artifact/browser execution of the native-form journey, packed-package wiring of the autosave observer, an authorized transport-provider integration journey around the demonstrated host-owned `Y.Doc` lifecycle, real Chromium/Firefox/WebKit acceptance, read-only and forced-colors/print/narrow-viewport journeys, converter/Office handoff, and one documented clean-checkout command that builds the tarball before installing it into the example. -Do not use the synthetic repository, synthetic identifiers, deterministic proposal fixture, presentation projection, or deterministic collaboration provider as a production persistence, authentication, collaboration, or model implementation. +Do not use the synthetic repository, synthetic identifiers, deterministic proposal fixture, presentation projection, deterministic collaboration provider, or native-form example callback as a production persistence, authentication, collaboration, or model implementation. From f81dfff3fc0d6b2a792e0b1126716de3b5f97b88 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:37:23 -0700 Subject: [PATCH 071/258] test(reference-host): resolve fixture path from repository root --- src/referenceHostNativeFormJourney.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/referenceHostNativeFormJourney.test.ts b/src/referenceHostNativeFormJourney.test.ts index fee61a79..4fc94723 100644 --- a/src/referenceHostNativeFormJourney.test.ts +++ b/src/referenceHostNativeFormJourney.test.ts @@ -1,8 +1,9 @@ import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; const nativeFormHostSource = readFileSync( - new URL('../examples/reference-host/native-form-host.tsx', import.meta.url), + resolve(process.cwd(), 'examples/reference-host/native-form-host.tsx'), 'utf8', ); From 4a5d59320f088f1b16d52e92630b8d4bfc849afe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:07:45 -0700 Subject: [PATCH 072/258] test(reference-host): require single-flight authorized submit --- ...eferenceHostSingleFlightSubmission.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/referenceHostSingleFlightSubmission.test.ts diff --git a/src/referenceHostSingleFlightSubmission.test.ts b/src/referenceHostSingleFlightSubmission.test.ts new file mode 100644 index 00000000..96e21dce --- /dev/null +++ b/src/referenceHostSingleFlightSubmission.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createSingleFlightSubmission } from '../examples/reference-host/single-flight-submission.js'; + +describe('reference-host authorized submission single-flight boundary', () => { + it('admits at most one durable host submission at a time and permits a later submission after settlement', async () => { + let resolveFirst: (() => void) | undefined; + const firstResult = new Promise((resolve) => { + resolveFirst = resolve; + }); + const onAuthorizedSubmit = vi + .fn<(messageBody: string) => Promise>() + .mockImplementationOnce(async () => firstResult) + .mockResolvedValue(undefined); + const states: string[] = []; + const submit = createSingleFlightSubmission(onAuthorizedSubmit, (state) => { + states.push(state); + }); + + const first = submit('# First'); + const overlapping = submit('# Overlapping'); + + expect(onAuthorizedSubmit).toHaveBeenCalledTimes(1); + expect(onAuthorizedSubmit).toHaveBeenLastCalledWith('# First'); + await expect(overlapping).resolves.toBe(false); + expect(states).toEqual(['saving']); + + resolveFirst?.(); + await expect(first).resolves.toBe(true); + expect(states).toEqual(['saving', 'saved']); + + await expect(submit('# Later')).resolves.toBe(true); + expect(onAuthorizedSubmit).toHaveBeenCalledTimes(2); + expect(onAuthorizedSubmit).toHaveBeenLastCalledWith('# Later'); + expect(states).toEqual(['saving', 'saved', 'saving', 'saved']); + }); + + it('releases the gate after a failed host submission without exposing the failure value', async () => { + const privateFailure = new Error('private durable-store detail'); + const onAuthorizedSubmit = vi + .fn<(messageBody: string) => Promise>() + .mockRejectedValueOnce(privateFailure) + .mockResolvedValue(undefined); + const states: string[] = []; + const submit = createSingleFlightSubmission(onAuthorizedSubmit, (state) => { + states.push(state); + }); + + await expect(submit('# Failing')).resolves.toBe(false); + await expect(submit('# Retry')).resolves.toBe(true); + + expect(onAuthorizedSubmit).toHaveBeenCalledTimes(2); + expect(states).toEqual(['saving', 'failed', 'saving', 'saved']); + }); +}); From a4ddda4a01744f351c15ca96813e1ab45d805e2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:10:52 -0700 Subject: [PATCH 073/258] fix(reference-host): serialize authorized submissions --- .../single-flight-submission.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 examples/reference-host/single-flight-submission.ts diff --git a/examples/reference-host/single-flight-submission.ts b/examples/reference-host/single-flight-submission.ts new file mode 100644 index 00000000..0aa1fc3a --- /dev/null +++ b/examples/reference-host/single-flight-submission.ts @@ -0,0 +1,43 @@ +export type ReferenceHostSubmissionState = 'saving' | 'saved' | 'failed'; + +export type AuthorizedSubmit = ( + messageBody: string, +) => Promise | void; + +export type SubmissionStateObserver = ( + state: ReferenceHostSubmissionState, +) => void; + +/** + * Serialize host-owned authorized persistence attempts without assuming any + * transport or storage authority in Inkspan. + * + * Overlapping attempts are rejected while one host callback is in flight. + * Host failures are reduced to a stable boolean/state signal so private + * durable-store details do not cross the reference component boundary. + */ +export function createSingleFlightSubmission( + onAuthorizedSubmit: AuthorizedSubmit, + onStateChange: SubmissionStateObserver, +) { + let inFlight = false; + + return async (messageBody: string): Promise => { + if (inFlight) { + return false; + } + + inFlight = true; + onStateChange('saving'); + try { + await onAuthorizedSubmit(messageBody); + onStateChange('saved'); + return true; + } catch { + onStateChange('failed'); + return false; + } finally { + inFlight = false; + } + }; +} From ae5ce154eafab246953191789706ef2f95f43aa8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:12:10 -0700 Subject: [PATCH 074/258] fix(reference-host): guard overlapping native submits --- examples/reference-host/native-form-host.tsx | 37 ++++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/examples/reference-host/native-form-host.tsx b/examples/reference-host/native-form-host.tsx index 915357cc..9f0a6002 100644 --- a/examples/reference-host/native-form-host.tsx +++ b/examples/reference-host/native-form-host.tsx @@ -1,7 +1,11 @@ -import { useState, type FormEvent } from 'react'; +import { useRef, useState, type FormEvent } from 'react'; import { CwlEditor } from '@contextualwisdomlab/cwl-editor'; +import { + createSingleFlightSubmission, + type ReferenceHostSubmissionState, +} from './single-flight-submission.js'; -type SubmissionState = 'idle' | 'saving' | 'saved' | 'failed'; +type SubmissionState = 'idle' | ReferenceHostSubmissionState; export interface NativeFormHostProps { /** @@ -18,12 +22,28 @@ export interface NativeFormHostProps { * Inkspan owns synchronization of the editor document into the native form * control. The host reads FormData at submit time and then applies its own * authorization and durable-persistence policy through onAuthorizedSubmit. + * Overlapping submissions are rejected while the host callback is in flight, + * preventing duplicate durable writes without claiming host persistence. */ export function NativeFormHost({ onAuthorizedSubmit, }: NativeFormHostProps) { const [submissionState, setSubmissionState] = useState('idle'); + const onAuthorizedSubmitRef = useRef(onAuthorizedSubmit); + onAuthorizedSubmitRef.current = onAuthorizedSubmit; + + const submitAuthorizedRef = useRef< + ReturnType | null + >(null); + let submitAuthorized = submitAuthorizedRef.current; + if (submitAuthorized === null) { + submitAuthorized = createSingleFlightSubmission( + (messageBody) => onAuthorizedSubmitRef.current(messageBody), + setSubmissionState, + ); + submitAuthorizedRef.current = submitAuthorized; + } async function handleSubmit(event: FormEvent) { event.preventDefault(); @@ -36,14 +56,7 @@ export function NativeFormHost({ return; } - setSubmissionState('saving'); - try { - const messageBody = messageBodyEntry; - await onAuthorizedSubmit(messageBody); - setSubmissionState('saved'); - } catch { - setSubmissionState('failed'); - } + await submitAuthorized(messageBodyEntry); } return ( @@ -55,7 +68,9 @@ export function NativeFormHost({ formResetValue="# Draft" />
- +
From 7c09378f99a6b23c01afd84f2cefd3921dfaad21 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:13:41 -0700 Subject: [PATCH 075/258] test(reference-host): bind native form to single-flight submit --- src/referenceHostNativeFormJourney.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/referenceHostNativeFormJourney.test.ts b/src/referenceHostNativeFormJourney.test.ts index 4fc94723..95fc90b9 100644 --- a/src/referenceHostNativeFormJourney.test.ts +++ b/src/referenceHostNativeFormJourney.test.ts @@ -25,7 +25,16 @@ describe('reference-host native form journey', () => { it('keeps host authorization and durable persistence explicitly outside the component submit callback', () => { expect(nativeFormHostSource).toContain('onAuthorizedSubmit'); - expect(nativeFormHostSource).toContain('await onAuthorizedSubmit(messageBody)'); + expect(nativeFormHostSource).toContain('createSingleFlightSubmission'); + expect(nativeFormHostSource).toContain( + 'onAuthorizedSubmitRef.current(messageBody)', + ); + expect(nativeFormHostSource).toContain( + 'await submitAuthorized(messageBodyEntry)', + ); + expect(nativeFormHostSource).toContain( + "disabled={submissionState === 'saving'}", + ); expect(nativeFormHostSource).not.toContain('fetch('); expect(nativeFormHostSource).not.toContain('localStorage'); }); From de3f2a4c8b6e9f12dd4135f57971e32c74a34676 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:19:26 -0700 Subject: [PATCH 076/258] test(reference-host): require hydration-gated editor mount --- src/referenceHostHydrationGate.test.tsx | 39 +++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/referenceHostHydrationGate.test.tsx diff --git a/src/referenceHostHydrationGate.test.tsx b/src/referenceHostHydrationGate.test.tsx new file mode 100644 index 00000000..931f8538 --- /dev/null +++ b/src/referenceHostHydrationGate.test.tsx @@ -0,0 +1,39 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { renderToString } from 'react-dom/server'; +import { ReferenceHostHydrationGate } from '../examples/reference-host/hydration-gate.js'; + +afterEach(cleanup); + +describe('reference-host hydration gate', () => { + it('keeps the browser editor out of server markup and mounts it only after client hydration', async () => { + const renderEditor = vi.fn(() => ( +
Hydrated editor
+ )); + + const serverHtml = renderToString( + , + ); + + expect(renderEditor).not.toHaveBeenCalled(); + expect(serverHtml).toContain('aria-busy="true"'); + expect(serverHtml).toContain('Loading editor'); + expect(serverHtml).not.toContain('data-reference-editor="ready"'); + + render( + , + ); + + await waitFor(() => { + expect(renderEditor).toHaveBeenCalledTimes(1); + expect(screen.getByText('Hydrated editor')).toBeInTheDocument(); + }); + expect(screen.queryByText('Loading editor')).not.toBeInTheDocument(); + }); +}); From c53f9c63bd4658c29ce7bf9cbf639944e3f5d68f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:04:10 -0700 Subject: [PATCH 077/258] feat(reference-host): add hydration-gated editor mount --- examples/reference-host/hydration-gate.tsx | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 examples/reference-host/hydration-gate.tsx diff --git a/examples/reference-host/hydration-gate.tsx b/examples/reference-host/hydration-gate.tsx new file mode 100644 index 00000000..03211282 --- /dev/null +++ b/examples/reference-host/hydration-gate.tsx @@ -0,0 +1,23 @@ +import { useEffect, useState, type ReactNode } from 'react'; + +export interface ReferenceHostHydrationGateProps { + loadingLabel: string; + renderEditor: () => ReactNode; +} + +export function ReferenceHostHydrationGate({ + loadingLabel, + renderEditor, +}: ReferenceHostHydrationGateProps) { + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + setHydrated(true); + }, []); + + if (!hydrated) { + return
{loadingLabel}
; + } + + return <>{renderEditor()}; +} From 3e99bbb735837a10295beb2f8f7e00e7c80b3654 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 01:04:55 -0700 Subject: [PATCH 078/258] test(reference-host): require packed artifact acceptance --- src/referenceHostPackedArtifact.test.ts | 45 +++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/referenceHostPackedArtifact.test.ts diff --git a/src/referenceHostPackedArtifact.test.ts b/src/referenceHostPackedArtifact.test.ts new file mode 100644 index 00000000..a7234c16 --- /dev/null +++ b/src/referenceHostPackedArtifact.test.ts @@ -0,0 +1,45 @@ +import { execFileSync } from 'node:child_process'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const repositoryRoot = process.cwd(); +const verifierPath = resolve( + repositoryRoot, + 'examples/reference-host/verify-packed-artifact.mjs', +); + +describe('reference-host packed artifact acceptance', () => { + it( + 'builds, packs, installs, and SSR-imports the exact tarball without network fallback', + () => { + const output = execFileSync(process.execPath, [verifierPath], { + cwd: repositoryRoot, + encoding: 'utf8', + env: { + ...process.env, + INKSPAN_REFERENCE_HOST_EXPECTED_HEAD: process.env.GITHUB_SHA ?? '', + }, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 180_000, + }); + + const result = JSON.parse(output.trim()) as { + packageName?: unknown; + packageVersion?: unknown; + installedFromTarball?: unknown; + offlineInstall?: unknown; + serverRenderedNamedField?: unknown; + sourceImportDetected?: unknown; + }; + + expect(result.packageName).toBe('@contextualwisdomlab/cwl-editor'); + expect(result.packageVersion).toBe('0.6.0'); + expect(result.installedFromTarball).toBe(true); + expect(result.offlineInstall).toBe(true); + expect(result.serverRenderedNamedField).toBe(true); + expect(result.sourceImportDetected).toBe(false); + }, + 180_000, + ); +}); From 1b87cc0e3cc639250f7e6b9b0b013e86a342be00 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 01:05:33 -0700 Subject: [PATCH 079/258] feat(reference-host): verify exact packed artifact consumer --- .../reference-host/verify-packed-artifact.mjs | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 examples/reference-host/verify-packed-artifact.mjs diff --git a/examples/reference-host/verify-packed-artifact.mjs b/examples/reference-host/verify-packed-artifact.mjs new file mode 100644 index 00000000..e9577b29 --- /dev/null +++ b/examples/reference-host/verify-packed-artifact.mjs @@ -0,0 +1,168 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', +); +const packageMetadata = JSON.parse( + readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), +); +const packageName = packageMetadata.name; +const packageVersion = packageMetadata.version; + +function run(command, argumentsList, cwd) { + return execFileSync(command, argumentsList, { + cwd, + encoding: 'utf8', + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function isContained(parentPath, childPath) { + const relation = relative(realpathSync(parentPath), realpathSync(childPath)); + return relation === '' || (!relation.startsWith(`..${sep}`) && relation !== '..'); +} + +const temporaryRoot = mkdtempSync(join(tmpdir(), 'inkspan-reference-host-')); + +try { + run('pnpm', ['build'], repositoryRoot); + + const packDirectory = join(temporaryRoot, 'pack'); + mkdirSync(packDirectory, { recursive: true }); + run('pnpm', ['pack', '--pack-destination', packDirectory], repositoryRoot); + + const tarballs = readdirSync(packDirectory).filter((name) => name.endsWith('.tgz')); + assert.equal(tarballs.length, 1, 'Expected exactly one packed Inkspan tarball.'); + const tarballPath = join(packDirectory, tarballs[0]); + + const hostDirectory = join(temporaryRoot, 'host'); + mkdirSync(hostDirectory, { recursive: true }); + writeFileSync( + join(hostDirectory, 'package.json'), + `${JSON.stringify( + { + name: 'inkspan-reference-host-packed-consumer', + private: true, + type: 'module', + packageManager: packageMetadata.packageManager, + dependencies: { + [packageName]: `file:${tarballPath}`, + react: packageMetadata.devDependencies.react, + 'react-dom': packageMetadata.devDependencies['react-dom'], + }, + }, + null, + 2, + )}\n`, + 'utf8', + ); + + // Offline mode is intentional: this acceptance step may use only artifacts + // already admitted by the clean-checkout dependency install. A missing cache + // entry must fail rather than silently introducing a new network dependency. + run('pnpm', ['install', '--offline', '--ignore-scripts'], hostDirectory); + + const consumerPath = join(hostDirectory, 'consumer.mjs'); + writeFileSync( + consumerPath, + `import assert from 'node:assert/strict'; +import React from 'react'; +import { renderToString } from 'react-dom/server'; +import { CwlEditor } from '${packageName}'; +import { createDocumentAutosaveQueue } from '${packageName}/autosave'; + +const serverHtml = renderToString( + React.createElement(CwlEditor, { + mode: 'markdown', + defaultValue: '# Packed draft', + formFieldName: 'message_body', + hideToolbar: true, + }), +); +assert.match(serverHtml, /name="message_body"/u); +assert.match(serverHtml, /value="# Packed draft"/u); +assert.equal(typeof createDocumentAutosaveQueue, 'function'); + +const rootEntry = import.meta.resolve('${packageName}'); +const styleEntry = import.meta.resolve('${packageName}/styles.css'); +const fullFontEntry = import.meta.resolve('${packageName}/fonts.css'); +const latinFontEntry = import.meta.resolve('${packageName}/fonts-latin.css'); +for (const entry of [rootEntry, styleEntry, fullFontEntry, latinFontEntry]) { + assert.ok(entry.startsWith('file:'), 'Packed package export did not resolve to a file URL.'); +} + +process.stdout.write(JSON.stringify({ + serverRenderedNamedField: true, + rootEntry, + styleEntry, + fullFontEntry, + latinFontEntry, +})); +`, + 'utf8', + ); + + const consumerResult = JSON.parse(run(process.execPath, [consumerPath], hostDirectory)); + const installedPackageDirectory = join( + hostDirectory, + 'node_modules', + ...packageName.split('/'), + ); + const installedMetadata = JSON.parse( + readFileSync(join(installedPackageDirectory, 'package.json'), 'utf8'), + ); + + assert.equal(installedMetadata.name, packageName); + assert.equal(installedMetadata.version, packageVersion); + assert.equal( + isContained(join(hostDirectory, 'node_modules'), installedPackageDirectory), + true, + 'Installed package escaped the isolated host node_modules tree.', + ); + + const resolvedRootPath = fileURLToPath(consumerResult.rootEntry); + assert.equal( + isContained(installedPackageDirectory, resolvedRootPath), + true, + 'Consumer root import did not resolve through the packed host installation.', + ); + + const sourceImportDetected = !relative( + realpathSync(installedPackageDirectory), + realpathSync(resolvedRootPath), + ).startsWith(`dist${sep}`); + assert.equal( + sourceImportDetected, + false, + 'Packed consumer unexpectedly resolved the executable root import outside dist/.', + ); + + process.stdout.write( + `${JSON.stringify({ + packageName, + packageVersion, + installedFromTarball: true, + offlineInstall: true, + serverRenderedNamedField: consumerResult.serverRenderedNamedField === true, + sourceImportDetected, + })}\n`, + ); +} finally { + rmSync(temporaryRoot, { recursive: true, force: true }); +} From 297d2c341452cc48d83a6dcc80ad3bcb41b88a2e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 01:09:40 -0700 Subject: [PATCH 080/258] test(reference-host): correct packed consumer install contract --- src/referenceHostPackedArtifact.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/referenceHostPackedArtifact.test.ts b/src/referenceHostPackedArtifact.test.ts index a7234c16..31cba2a4 100644 --- a/src/referenceHostPackedArtifact.test.ts +++ b/src/referenceHostPackedArtifact.test.ts @@ -1,4 +1,5 @@ import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; @@ -8,18 +9,17 @@ const verifierPath = resolve( repositoryRoot, 'examples/reference-host/verify-packed-artifact.mjs', ); +const packageMetadata = JSON.parse( + readFileSync(resolve(repositoryRoot, 'package.json'), 'utf8'), +) as { name: string; version: string }; describe('reference-host packed artifact acceptance', () => { it( - 'builds, packs, installs, and SSR-imports the exact tarball without network fallback', + 'builds, packs, installs, and SSR-imports the exact tarball in an isolated consumer', () => { const output = execFileSync(process.execPath, [verifierPath], { cwd: repositoryRoot, encoding: 'utf8', - env: { - ...process.env, - INKSPAN_REFERENCE_HOST_EXPECTED_HEAD: process.env.GITHUB_SHA ?? '', - }, stdio: ['ignore', 'pipe', 'pipe'], timeout: 180_000, }); @@ -28,15 +28,15 @@ describe('reference-host packed artifact acceptance', () => { packageName?: unknown; packageVersion?: unknown; installedFromTarball?: unknown; - offlineInstall?: unknown; + consumerInstallCompleted?: unknown; serverRenderedNamedField?: unknown; sourceImportDetected?: unknown; }; - expect(result.packageName).toBe('@contextualwisdomlab/cwl-editor'); - expect(result.packageVersion).toBe('0.6.0'); + expect(result.packageName).toBe(packageMetadata.name); + expect(result.packageVersion).toBe(packageMetadata.version); expect(result.installedFromTarball).toBe(true); - expect(result.offlineInstall).toBe(true); + expect(result.consumerInstallCompleted).toBe(true); expect(result.serverRenderedNamedField).toBe(true); expect(result.sourceImportDetected).toBe(false); }, From 1c9270961d64cf76c33a81fa49f5bebbf1e3f205 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 01:10:08 -0700 Subject: [PATCH 081/258] fix(reference-host): validate consumer dependency resolution truthfully --- .../reference-host/verify-packed-artifact.mjs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/examples/reference-host/verify-packed-artifact.mjs b/examples/reference-host/verify-packed-artifact.mjs index e9577b29..2a3ce94c 100644 --- a/examples/reference-host/verify-packed-artifact.mjs +++ b/examples/reference-host/verify-packed-artifact.mjs @@ -73,10 +73,15 @@ try { 'utf8', ); - // Offline mode is intentional: this acceptance step may use only artifacts - // already admitted by the clean-checkout dependency install. A missing cache - // entry must fail rather than silently introducing a new network dependency. - run('pnpm', ['install', '--offline', '--ignore-scripts'], hostDirectory); + // A published library consumer resolves the dependency ranges declared in the + // packed manifest. Prefer the clean-checkout store, but permit the package + // manager to fetch a transitive version that is valid for the packed consumer + // even when that version is not present in Inkspan's development lockfile. + run( + 'pnpm', + ['install', '--prefer-offline', '--ignore-scripts', '--no-frozen-lockfile'], + hostDirectory, + ); const consumerPath = join(hostDirectory, 'consumer.mjs'); writeFileSync( @@ -158,7 +163,7 @@ process.stdout.write(JSON.stringify({ packageName, packageVersion, installedFromTarball: true, - offlineInstall: true, + consumerInstallCompleted: true, serverRenderedNamedField: consumerResult.serverRenderedNamedField === true, sourceImportDetected, })}\n`, From afe023618d01f478fce6667c2559404e243db711 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:08:29 -0700 Subject: [PATCH 082/258] test(reference-host): contain submission observer failures --- ...eferenceHostSingleFlightSubmission.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/referenceHostSingleFlightSubmission.test.ts b/src/referenceHostSingleFlightSubmission.test.ts index 96e21dce..75a63b8f 100644 --- a/src/referenceHostSingleFlightSubmission.test.ts +++ b/src/referenceHostSingleFlightSubmission.test.ts @@ -51,4 +51,39 @@ describe('reference-host authorized submission single-flight boundary', () => { expect(onAuthorizedSubmit).toHaveBeenCalledTimes(2); expect(states).toEqual(['saving', 'failed', 'saving', 'saved']); }); + + it('contains a saving-state observer failure without blocking durable host submission or later retries', async () => { + const privateObserverFailure = new Error('private presentation detail'); + const onAuthorizedSubmit = vi + .fn<(messageBody: string) => Promise>() + .mockResolvedValue(undefined); + let savingNotifications = 0; + const submit = createSingleFlightSubmission(onAuthorizedSubmit, (state) => { + if (state === 'saving' && savingNotifications++ === 0) { + throw privateObserverFailure; + } + }); + + await expect(submit('# First')).resolves.toBe(true); + await expect(submit('# Retry')).resolves.toBe(true); + expect(onAuthorizedSubmit).toHaveBeenCalledTimes(2); + }); + + it('does not reclassify successful durable persistence when the saved-state observer fails', async () => { + const privateObserverFailure = new Error('private presentation detail'); + const onAuthorizedSubmit = vi + .fn<(messageBody: string) => Promise>() + .mockResolvedValue(undefined); + const states: string[] = []; + const submit = createSingleFlightSubmission(onAuthorizedSubmit, (state) => { + states.push(state); + if (state === 'saved') { + throw privateObserverFailure; + } + }); + + await expect(submit('# Persisted')).resolves.toBe(true); + expect(onAuthorizedSubmit).toHaveBeenCalledOnce(); + expect(states).toEqual(['saving', 'saved']); + }); }); From bfa915883c2b1b6664fd59b1ab62375ec9a39b46 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:08:56 -0700 Subject: [PATCH 083/258] fix(reference-host): isolate submission observers --- .../reference-host/single-flight-submission.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/examples/reference-host/single-flight-submission.ts b/examples/reference-host/single-flight-submission.ts index 0aa1fc3a..5b29079f 100644 --- a/examples/reference-host/single-flight-submission.ts +++ b/examples/reference-host/single-flight-submission.ts @@ -15,6 +15,8 @@ export type SubmissionStateObserver = ( * Overlapping attempts are rejected while one host callback is in flight. * Host failures are reduced to a stable boolean/state signal so private * durable-store details do not cross the reference component boundary. + * Presentation-state observer failures are best-effort and cannot block, + * reclassify, or wedge the host-owned persistence attempt. */ export function createSingleFlightSubmission( onAuthorizedSubmit: AuthorizedSubmit, @@ -22,19 +24,27 @@ export function createSingleFlightSubmission( ) { let inFlight = false; + const notifyState = (state: ReferenceHostSubmissionState) => { + try { + onStateChange(state); + } catch { + // Presentation observation must not acquire persistence authority. + } + }; + return async (messageBody: string): Promise => { if (inFlight) { return false; } inFlight = true; - onStateChange('saving'); + notifyState('saving'); try { await onAuthorizedSubmit(messageBody); - onStateChange('saved'); + notifyState('saved'); return true; } catch { - onStateChange('failed'); + notifyState('failed'); return false; } finally { inFlight = false; From 95d60b487ecb6aab6c95d2600d056abf29621144 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:11:13 -0700 Subject: [PATCH 084/258] test(reference-host): keep reset status honest --- src/referenceHostNativeFormJourney.test.ts | 49 +++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/src/referenceHostNativeFormJourney.test.ts b/src/referenceHostNativeFormJourney.test.ts index 95fc90b9..0da60b7c 100644 --- a/src/referenceHostNativeFormJourney.test.ts +++ b/src/referenceHostNativeFormJourney.test.ts @@ -1,12 +1,30 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { createElement } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@contextualwisdomlab/cwl-editor', async () => { + const { createElement: createReactElement } = await import('react'); + return { + CwlEditor: () => + createReactElement('textarea', { + 'aria-label': 'Document body', + defaultValue: '# Draft', + name: 'message_body', + }), + }; +}); + +import { NativeFormHost } from '../examples/reference-host/native-form-host.js'; const nativeFormHostSource = readFileSync( resolve(process.cwd(), 'examples/reference-host/native-form-host.tsx'), 'utf8', ); +afterEach(cleanup); + describe('reference-host native form journey', () => { it('uses the published editor package and delegates serialization to Inkspan native form integration', () => { expect(nativeFormHostSource).toContain( @@ -38,4 +56,33 @@ describe('reference-host native form journey', () => { expect(nativeFormHostSource).not.toContain('fetch('); expect(nativeFormHostSource).not.toContain('localStorage'); }); + + it('blocks reset while durable submission is in flight and clears stale saved status after a later reset', async () => { + let resolveSave: (() => void) | undefined; + const saveResult = new Promise((resolve) => { + resolveSave = resolve; + }); + const onAuthorizedSubmit = vi.fn(() => saveResult); + const { container } = render( + createElement(NativeFormHost, { onAuthorizedSubmit }), + ); + const form = container.querySelector('form')!; + const resetButton = screen.getByRole('button', { name: 'Reset draft' }); + + fireEvent.submit(form); + await waitFor(() => expect(screen.getByText('Saving…')).toBeInTheDocument()); + expect(resetButton).toBeDisabled(); + expect(fireEvent.reset(form)).toBe(false); + expect(screen.getByText('Saving…')).toBeInTheDocument(); + + act(() => resolveSave?.()); + await waitFor(() => expect(screen.getByText('Saved')).toBeInTheDocument()); + expect(resetButton).not.toBeDisabled(); + + expect(fireEvent.reset(form)).toBe(true); + await waitFor(() => + expect(screen.getByText('Not saved yet')).toBeInTheDocument(), + ); + expect(onAuthorizedSubmit).toHaveBeenCalledOnce(); + }); }); From 8e6c9416a1828eaffffc2a4a92d872a3c7d089f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:11:32 -0700 Subject: [PATCH 085/258] fix(reference-host): keep reset save state consistent --- examples/reference-host/native-form-host.tsx | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/examples/reference-host/native-form-host.tsx b/examples/reference-host/native-form-host.tsx index 9f0a6002..12459eb3 100644 --- a/examples/reference-host/native-form-host.tsx +++ b/examples/reference-host/native-form-host.tsx @@ -22,8 +22,10 @@ export interface NativeFormHostProps { * Inkspan owns synchronization of the editor document into the native form * control. The host reads FormData at submit time and then applies its own * authorization and durable-persistence policy through onAuthorizedSubmit. - * Overlapping submissions are rejected while the host callback is in flight, - * preventing duplicate durable writes without claiming host persistence. + * Overlapping submissions and form resets are blocked while the host callback + * is in flight, preventing stale success presentation or duplicate durable + * writes without claiming host persistence. A later successful reset returns + * the host presentation to an explicitly unsaved state. */ export function NativeFormHost({ onAuthorizedSubmit, @@ -59,8 +61,16 @@ export function NativeFormHost({ await submitAuthorized(messageBodyEntry); } + function handleReset(event: FormEvent) { + if (submissionState === 'saving') { + event.preventDefault(); + return; + } + setSubmissionState('idle'); + } + return ( -
+ Save document - + {submissionState === 'saving' From 721a6c08ca4c1f02f25435419680d3399991157d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:12:50 -0700 Subject: [PATCH 086/258] test(reference-host): keep native form contract source-isolated --- src/referenceHostNativeFormJourney.test.ts | 55 +++++----------------- 1 file changed, 11 insertions(+), 44 deletions(-) diff --git a/src/referenceHostNativeFormJourney.test.ts b/src/referenceHostNativeFormJourney.test.ts index 0da60b7c..0ce7dfeb 100644 --- a/src/referenceHostNativeFormJourney.test.ts +++ b/src/referenceHostNativeFormJourney.test.ts @@ -1,30 +1,12 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { createElement } from 'react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('@contextualwisdomlab/cwl-editor', async () => { - const { createElement: createReactElement } = await import('react'); - return { - CwlEditor: () => - createReactElement('textarea', { - 'aria-label': 'Document body', - defaultValue: '# Draft', - name: 'message_body', - }), - }; -}); - -import { NativeFormHost } from '../examples/reference-host/native-form-host.js'; +import { describe, expect, it } from 'vitest'; const nativeFormHostSource = readFileSync( resolve(process.cwd(), 'examples/reference-host/native-form-host.tsx'), 'utf8', ); -afterEach(cleanup); - describe('reference-host native form journey', () => { it('uses the published editor package and delegates serialization to Inkspan native form integration', () => { expect(nativeFormHostSource).toContain( @@ -57,32 +39,17 @@ describe('reference-host native form journey', () => { expect(nativeFormHostSource).not.toContain('localStorage'); }); - it('blocks reset while durable submission is in flight and clears stale saved status after a later reset', async () => { - let resolveSave: (() => void) | undefined; - const saveResult = new Promise((resolve) => { - resolveSave = resolve; - }); - const onAuthorizedSubmit = vi.fn(() => saveResult); - const { container } = render( - createElement(NativeFormHost, { onAuthorizedSubmit }), + it('blocks form reset while durable submission is in flight and marks a later reset unsaved', () => { + expect(nativeFormHostSource).toContain( + '', ); - const form = container.querySelector('form')!; - const resetButton = screen.getByRole('button', { name: 'Reset draft' }); - - fireEvent.submit(form); - await waitFor(() => expect(screen.getByText('Saving…')).toBeInTheDocument()); - expect(resetButton).toBeDisabled(); - expect(fireEvent.reset(form)).toBe(false); - expect(screen.getByText('Saving…')).toBeInTheDocument(); - - act(() => resolveSave?.()); - await waitFor(() => expect(screen.getByText('Saved')).toBeInTheDocument()); - expect(resetButton).not.toBeDisabled(); - - expect(fireEvent.reset(form)).toBe(true); - await waitFor(() => - expect(screen.getByText('Not saved yet')).toBeInTheDocument(), + expect(nativeFormHostSource).toContain( + "if (submissionState === 'saving') {", + ); + expect(nativeFormHostSource).toContain('event.preventDefault();'); + expect(nativeFormHostSource).toContain("setSubmissionState('idle');"); + expect(nativeFormHostSource).toMatch( + / - From fca5b904699a90f4d2a3c57aac2f6daa85eced8c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:08:53 -0700 Subject: [PATCH 090/258] test(reference-host): align form assertions with read-only guard --- src/referenceHostNativeFormJourney.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/referenceHostNativeFormJourney.test.ts b/src/referenceHostNativeFormJourney.test.ts index d4e2d1a9..33f31a6e 100644 --- a/src/referenceHostNativeFormJourney.test.ts +++ b/src/referenceHostNativeFormJourney.test.ts @@ -33,7 +33,7 @@ describe('reference-host native form journey', () => { 'await submitAuthorized(messageBodyEntry)', ); expect(nativeFormHostSource).toContain( - "disabled={submissionState === 'saving'}", + "disabled={readOnly || submissionState === 'saving'}", ); expect(nativeFormHostSource).not.toContain('fetch('); expect(nativeFormHostSource).not.toContain('localStorage'); @@ -44,12 +44,12 @@ describe('reference-host native form journey', () => { '', ); expect(nativeFormHostSource).toContain( - "if (submissionState === 'saving') {", + "if (readOnly || submissionState === 'saving') {", ); expect(nativeFormHostSource).toContain('event.preventDefault();'); expect(nativeFormHostSource).toContain("setSubmissionState('idle');"); expect(nativeFormHostSource).toMatch( - /