From db5fc365f847876e1f32e6992dc269db974e7f98 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:14:26 -0700 Subject: [PATCH 01/63] test(review): define public review package contract --- src/reviewPackageContract.test.ts | 34 +++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/reviewPackageContract.test.ts diff --git a/src/reviewPackageContract.test.ts b/src/reviewPackageContract.test.ts new file mode 100644 index 00000000..aaeba0df --- /dev/null +++ b/src/reviewPackageContract.test.ts @@ -0,0 +1,34 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +interface PackageManifest { + exports?: Record< + string, + { + types?: string; + import?: string; + require?: string; + } + >; + scripts?: Record; +} + +const manifest = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +) as PackageManifest; + +describe('review package contract', () => { + it('publishes a dedicated React-free review subpath', () => { + expect(manifest.exports?.['./review']).toEqual({ + types: './dist/review/index.d.ts', + import: './dist/cwl-review.js', + require: './dist/cwl-review.cjs', + }); + expect(manifest.scripts?.build).toContain( + 'vite build --config vite.review.config.ts', + ); + expect(manifest.scripts?.['verify:package']).toContain( + 'scripts/verify-review-package.mjs', + ); + }); +}); From 3c363909fcfa07db8f7df7362efc916872d0de56 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:21:25 -0700 Subject: [PATCH 02/63] test(review): make package RED runner-realistic --- src/reviewPackageContract.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/reviewPackageContract.test.ts b/src/reviewPackageContract.test.ts index aaeba0df..c95fa1cb 100644 --- a/src/reviewPackageContract.test.ts +++ b/src/reviewPackageContract.test.ts @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; interface PackageManifest { @@ -14,7 +15,7 @@ interface PackageManifest { } const manifest = JSON.parse( - readFileSync(new URL('../package.json', import.meta.url), 'utf8'), + readFileSync(resolve(process.cwd(), 'package.json'), 'utf8'), ) as PackageManifest; describe('review package contract', () => { From 4f2332c9a678e720a50206eb094679e5e56a31c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:24:01 -0700 Subject: [PATCH 03/63] feat(review): expose revision-bound review contract --- src/review/index.ts | 52 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/review/index.ts diff --git a/src/review/index.ts b/src/review/index.ts new file mode 100644 index 00000000..e60f74db --- /dev/null +++ b/src/review/index.ts @@ -0,0 +1,52 @@ +/** + * React-free review contract surface. + * + * Inkspan owns deterministic review targets bound to an exact canonical + * document revision and text projection. Hosts own durable review records, + * identity, authorization, tenancy, persistence, retention, notifications, + * audit, and cross-revision re-anchoring policy. + */ + +import type { CwlEditorDocumentRevision } from '../documentEnvelopeRevision.js'; +import type { + CwlEditorTextPositionSelector, + CwlEditorTextProjectionIdentity, +} from '../textPositionSelectorEvidence.js'; + +/** Version of Inkspan's deterministic review-target contract. */ +export const INKSPAN_REVIEW_CONTRACT_VERSION = 1 as const; + +/** + * Immutable target for a host-owned comment or suggestion. + * + * Positions are W3C TextPositionSelector offsets in Inkspan's canonical text + * projection, never DOM offsets. The revision validator prevents a host from + * silently applying a target to a different document revision. + */ +export interface CwlReviewTarget { + readonly contractVersion: typeof INKSPAN_REVIEW_CONTRACT_VERSION; + readonly revision: CwlEditorDocumentRevision; + readonly selector: CwlEditorTextPositionSelector; + readonly projection: CwlEditorTextProjectionIdentity; +} + +export { + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, + TextPositionSelectorEvidenceError, + createTextPositionSelector, +} from '../textPositionSelectorEvidence.js'; +export type { + CwlEditorTextPositionSelector, + CwlEditorTextProjectionIdentity, + TextPositionSelectorEvidenceErrorCode, +} from '../textPositionSelectorEvidence.js'; +export { + DocumentEnvelopeRevisionError, + createDocumentEnvelopeRevision, + createDocumentEnvelopeRevisionBytes, +} from '../documentEnvelopeRevision.js'; +export type { + CwlEditorDocumentRevision, + DocumentEnvelopeDigestProvider, +} from '../documentEnvelopeRevision.js'; From 897333e485c517055528f3193856483626352291 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:26:34 -0700 Subject: [PATCH 04/63] feat(review): add React-free review bundle --- vite.review.config.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 vite.review.config.ts diff --git a/vite.review.config.ts b/vite.review.config.ts new file mode 100644 index 00000000..420c5ff7 --- /dev/null +++ b/vite.review.config.ts @@ -0,0 +1,31 @@ +import { resolve } from 'node:path'; +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +// React-free review contract build: deterministic revision/selector evidence only. +// No React UI, network, credential, persistence, tenancy, model, or provider authority. +export default defineConfig({ + plugins: [ + dts({ + include: [ + 'src/review', + 'src/documentEnvelopeRevision.ts', + 'src/textPositionSelectorEvidence.ts', + ], + exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts'], + rollupTypes: false, + entryRoot: 'src', + }), + ], + build: { + emptyOutDir: false, + lib: { + entry: resolve(__dirname, 'src/review/index.ts'), + name: 'InkspanReview', + fileName: (format) => + format === 'es' ? 'cwl-review.js' : 'cwl-review.cjs', + formats: ['es', 'cjs'], + }, + sourcemap: true, + }, +}); From 6e91833386a2ebba55634d31b31d9e98478be47d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:27:09 -0700 Subject: [PATCH 05/63] test(review): verify packed review subpath --- scripts/verify-review-package.mjs | 202 ++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 scripts/verify-review-package.mjs diff --git a/scripts/verify-review-package.mjs b/scripts/verify-review-package.mjs new file mode 100644 index 00000000..085a5f61 --- /dev/null +++ b/scripts/verify-review-package.mjs @@ -0,0 +1,202 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageJson = JSON.parse( + readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), +); +const verificationRoot = mkdtempSync(join(tmpdir(), 'inkspan-review-')); +const extractionDirectory = join(verificationRoot, 'extracted'); +const consumerDirectory = join(verificationRoot, 'consumer'); +const packageDirectory = join( + consumerDirectory, + 'node_modules', + ...packageJson.name.split('/'), +); + +const dynamicLoaderPattern = /(?:\bimport\s*\(|\brequire\s*\()/u; +const externalRuntimeImportPattern = + /(?:\bimport\s+(?:[^'";]*?\sfrom\s*)?['"][^'"]+['"]|\bexport\s+[^'";]*?\sfrom\s*['"][^'"]+['"])/u; +const ambientAuthorityPattern = + /(?:\bfetch\s*\(|\bXMLHttpRequest\b|\bWebSocket\b|\bEventSource\b|\bprocess\.env\b|\bimport\.meta\.env\b|\bDeno\.env\b|\bBun\.env\b)/u; + +function run(command, argumentsList, cwd = repositoryRoot) { + return execFileSync(command, argumentsList, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }); +} + +function preparePackage() { + mkdirSync(extractionDirectory, { recursive: true }); + mkdirSync(dirname(packageDirectory), { recursive: true }); + const packOutput = run('npm', [ + 'pack', + '--json', + '--ignore-scripts', + '--pack-destination', + verificationRoot, + ]); + const packResult = JSON.parse(packOutput)[0]; + assert.equal(packResult.name, packageJson.name); + assert.equal(packResult.version, packageJson.version); + const tarballPath = join(verificationRoot, packResult.filename); + assert.ok(existsSync(tarballPath)); + run('tar', ['-xzf', tarballPath, '-C', extractionDirectory]); + renameSync(join(extractionDirectory, 'package'), packageDirectory); + writeFileSync( + join(consumerDirectory, 'package.json'), + '{"name":"inkspan-review-consumer","private":true,"type":"module"}\n', + 'utf8', + ); + + const repositoryTiptap = join(repositoryRoot, 'node_modules', '@tiptap'); + const consumerTiptap = join(consumerDirectory, 'node_modules', '@tiptap'); + assert.ok(existsSync(repositoryTiptap)); + symlinkSync(repositoryTiptap, consumerTiptap, 'dir'); +} + +function verifyAuthorityFreeBundles() { + for (const filename of ['cwl-review.js', 'cwl-review.cjs']) { + const source = readFileSync(join(packageDirectory, 'dist', filename), 'utf8'); + assert.equal( + dynamicLoaderPattern.test(source), + false, + `${filename} must not invoke dynamic module loaders`, + ); + assert.doesNotMatch( + source, + externalRuntimeImportPattern, + `${filename} must not import external runtime authority`, + ); + assert.doesNotMatch( + source, + ambientAuthorityPattern, + `${filename} must not reference ambient network or credential authority`, + ); + } +} + +function verifyRuntimeConsumers() { + const esmPath = join(consumerDirectory, 'consumer.mjs'); + writeFileSync( + esmPath, + `import assert from 'node:assert/strict'; +import { + INKSPAN_REVIEW_CONTRACT_VERSION, + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, + DocumentEnvelopeRevisionError, + TextPositionSelectorEvidenceError, + createDocumentEnvelopeRevision, + createTextPositionSelector, +} from '${packageJson.name}/review'; +assert.equal(INKSPAN_REVIEW_CONTRACT_VERSION, 1); +assert.equal(TEXT_POSITION_PROJECTION_ID, 'inkspan-prosemirror-text'); +assert.equal(TEXT_POSITION_PROJECTION_VERSION, 1); +assert.equal(typeof DocumentEnvelopeRevisionError, 'function'); +assert.equal(typeof TextPositionSelectorEvidenceError, 'function'); +assert.equal(typeof createDocumentEnvelopeRevision, 'function'); +assert.equal(typeof createTextPositionSelector, 'function'); +`, + 'utf8', + ); + const cjsPath = join(consumerDirectory, 'consumer.cjs'); + writeFileSync( + cjsPath, + `const assert = require('node:assert/strict'); +const review = require('${packageJson.name}/review'); +assert.equal(review.INKSPAN_REVIEW_CONTRACT_VERSION, 1); +assert.equal(review.TEXT_POSITION_PROJECTION_ID, 'inkspan-prosemirror-text'); +assert.equal(review.TEXT_POSITION_PROJECTION_VERSION, 1); +assert.equal(typeof review.createDocumentEnvelopeRevision, 'function'); +assert.equal(typeof review.createTextPositionSelector, 'function'); +`, + 'utf8', + ); + run(process.execPath, [esmPath], consumerDirectory); + run(process.execPath, [cjsPath], consumerDirectory); +} + +function verifyDeclarationConsumer() { + const sourcePath = join(consumerDirectory, 'consumer.ts'); + const configurationPath = join(consumerDirectory, 'tsconfig.json'); + writeFileSync( + sourcePath, + `import { + INKSPAN_REVIEW_CONTRACT_VERSION, + type CwlReviewTarget, + type CwlEditorDocumentRevision, + type CwlEditorTextPositionSelector, + type CwlEditorTextProjectionIdentity, +} from '${packageJson.name}/review'; +declare const revision: CwlEditorDocumentRevision; +declare const selector: CwlEditorTextPositionSelector; +declare const projection: CwlEditorTextProjectionIdentity; +const target: CwlReviewTarget = { + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + revision, + selector, + projection, +}; +void [target.revision, target.selector.start, target.selector.end, target.projection.id]; +`, + 'utf8', + ); + writeFileSync( + configurationPath, + `${JSON.stringify( + { + compilerOptions: { + noEmit: true, + strict: true, + skipLibCheck: false, + module: 'NodeNext', + moduleResolution: 'NodeNext', + target: 'ES2022', + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + types: [], + }, + files: ['./consumer.ts'], + }, + null, + 2, + )}\n`, + 'utf8', + ); + const compilerPath = join( + repositoryRoot, + 'node_modules', + 'typescript', + 'bin', + 'tsc', + ); + assert.ok(existsSync(compilerPath)); + run(process.execPath, [compilerPath, '--project', configurationPath], consumerDirectory); +} + +try { + preparePackage(); + verifyAuthorityFreeBundles(); + verifyRuntimeConsumers(); + verifyDeclarationConsumer(); + console.log( + `Verified packed ${packageJson.name}/review through authority-bounded ESM, CommonJS, and strict TypeScript consumers.`, + ); +} finally { + rmSync(verificationRoot, { recursive: true, force: true }); +} From 72c949eb472afdaacfaaa624925816c5fc2fa3cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:36:13 -0700 Subject: [PATCH 06/63] feat(review): publish review package subpath --- package.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 4e55d924..d0b17a47 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,11 @@ "import": "./dist/cwl-text-position-selector.js", "require": "./dist/cwl-text-position-selector.cjs" }, + "./review": { + "types": "./dist/review/index.d.ts", + "import": "./dist/cwl-review.js", + "require": "./dist/cwl-review.cjs" + }, "./markdown": { "types": "./dist/markdown/index.d.ts", "import": "./dist/cwl-markdown.js", @@ -99,7 +104,7 @@ ], "scripts": { "dev": "vite", - "build": "tsc --noEmit && vite build && vite build --config vite.collaboration.config.ts && vite build --config vite.converter.config.ts && vite build --config vite.envelope-identity.config.ts && vite build --config vite.revision-evidence.config.ts && vite build --config vite.autosave.config.ts && vite build --config vite.text-position-selector.config.ts && vite build --config vite.markdown.config.ts && node ./scripts/copy-styles.mjs", + "build": "tsc --noEmit && vite build && vite build --config vite.collaboration.config.ts && vite build --config vite.converter.config.ts && vite build --config vite.envelope-identity.config.ts && vite build --config vite.revision-evidence.config.ts && vite build --config vite.autosave.config.ts && vite build --config vite.text-position-selector.config.ts && vite build --config vite.review.config.ts && vite build --config vite.markdown.config.ts && node ./scripts/copy-styles.mjs", "build:demo": "vite build --config vite.demo.config.ts", "fonts": "node ./scripts/fetch-fonts.mjs", "preview": "vite preview", @@ -108,7 +113,7 @@ "test:watch": "vitest", "coverage": "vitest run --coverage", "test:package-config": "node --test ./scripts/revision-evidence-consumer-config.test.mjs ./scripts/release-metadata.test.mjs ./scripts/javascript-runtime-authority.test.mjs", - "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./tests/package/verify-editor-placeholder-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs && node ./scripts/verify-text-position-selector-subpath-package.mjs && node ./scripts/verify-markdown-subpath-package.mjs" + "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./tests/package/verify-editor-placeholder-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs && node ./scripts/verify-text-position-selector-subpath-package.mjs && node ./scripts/verify-review-package.mjs && node ./scripts/verify-markdown-subpath-package.mjs" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", From 1e0063db0c7b8d64e0f6be7ff1f90abc0bb78364 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:39:43 -0700 Subject: [PATCH 07/63] test(review): require bounded detached review targets --- src/review/index.test.ts | 185 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 src/review/index.test.ts diff --git a/src/review/index.test.ts b/src/review/index.test.ts new file mode 100644 index 00000000..5ebf3383 --- /dev/null +++ b/src/review/index.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from 'vitest'; +import * as reviewModule from './index.js'; + +interface ReviewTargetFactorySurface { + readonly CwlReviewTargetError: new () => Error & { readonly code: 'invalid_target' }; + readonly createReviewTarget: (source: unknown) => unknown; +} + +function reviewSurface(): ReviewTargetFactorySurface { + return reviewModule as unknown as ReviewTargetFactorySurface; +} + +const digestHex = 'a'.repeat(64); +const revision = Object.freeze({ + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, +}); +const selector = Object.freeze({ + type: 'TextPositionSelector', + start: 2, + end: 5, +}); +const projection = Object.freeze({ + id: 'inkspan-prosemirror-text', + version: 1, +}); + +function validTarget(): Record { + return { + contractVersion: 1, + revision, + selector, + projection, + }; +} + +function expectInvalid(source: unknown): void { + const { createReviewTarget, CwlReviewTargetError } = reviewSurface(); + let failure: unknown; + try { + createReviewTarget(source); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(CwlReviewTargetError); + expect(failure).toMatchObject({ code: 'invalid_target' }); + expect(String(failure)).not.toContain(digestHex); +} + +describe('provider-neutral review target contract', () => { + it('creates one detached deeply frozen exact-revision target', () => { + const { createReviewTarget } = reviewSurface(); + const source = validTarget(); + const target = createReviewTarget(source) as { + readonly contractVersion: 1; + readonly revision: typeof revision; + readonly selector: typeof selector; + readonly projection: typeof projection; + }; + + expect(target).toEqual(source); + expect(target).not.toBe(source); + expect(target.revision).not.toBe(revision); + expect(target.selector).not.toBe(selector); + expect(target.projection).not.toBe(projection); + expect(Object.isFrozen(target)).toBe(true); + expect(Object.isFrozen(target.revision)).toBe(true); + expect(Object.isFrozen(target.selector)).toBe(true); + expect(Object.isFrozen(target.projection)).toBe(true); + }); + + it('rejects malformed contract versions and target shapes', () => { + expectInvalid(null); + expectInvalid({ ...validTarget(), contractVersion: 2 }); + expectInvalid({ ...validTarget(), unexpected: true }); + expectInvalid({ + ...validTarget(), + [Symbol('hidden authority')]: true, + }); + + const accessorTarget = validTarget(); + let getterCalls = 0; + Object.defineProperty(accessorTarget, 'revision', { + enumerable: true, + get() { + getterCalls += 1; + return revision; + }, + }); + expectInvalid(accessorTarget); + expect(getterCalls).toBe(0); + + const nonEnumerableTarget = validTarget(); + Object.defineProperty(nonEnumerableTarget, 'projection', { + value: projection, + enumerable: false, + }); + expectInvalid(nonEnumerableTarget); + }); + + it('rejects malformed revision, selector, and projection metadata', () => { + expectInvalid({ + ...validTarget(), + revision: { ...revision, algorithm: 'MD5' }, + }); + expectInvalid({ + ...validTarget(), + revision: { ...revision, digestHex: 'A'.repeat(64) }, + }); + expectInvalid({ + ...validTarget(), + revision: { ...revision, digestHex: 'a'.repeat(63) }, + }); + expectInvalid({ + ...validTarget(), + revision: { ...revision, strongEntityTag: '"sha256-wrong"' }, + }); + expectInvalid({ + ...validTarget(), + selector: { ...selector, type: 'CssSelector' }, + }); + expectInvalid({ + ...validTarget(), + selector: { ...selector, start: -1 }, + }); + expectInvalid({ + ...validTarget(), + selector: { ...selector, start: 1.5 }, + }); + expectInvalid({ + ...validTarget(), + selector: { ...selector, end: Number.MAX_SAFE_INTEGER + 1 }, + }); + expectInvalid({ + ...validTarget(), + selector: { ...selector, start: 6, end: 5 }, + }); + expectInvalid({ + ...validTarget(), + projection: { ...projection, id: 'dom-text' }, + }); + expectInvalid({ + ...validTarget(), + projection: { ...projection, version: 2 }, + }); + }); + + it('rejects malformed nested property shapes without invoking accessors', () => { + for (const key of ['revision', 'selector', 'projection'] as const) { + const source = validTarget(); + const nested = { ...(source[key] as Record) }; + let getterCalls = 0; + const firstKey = Object.keys(nested)[0]!; + Object.defineProperty(nested, firstKey, { + enumerable: true, + get() { + getterCalls += 1; + return undefined; + }, + }); + source[key] = nested; + expectInvalid(source); + expect(getterCalls).toBe(0); + } + }); + + it('normalizes hostile reflection failures without leaking private causes', () => { + const secret = 'private-review-reflection-value'; + const target = new Proxy(validTarget(), { + ownKeys() { + throw new Error(secret); + }, + }); + const { createReviewTarget, CwlReviewTargetError } = reviewSurface(); + let failure: unknown; + try { + createReviewTarget(target); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(CwlReviewTargetError); + expect(String(failure)).not.toContain(secret); + }); +}); From b331806d25f18de908b9afffa6a413f39c16e118 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:41:10 -0700 Subject: [PATCH 08/63] feat(review): validate detached review targets --- src/review/index.ts | 161 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 157 insertions(+), 4 deletions(-) diff --git a/src/review/index.ts b/src/review/index.ts index e60f74db..2a85315b 100644 --- a/src/review/index.ts +++ b/src/review/index.ts @@ -8,20 +8,42 @@ */ import type { CwlEditorDocumentRevision } from '../documentEnvelopeRevision.js'; -import type { - CwlEditorTextPositionSelector, - CwlEditorTextProjectionIdentity, +import { + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, + type CwlEditorTextPositionSelector, + type CwlEditorTextProjectionIdentity, } from '../textPositionSelectorEvidence.js'; /** Version of Inkspan's deterministic review-target contract. */ export const INKSPAN_REVIEW_CONTRACT_VERSION = 1 as const; +/** Stable redacted failure code for malformed review-target metadata. */ +export type CwlReviewTargetErrorCode = 'invalid_target'; + +/** Raised when untrusted review-target metadata violates the public contract. */ +export class CwlReviewTargetError extends Error { + /** Stable machine-readable failure category. */ + readonly code: CwlReviewTargetErrorCode; + + /** Create one redacted review-target validation error. */ + constructor() { + super('Review target metadata is invalid.'); + this.name = 'CwlReviewTargetError'; + this.code = 'invalid_target'; + } +} + /** * Immutable target for a host-owned comment or suggestion. * * Positions are W3C TextPositionSelector offsets in Inkspan's canonical text * projection, never DOM offsets. The revision validator prevents a host from - * silently applying a target to a different document revision. + * silently applying a target to a different document revision. This metadata + * contract validates shape and coordinate ordering only; without the source + * document it cannot prove that an arbitrary caller-supplied `end` offset is + * within the referenced projection. Consumers should create selectors through + * Inkspan's selector APIs and reject revision mismatches before applying them. */ export interface CwlReviewTarget { readonly contractVersion: typeof INKSPAN_REVIEW_CONTRACT_VERSION; @@ -30,6 +52,137 @@ export interface CwlReviewTarget { readonly projection: CwlEditorTextProjectionIdentity; } +const REVIEW_TARGET_KEYS = [ + 'contractVersion', + 'revision', + 'selector', + 'projection', +] as const; +const REVISION_KEYS = ['algorithm', 'digestHex', 'strongEntityTag'] as const; +const SELECTOR_KEYS = ['type', 'start', 'end'] as const; +const PROJECTION_KEYS = ['id', 'version'] as const; +const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/u; + +/** Throw one fresh redacted public validation error. */ +function rejectReviewTarget(): never { + throw new CwlReviewTargetError(); +} + +/** + * Snapshot exactly named enumerable data properties without invoking accessors. + * + * Unknown keys, symbols, accessors, non-enumerable fields, and reflection + * failures are rejected before Inkspan retains any caller-owned object. + */ +function readExactDataRecord( + source: unknown, + expectedKeys: readonly string[], +): Readonly> { + try { + if (typeof source !== 'object' || source === null) rejectReviewTarget(); + const ownKeys = Reflect.ownKeys(source); + if (ownKeys.length !== expectedKeys.length) rejectReviewTarget(); + for (const key of ownKeys) { + if (typeof key !== 'string' || !expectedKeys.includes(key)) { + rejectReviewTarget(); + } + } + + const values: Record = {}; + for (const key of expectedKeys) { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if ( + descriptor === undefined || + descriptor.enumerable !== true || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + rejectReviewTarget(); + } + values[key] = descriptor.value; + } + return values; + } catch { + rejectReviewTarget(); + } +} + +/** + * Validate and detach untrusted host review-target metadata. + * + * The returned value is deeply frozen across the complete v1 target shape and + * retains no caller-owned nested objects. Reflection/accessor failures and all + * malformed fields collapse to one redacted stable error so private caller data + * is never copied into generic diagnostics. The revision is local SHA-256 + * equality evidence only; this operation grants no identity, authorization, + * tenancy, persistence, timestamp, signature, or durable-write authority. + * + * @param source - Untrusted candidate review-target metadata. + * @returns A detached, deeply frozen v1 review target. + * @throws {CwlReviewTargetError} When any target field or shape is invalid. + */ +export function createReviewTarget(source: unknown): CwlReviewTarget { + const target = readExactDataRecord(source, REVIEW_TARGET_KEYS); + if (target.contractVersion !== INKSPAN_REVIEW_CONTRACT_VERSION) { + rejectReviewTarget(); + } + + const revision = readExactDataRecord(target.revision, REVISION_KEYS); + const digestHex = revision.digestHex; + if ( + revision.algorithm !== 'SHA-256' || + typeof digestHex !== 'string' || + digestHex.length !== 64 || + !SHA256_HEX_PATTERN.test(digestHex) || + revision.strongEntityTag !== `"sha256-${digestHex}"` + ) { + rejectReviewTarget(); + } + + const selector = readExactDataRecord(target.selector, SELECTOR_KEYS); + const start = selector.start; + const end = selector.end; + if ( + selector.type !== 'TextPositionSelector' || + typeof start !== 'number' || + typeof end !== 'number' || + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + start < 0 || + end < start + ) { + rejectReviewTarget(); + } + + const projection = readExactDataRecord(target.projection, PROJECTION_KEYS); + if ( + projection.id !== TEXT_POSITION_PROJECTION_ID || + projection.version !== TEXT_POSITION_PROJECTION_VERSION + ) { + rejectReviewTarget(); + } + + const detachedRevision: CwlEditorDocumentRevision = Object.freeze({ + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }); + const detachedSelector: CwlEditorTextPositionSelector = Object.freeze({ + type: 'TextPositionSelector', + start, + end, + }); + const detachedProjection: CwlEditorTextProjectionIdentity = Object.freeze({ + id: TEXT_POSITION_PROJECTION_ID, + version: TEXT_POSITION_PROJECTION_VERSION, + }); + return Object.freeze({ + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + revision: detachedRevision, + selector: detachedSelector, + projection: detachedProjection, + }); +} + export { TEXT_POSITION_PROJECTION_ID, TEXT_POSITION_PROJECTION_VERSION, From cb8c3271cb8d8ead5337d852503bf8b606392b1f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:42:22 -0700 Subject: [PATCH 09/63] test(review): verify packed target validation API --- scripts/verify-review-package.mjs | 50 ++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/scripts/verify-review-package.mjs b/scripts/verify-review-package.mjs index 085a5f61..5249e4e7 100644 --- a/scripts/verify-review-package.mjs +++ b/scripts/verify-review-package.mjs @@ -100,18 +100,40 @@ import { INKSPAN_REVIEW_CONTRACT_VERSION, TEXT_POSITION_PROJECTION_ID, TEXT_POSITION_PROJECTION_VERSION, + CwlReviewTargetError, DocumentEnvelopeRevisionError, TextPositionSelectorEvidenceError, createDocumentEnvelopeRevision, + createReviewTarget, createTextPositionSelector, } from '${packageJson.name}/review'; assert.equal(INKSPAN_REVIEW_CONTRACT_VERSION, 1); assert.equal(TEXT_POSITION_PROJECTION_ID, 'inkspan-prosemirror-text'); assert.equal(TEXT_POSITION_PROJECTION_VERSION, 1); +assert.equal(typeof CwlReviewTargetError, 'function'); assert.equal(typeof DocumentEnvelopeRevisionError, 'function'); assert.equal(typeof TextPositionSelectorEvidenceError, 'function'); assert.equal(typeof createDocumentEnvelopeRevision, 'function'); +assert.equal(typeof createReviewTarget, 'function'); assert.equal(typeof createTextPositionSelector, 'function'); +const digestHex = 'a'.repeat(64); +const target = createReviewTarget({ + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: '\"sha256-' + digestHex + '\"', + }, + selector: { type: 'TextPositionSelector', start: 1, end: 2 }, + projection: { id: 'inkspan-prosemirror-text', version: 1 }, +}); +assert.equal(target.revision.digestHex, digestHex); +assert.equal(target.selector.start, 1); +assert.equal(Object.isFrozen(target), true); +assert.throws( + () => createReviewTarget({ ...target, contractVersion: 2 }), + CwlReviewTargetError, +); `, 'utf8', ); @@ -123,8 +145,23 @@ const review = require('${packageJson.name}/review'); assert.equal(review.INKSPAN_REVIEW_CONTRACT_VERSION, 1); assert.equal(review.TEXT_POSITION_PROJECTION_ID, 'inkspan-prosemirror-text'); assert.equal(review.TEXT_POSITION_PROJECTION_VERSION, 1); +assert.equal(typeof review.CwlReviewTargetError, 'function'); assert.equal(typeof review.createDocumentEnvelopeRevision, 'function'); +assert.equal(typeof review.createReviewTarget, 'function'); assert.equal(typeof review.createTextPositionSelector, 'function'); +const digestHex = 'b'.repeat(64); +const target = review.createReviewTarget({ + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: '\"sha256-' + digestHex + '\"', + }, + selector: { type: 'TextPositionSelector', start: 0, end: 0 }, + projection: { id: 'inkspan-prosemirror-text', version: 1 }, +}); +assert.equal(target.revision.digestHex, digestHex); +assert.equal(Object.isFrozen(target.projection), true); `, 'utf8', ); @@ -139,7 +176,10 @@ function verifyDeclarationConsumer() { sourcePath, `import { INKSPAN_REVIEW_CONTRACT_VERSION, + CwlReviewTargetError, + createReviewTarget, type CwlReviewTarget, + type CwlReviewTargetErrorCode, type CwlEditorDocumentRevision, type CwlEditorTextPositionSelector, type CwlEditorTextProjectionIdentity, @@ -153,7 +193,15 @@ const target: CwlReviewTarget = { selector, projection, }; -void [target.revision, target.selector.start, target.selector.end, target.projection.id]; +const detachedTarget: CwlReviewTarget = createReviewTarget(target); +const code: CwlReviewTargetErrorCode = new CwlReviewTargetError().code; +void [ + detachedTarget.revision, + detachedTarget.selector.start, + detachedTarget.selector.end, + detachedTarget.projection.id, + code, +]; `, 'utf8', ); From a901a4ba26c9ac1649bf94a4ec4aa93e669c02b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:49:56 -0700 Subject: [PATCH 10/63] docs(review): document active review package boundary --- docs/package-distribution.md | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/docs/package-distribution.md b/docs/package-distribution.md index ddb4df0e..dfbf032e 100644 --- a/docs/package-distribution.md +++ b/docs/package-distribution.md @@ -17,6 +17,7 @@ integrations. | `@contextualwisdomlab/cwl-editor/envelope-identity` | Framework-independent identity-only envelope routing for bounded schema identity inspection; migration remains host-owned | | `@contextualwisdomlab/cwl-editor/revision-evidence` | Framework-independent revision evidence and document-transition evidence for local content equality/lineage claims | | `@contextualwisdomlab/cwl-editor/text-position-selector` | `implemented_on_protected_main` — React-free text-position projection core implementing W3C `TextPositionSelector`; interactive capture, revision binding, authorization, persistence, and re-anchoring remain outside this subpath | +| `@contextualwisdomlab/cwl-editor/review` | `implemented_on_active_pr` — React-free deterministic review-target validation bound to exact local SHA-256 revision evidence plus Inkspan's W3C text-position projection; durable review records and policy remain host-owned | | `@contextualwisdomlab/cwl-editor/markdown` | `implemented_on_active_pr` — headless deterministic Markdown/HTML/email/plain-text conversion with the same safe-link and strict inline-raster policies as the editor, without importing the React/TipTap editor graph | | `@contextualwisdomlab/cwl-editor/styles.css` | Editor layout and theming | | `@contextualwisdomlab/cwl-editor/fonts.css` | Full offline KR/EN/JP/SC/TC/VI font bundle | @@ -58,8 +59,8 @@ embedded in the npm tarball. dependencies so the consumer's package manager installs and resolves it; it is not merely a type-only dependency. - The framework-independent autosave, converter, envelope-identity, - revision-evidence, text-position-selector, and Markdown entrypoints do not - require React UI, a mounted editor, naruon, contextual-orchestrator, a + revision-evidence, text-position-selector, review, and Markdown entrypoints do + not require React UI, a mounted editor, naruon, contextual-orchestrator, a database, provider credentials, or host transport. Their individual package-consumer gates additionally prevent framework dependencies from leaking into subpaths whose public contracts exclude them. @@ -81,6 +82,15 @@ embedded in the npm tarball. state or bind a selector to a document revision. Hosts remain responsible for annotation identifiers/bodies, source-resource identity, authorization, tenancy, persistence, audit, and cross-revision re-anchoring. +- The review subpath composes only validated local SHA-256 revision evidence, + Inkspan's canonical text-position selector/projection identity, and a detached + immutable v1 target. It rejects malformed, accessor-backed, symbolic, or + unknown target fields without retaining caller-owned objects. It cannot prove + that an arbitrary caller-supplied offset is inside a document without the + referenced document, and it creates no durable comment/thread identifier, + actor identity, authorization, tenant boundary, persistence, retention, + notification, audit occurrence, collaboration-provider admission, or + cross-revision re-anchoring authority. Hosts retain all of those duties. - Envelope identity output is routing metadata only. It does not accept an unsupported document generation as current semantics and does not move schema registry, migration, persistence, rollback, or authorization authority into @@ -109,9 +119,9 @@ production library build. The verification chain: 4. rejects internal source, tests, demos, Office files, coverage output, and workflow files from the npm tarball; 5. imports the root, collaboration, converter, autosave, envelope-identity, - revision-evidence, text-position-selector, and Markdown surfaces through their - dedicated packed-consumer checks, including framework-free isolation where - that is part of the public contract; + revision-evidence, text-position-selector, review, and Markdown surfaces + through their dedicated packed-consumer checks, including framework-free + isolation where that is part of the public contract; 6. exercises supported ESM/CommonJS entrypoints and compiles strict TypeScript consumers against the published declaration surfaces; 7. resolves public CSS and font subpaths; and @@ -129,6 +139,13 @@ rejects **ambient network and credential authority** such as `fetch`, of the selector's structural contract and introduce no interactive runtime authority. +The active review-package check likewise builds and extracts a real npm tarball, +executes `@contextualwisdomlab/cwl-editor/review` through ESM and CommonJS, +compiles a strict TypeScript consumer, exercises valid and malformed target +metadata, and rejects external runtime imports, dynamic module loaders, and +ambient network or credential authority. This is active-PR evidence only until +the review subpath is integrated into protected main. + The Markdown package check likewise builds and extracts a real npm tarball, executes its ESM and CommonJS entrypoints outside the source tree, compiles a strict TypeScript consumer, and verifies representative safe-link, plain-text, From 8aa01e1191359cf73b9816d9c6a5bae4b8c0fc46 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:53:10 -0700 Subject: [PATCH 11/63] docs(review): expose active review subpath in README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f2b02332..42ea48ad 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ runtime. | Envelope identity | `@contextualwisdomlab/cwl-editor/envelope-identity` | Framework-independent bounded schema identity for host-owned migration routing | | Revision evidence | `@contextualwisdomlab/cwl-editor/revision-evidence` | Framework-independent canonical envelope, strong revision, and transition evidence | | Text-position selector | `@contextualwisdomlab/cwl-editor/text-position-selector` | React-free deterministic W3C `TextPositionSelector` projection core | +| Review target core | `@contextualwisdomlab/cwl-editor/review` | `implemented_on_active_pr` — React-free deterministic exact-revision review targets; durable review records and policy remain host-owned | | Autosave | `@contextualwisdomlab/cwl-editor/autosave` | Provider-neutral bounded single-flight persistence coordination | | Headless Markdown | `@contextualwisdomlab/cwl-editor/markdown` | React-free deterministic Markdown/HTML/email/plain-text conversion | | Styles | `@contextualwisdomlab/cwl-editor/styles.css` | Editor layout and theming | From 17ba45b0ed79f48bbe6749c3b440798f7d3ff0cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:56:25 -0700 Subject: [PATCH 12/63] test(review): cover same-width hostile target keys --- src/review/index.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/review/index.test.ts b/src/review/index.test.ts index 5ebf3383..f6285835 100644 --- a/src/review/index.test.ts +++ b/src/review/index.test.ts @@ -79,6 +79,16 @@ describe('provider-neutral review target contract', () => { [Symbol('hidden authority')]: true, }); + const sameWidthUnknownKey = validTarget(); + delete sameWidthUnknownKey.contractVersion; + sameWidthUnknownKey.unexpected = 1; + expectInvalid(sameWidthUnknownKey); + + const sameWidthSymbolKey = validTarget(); + delete sameWidthSymbolKey.contractVersion; + sameWidthSymbolKey[Symbol('hidden authority')] = 1; + expectInvalid(sameWidthSymbolKey); + const accessorTarget = validTarget(); let getterCalls = 0; Object.defineProperty(accessorTarget, 'revision', { From da5c36347a046b7baed90b4f456a1d8fee8dbbb4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 08:10:58 -0700 Subject: [PATCH 13/63] test(review): compile hostile symbol-key coverage --- src/review/index.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/review/index.test.ts b/src/review/index.test.ts index f6285835..fa86dc3e 100644 --- a/src/review/index.test.ts +++ b/src/review/index.test.ts @@ -86,7 +86,10 @@ describe('provider-neutral review target contract', () => { const sameWidthSymbolKey = validTarget(); delete sameWidthSymbolKey.contractVersion; - sameWidthSymbolKey[Symbol('hidden authority')] = 1; + Object.defineProperty(sameWidthSymbolKey, Symbol('hidden authority'), { + value: 1, + enumerable: true, + }); expectInvalid(sameWidthSymbolKey); const accessorTarget = validTarget(); From 245e6a07c5ede45b9eda0d3c7f252e5f6e51663b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 11:20:33 -0700 Subject: [PATCH 14/63] test(review): require bounded insert and delete suggestion records --- src/review/suggestion.test.ts | 208 ++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 src/review/suggestion.test.ts diff --git a/src/review/suggestion.test.ts b/src/review/suggestion.test.ts new file mode 100644 index 00000000..a5c2daab --- /dev/null +++ b/src/review/suggestion.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from 'vitest'; +import * as reviewModule from './index.js'; + +interface ReviewSuggestionFactorySurface { + readonly CwlReviewSuggestionError: new () => Error & { + readonly code: 'invalid_suggestion'; + }; + readonly createReviewSuggestion: (source: unknown) => unknown; +} + +function reviewSuggestionSurface(): ReviewSuggestionFactorySurface { + return reviewModule as unknown as ReviewSuggestionFactorySurface; +} + +const digestHex = 'b'.repeat(64); +const revision = Object.freeze({ + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, +}); +const projection = Object.freeze({ + id: 'inkspan-prosemirror-text', + version: 1, +}); + +function target(start: number, end: number): Record { + return { + contractVersion: 1, + revision, + selector: { + type: 'TextPositionSelector', + start, + end, + }, + projection, + }; +} + +function insertSuggestion(): Record { + return { + contractVersion: 1, + kind: 'insert', + target: target(2, 2), + text: '제안 👩🏽‍💻', + }; +} + +function deleteSuggestion(): Record { + return { + contractVersion: 1, + kind: 'delete', + target: target(2, 5), + }; +} + +function expectInvalidSuggestion(source: unknown): void { + const { createReviewSuggestion, CwlReviewSuggestionError } = + reviewSuggestionSurface(); + let failure: unknown; + try { + createReviewSuggestion(source); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(CwlReviewSuggestionError); + expect(failure).toMatchObject({ code: 'invalid_suggestion' }); + expect(String(failure)).not.toContain(digestHex); +} + +describe('provider-neutral review suggestion contract', () => { + it('creates detached deeply frozen insertion and deletion proposals', () => { + const { createReviewSuggestion } = reviewSuggestionSurface(); + const insertSource = insertSuggestion(); + const deleteSource = deleteSuggestion(); + + const insert = createReviewSuggestion(insertSource) as { + readonly contractVersion: 1; + readonly kind: 'insert'; + readonly target: { + readonly selector: { readonly start: number; readonly end: number }; + }; + readonly text: string; + }; + const deletion = createReviewSuggestion(deleteSource) as { + readonly contractVersion: 1; + readonly kind: 'delete'; + readonly target: { + readonly selector: { readonly start: number; readonly end: number }; + }; + }; + + expect(insert).toEqual(insertSource); + expect(insert).not.toBe(insertSource); + expect(insert.target).not.toBe(insertSource.target); + expect(insert.text).toBe('제안 👩🏽‍💻'); + expect(Object.isFrozen(insert)).toBe(true); + expect(Object.isFrozen(insert.target)).toBe(true); + expect(Object.isFrozen(insert.target.selector)).toBe(true); + + expect(deletion).toEqual(deleteSource); + expect(deletion).not.toBe(deleteSource); + expect(deletion.target).not.toBe(deleteSource.target); + expect(Object.isFrozen(deletion)).toBe(true); + expect(Object.isFrozen(deletion.target)).toBe(true); + }); + + it('requires exact bounded shapes before retaining suggestion proposal data', () => { + expectInvalidSuggestion(null); + expectInvalidSuggestion('not-a-suggestion'); + expectInvalidSuggestion({ contractVersion: 1, target: target(1, 1) }); + expectInvalidSuggestion({ ...insertSuggestion(), kind: 'replace' }); + expectInvalidSuggestion({ ...insertSuggestion(), contractVersion: 2 }); + expectInvalidSuggestion({ ...insertSuggestion(), unexpected: true }); + expectInvalidSuggestion({ + ...insertSuggestion(), + [Symbol('hidden authority')]: true, + }); + + const accessorKind = insertSuggestion(); + let getterCalls = 0; + Object.defineProperty(accessorKind, 'kind', { + enumerable: true, + get() { + getterCalls += 1; + return 'insert'; + }, + }); + expectInvalidSuggestion(accessorKind); + expect(getterCalls).toBe(0); + + const hiddenKind = insertSuggestion(); + Object.defineProperty(hiddenKind, 'kind', { + value: 'insert', + enumerable: false, + }); + expectInvalidSuggestion(hiddenKind); + }); + + it('requires insertion points and bounded non-empty insertion text', () => { + expectInvalidSuggestion({ + ...insertSuggestion(), + target: target(2, 3), + }); + expectInvalidSuggestion({ ...insertSuggestion(), text: '' }); + expectInvalidSuggestion({ ...insertSuggestion(), text: 1 }); + expectInvalidSuggestion({ + ...insertSuggestion(), + text: 'a'.repeat(65_537), + }); + + const exactLimit = { + ...insertSuggestion(), + text: 'a'.repeat(65_536), + }; + const accepted = reviewSuggestionSurface().createReviewSuggestion( + exactLimit, + ) as { readonly text: string }; + expect(accepted.text).toHaveLength(65_536); + }); + + it('requires deletion suggestions to select existing projected text only', () => { + expectInvalidSuggestion({ + ...deleteSuggestion(), + target: target(2, 2), + }); + expectInvalidSuggestion({ ...deleteSuggestion(), text: 'copied source text' }); + expectInvalidSuggestion({ + ...deleteSuggestion(), + target: { + ...target(2, 5), + revision: { ...revision, algorithm: 'MD5' }, + }, + }); + }); + + it('fails closed when hostile reflection changes or rejects kind evidence', () => { + const privateValue = 'private-suggestion-reflection-value'; + let kindReads = 0; + const changingKind = new Proxy(insertSuggestion(), { + getOwnPropertyDescriptor(source, property) { + const descriptor = Reflect.getOwnPropertyDescriptor(source, property); + if (property !== 'kind' || descriptor === undefined) return descriptor; + kindReads += 1; + return { + ...descriptor, + value: kindReads === 1 ? 'insert' : 'delete', + }; + }, + }); + expectInvalidSuggestion(changingKind); + + const hostileKeys = new Proxy(insertSuggestion(), { + ownKeys() { + throw new Error(privateValue); + }, + }); + const { createReviewSuggestion, CwlReviewSuggestionError } = + reviewSuggestionSurface(); + let failure: unknown; + try { + createReviewSuggestion(hostileKeys); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(CwlReviewSuggestionError); + expect(String(failure)).not.toContain(privateValue); + }); +}); From 670356c80820ddedce22cabd22332bab22524045 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 11:23:03 -0700 Subject: [PATCH 15/63] feat(review): add bounded insert and delete suggestion records --- src/review/index.ts | 145 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 139 insertions(+), 6 deletions(-) diff --git a/src/review/index.ts b/src/review/index.ts index 2a85315b..09d62982 100644 --- a/src/review/index.ts +++ b/src/review/index.ts @@ -1,10 +1,10 @@ /** * React-free review contract surface. * - * Inkspan owns deterministic review targets bound to an exact canonical - * document revision and text projection. Hosts own durable review records, - * identity, authorization, tenancy, persistence, retention, notifications, - * audit, and cross-revision re-anchoring policy. + * Inkspan owns deterministic review targets and bounded proposal metadata bound + * to an exact canonical document revision and text projection. Hosts own durable + * review records, identity, authorization, tenancy, persistence, retention, + * notifications, audit, and cross-revision re-anchoring policy. */ import type { CwlEditorDocumentRevision } from '../documentEnvelopeRevision.js'; @@ -34,6 +34,22 @@ export class CwlReviewTargetError extends Error { } } +/** Stable redacted failure code for malformed suggestion proposal metadata. */ +export type CwlReviewSuggestionErrorCode = 'invalid_suggestion'; + +/** Raised when untrusted suggestion proposal metadata violates the contract. */ +export class CwlReviewSuggestionError extends Error { + /** Stable machine-readable failure category. */ + readonly code: CwlReviewSuggestionErrorCode; + + /** Create one redacted suggestion validation error. */ + constructor() { + super('Review suggestion is invalid.'); + this.name = 'CwlReviewSuggestionError'; + this.code = 'invalid_suggestion'; + } +} + /** * Immutable target for a host-owned comment or suggestion. * @@ -52,22 +68,55 @@ export interface CwlReviewTarget { readonly projection: CwlEditorTextProjectionIdentity; } +/** Detached insertion proposal with no host identity or persistence authority. */ +export interface CwlReviewInsertSuggestion { + readonly contractVersion: typeof INKSPAN_REVIEW_CONTRACT_VERSION; + readonly kind: 'insert'; + readonly target: CwlReviewTarget; + readonly text: string; +} + +/** Detached deletion proposal with no copied source text or durable authority. */ +export interface CwlReviewDeleteSuggestion { + readonly contractVersion: typeof INKSPAN_REVIEW_CONTRACT_VERSION; + readonly kind: 'delete'; + readonly target: CwlReviewTarget; +} + +/** Provider-neutral proposal data accepted by Inkspan's review contract. */ +export type CwlReviewSuggestion = + | CwlReviewInsertSuggestion + | CwlReviewDeleteSuggestion; + const REVIEW_TARGET_KEYS = [ 'contractVersion', 'revision', 'selector', 'projection', ] as const; +const INSERT_SUGGESTION_KEYS = [ + 'contractVersion', + 'kind', + 'target', + 'text', +] as const; +const DELETE_SUGGESTION_KEYS = ['contractVersion', 'kind', 'target'] as const; const REVISION_KEYS = ['algorithm', 'digestHex', 'strongEntityTag'] as const; const SELECTOR_KEYS = ['type', 'start', 'end'] as const; const PROJECTION_KEYS = ['id', 'version'] as const; const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/u; +const MAX_REVIEW_INSERT_TEXT_CODE_UNITS = 65_536; /** Throw one fresh redacted public validation error. */ function rejectReviewTarget(): never { throw new CwlReviewTargetError(); } +/** Throw one fresh redacted public suggestion validation error. */ +function rejectReviewSuggestion(): never { + throw new CwlReviewSuggestionError(); +} + /** * Snapshot exactly named enumerable data properties without invoking accessors. * @@ -106,6 +155,30 @@ function readExactDataRecord( } } +/** + * Read only the discriminant needed to choose the exact suggestion shape. + * Accessors and reflection failures are rejected without invoking caller code. + */ +function readSuggestionKind(source: unknown): 'insert' | 'delete' { + try { + if (typeof source !== 'object' || source === null) rejectReviewSuggestion(); + const descriptor = Object.getOwnPropertyDescriptor(source, 'kind'); + if ( + descriptor === undefined || + descriptor.enumerable !== true || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + rejectReviewSuggestion(); + } + if (descriptor.value !== 'insert' && descriptor.value !== 'delete') { + rejectReviewSuggestion(); + } + return descriptor.value; + } catch { + rejectReviewSuggestion(); + } +} + /** * Validate and detach untrusted host review-target metadata. * @@ -133,7 +206,7 @@ export function createReviewTarget(source: unknown): CwlReviewTarget { typeof digestHex !== 'string' || digestHex.length !== 64 || !SHA256_HEX_PATTERN.test(digestHex) || - revision.strongEntityTag !== `"sha256-${digestHex}"` + revision.strongEntityTag !== `\"sha256-${digestHex}\"` ) { rejectReviewTarget(); } @@ -164,7 +237,7 @@ export function createReviewTarget(source: unknown): CwlReviewTarget { const detachedRevision: CwlEditorDocumentRevision = Object.freeze({ algorithm: 'SHA-256', digestHex, - strongEntityTag: `"sha256-${digestHex}"`, + strongEntityTag: `\"sha256-${digestHex}\"`, }); const detachedSelector: CwlEditorTextPositionSelector = Object.freeze({ type: 'TextPositionSelector', @@ -183,6 +256,66 @@ export function createReviewTarget(source: unknown): CwlReviewTarget { }); } +/** + * Validate and detach an untrusted insert/delete suggestion proposal. + * + * Insertions must target an insertion point and carry 1..65,536 UTF-16 code + * units of proposal text. Deletions must target a non-empty projected range and + * deliberately carry no copied source body. The returned proposal and target + * are frozen snapshots. This validator does not apply edits, persist records, + * assign identities, authorize actors, or grant model/provider output any + * authority; hosts must still perform admission and Inkspan revision checks at + * the operation boundary. + * + * @param source - Untrusted candidate suggestion proposal metadata. + * @returns A detached, deeply frozen v1 suggestion proposal. + * @throws {CwlReviewSuggestionError} When any proposal field or shape is invalid. + */ +export function createReviewSuggestion(source: unknown): CwlReviewSuggestion { + try { + const kind = readSuggestionKind(source); + const expectedKeys = + kind === 'insert' ? INSERT_SUGGESTION_KEYS : DELETE_SUGGESTION_KEYS; + const suggestion = readExactDataRecord(source, expectedKeys); + if ( + suggestion.contractVersion !== INKSPAN_REVIEW_CONTRACT_VERSION || + suggestion.kind !== kind + ) { + rejectReviewSuggestion(); + } + + const target = createReviewTarget(suggestion.target); + if (kind === 'insert') { + const text = suggestion.text; + if ( + target.selector.start !== target.selector.end || + typeof text !== 'string' || + text.length === 0 || + text.length > MAX_REVIEW_INSERT_TEXT_CODE_UNITS + ) { + rejectReviewSuggestion(); + } + return Object.freeze({ + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + kind, + target, + text, + }); + } + + if (target.selector.start === target.selector.end) { + rejectReviewSuggestion(); + } + return Object.freeze({ + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + kind, + target, + }); + } catch { + rejectReviewSuggestion(); + } +} + export { TEXT_POSITION_PROJECTION_ID, TEXT_POSITION_PROJECTION_VERSION, From 2e0d22dff30c2dbc997a067dfc30b0bda32f91fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:41:44 -0700 Subject: [PATCH 16/63] test(review): require revision-bound operation evidence --- src/review/operation.test.ts | 244 +++++++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 src/review/operation.test.ts diff --git a/src/review/operation.test.ts b/src/review/operation.test.ts new file mode 100644 index 00000000..b2884b5e --- /dev/null +++ b/src/review/operation.test.ts @@ -0,0 +1,244 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { createDocumentEnvelope } from '../documentEnvelope.js'; +import { + createDocumentEnvelopeRevision, + type CwlEditorDocumentRevision, + type DocumentEnvelopeDigestProvider, +} from '../documentEnvelopeRevision.js'; +import * as reviewModule from './index.js'; + +interface ReviewOperationSurface { + readonly CwlReviewOperationError: new ( + code: + | 'invalid_operation' + | 'accepted_operation_unchanged' + | 'rejected_operation_changed', + ) => Error & { + readonly code: + | 'invalid_operation' + | 'accepted_operation_unchanged' + | 'rejected_operation_changed'; + }; + readonly createReviewOperationResult: ( + suggestion: unknown, + action: 'accept' | 'reject', + previousSource: unknown, + resultingSource: unknown, + limits?: unknown, + digestProvider?: DocumentEnvelopeDigestProvider | null, + ) => Promise; +} + +function reviewOperationSurface(): ReviewOperationSurface { + return reviewModule as unknown as ReviewOperationSurface; +} + +function toBytes(source: BufferSource): Uint8Array { + return ArrayBuffer.isView(source) + ? new Uint8Array(source.buffer, source.byteOffset, source.byteLength) + : new Uint8Array(source); +} + +function sha256(source: BufferSource): ArrayBuffer { + const digest = createHash('sha256').update(toBytes(source)).digest(); + const result = new Uint8Array(32); + result.set(digest); + return result.buffer; +} + +function digestProvider(): DocumentEnvelopeDigestProvider { + return { + async digest(algorithm, source) { + expect(algorithm).toBe('SHA-256'); + return sha256(source); + }, + }; +} + +function target(revision: CwlEditorDocumentRevision) { + return { + contractVersion: 1, + revision, + selector: { + type: 'TextPositionSelector', + start: 0, + end: 0, + }, + projection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + }; +} + +function insertSuggestion(revision: CwlEditorDocumentRevision) { + return { + contractVersion: 1, + kind: 'insert', + target: target(revision), + text: '검토 제안', + }; +} + +const BEFORE_DOCUMENT = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'private before body' }], + }, + ], +}; +const AFTER_DOCUMENT = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'private after body' }], + }, + ], +}; + +describe('provider-neutral review operation evidence', () => { + it('binds an accepted proposal to exact before/after revisions without retaining document bodies', async () => { + const provider = digestProvider(); + const previousEnvelope = createDocumentEnvelope(BEFORE_DOCUMENT); + const resultingEnvelope = createDocumentEnvelope(AFTER_DOCUMENT); + const expectedRevision = await createDocumentEnvelopeRevision( + previousEnvelope, + undefined, + provider, + ); + + const result = (await reviewOperationSurface().createReviewOperationResult( + insertSuggestion(expectedRevision), + 'accept', + previousEnvelope, + resultingEnvelope, + undefined, + provider, + )) as { + readonly contractVersion: 1; + readonly action: 'accept'; + readonly status: 'accepted'; + readonly beforeRevision: CwlEditorDocumentRevision; + readonly resultingRevision: CwlEditorDocumentRevision; + readonly transitionEvidence: { + readonly previousRevision: CwlEditorDocumentRevision; + readonly resultingRevision: CwlEditorDocumentRevision; + readonly changed: boolean; + }; + }; + + expect(result.contractVersion).toBe(1); + expect(result.action).toBe('accept'); + expect(result.status).toBe('accepted'); + expect(result.beforeRevision).toEqual(expectedRevision); + expect(result.resultingRevision).not.toEqual(expectedRevision); + expect(result.transitionEvidence).toMatchObject({ + previousRevision: expectedRevision, + changed: true, + }); + expect(Object.isFrozen(result)).toBe(true); + expect(JSON.stringify(result)).not.toContain('private before body'); + expect(JSON.stringify(result)).not.toContain('private after body'); + expect(JSON.stringify(result)).not.toContain('검토 제안'); + }); + + it('returns a stable stale result rather than silently re-anchoring a mismatched target', async () => { + const provider = digestProvider(); + const previousEnvelope = createDocumentEnvelope(BEFORE_DOCUMENT); + const resultingEnvelope = createDocumentEnvelope(AFTER_DOCUMENT); + const staleDigest = 'f'.repeat(64); + const staleRevision = Object.freeze({ + algorithm: 'SHA-256' as const, + digestHex: staleDigest, + strongEntityTag: `"sha256-${staleDigest}"`, + }); + + const result = (await reviewOperationSurface().createReviewOperationResult( + insertSuggestion(staleRevision), + 'accept', + previousEnvelope, + resultingEnvelope, + undefined, + provider, + )) as Record; + + expect(result.status).toBe('stale'); + expect(result.action).toBe('accept'); + expect(result.beforeRevision).not.toEqual(staleRevision); + expect(result).not.toHaveProperty('resultingRevision'); + expect(result).not.toHaveProperty('transitionEvidence'); + }); + + it('requires accepted operations to change the document and rejected operations to preserve it', async () => { + const provider = digestProvider(); + const previousEnvelope = createDocumentEnvelope(BEFORE_DOCUMENT); + const resultingEnvelope = createDocumentEnvelope(AFTER_DOCUMENT); + const expectedRevision = await createDocumentEnvelopeRevision( + previousEnvelope, + undefined, + provider, + ); + const suggestion = insertSuggestion(expectedRevision); + const { createReviewOperationResult, CwlReviewOperationError } = + reviewOperationSurface(); + + await expect( + createReviewOperationResult( + suggestion, + 'accept', + previousEnvelope, + previousEnvelope, + undefined, + provider, + ), + ).rejects.toMatchObject({ + code: 'accepted_operation_unchanged', + }); + await expect( + createReviewOperationResult( + suggestion, + 'reject', + previousEnvelope, + resultingEnvelope, + undefined, + provider, + ), + ).rejects.toMatchObject({ + code: 'rejected_operation_changed', + }); + + const failure = new CwlReviewOperationError('invalid_operation'); + expect(failure.message).not.toContain('private before body'); + expect(failure.message).not.toContain('검토 제안'); + }); + + it('returns a frozen rejected result for an unchanged exact-revision operation', async () => { + const provider = digestProvider(); + const previousEnvelope = createDocumentEnvelope(BEFORE_DOCUMENT); + const expectedRevision = await createDocumentEnvelopeRevision( + previousEnvelope, + undefined, + provider, + ); + + const result = (await reviewOperationSurface().createReviewOperationResult( + insertSuggestion(expectedRevision), + 'reject', + previousEnvelope, + previousEnvelope, + undefined, + provider, + )) as { + readonly status: 'rejected'; + readonly transitionEvidence: { readonly changed: boolean }; + }; + + expect(result.status).toBe('rejected'); + expect(result.transitionEvidence.changed).toBe(false); + expect(Object.isFrozen(result)).toBe(true); + }); +}); From 917b156aeb62f297e400a7870f44c395278940d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:42:50 -0700 Subject: [PATCH 17/63] test(review): cover invalid operation action --- src/review/operation.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/review/operation.test.ts b/src/review/operation.test.ts index b2884b5e..0ab9f56a 100644 --- a/src/review/operation.test.ts +++ b/src/review/operation.test.ts @@ -211,6 +211,25 @@ describe('provider-neutral review operation evidence', () => { code: 'rejected_operation_changed', }); + const dynamicOperation = createReviewOperationResult as unknown as ( + suggestion: unknown, + action: unknown, + previousSource: unknown, + resultingSource: unknown, + limits?: unknown, + digestProvider?: DocumentEnvelopeDigestProvider | null, + ) => Promise; + await expect( + dynamicOperation( + suggestion, + 'approve', + previousEnvelope, + resultingEnvelope, + undefined, + provider, + ), + ).rejects.toMatchObject({ code: 'invalid_operation' }); + const failure = new CwlReviewOperationError('invalid_operation'); expect(failure.message).not.toContain('private before body'); expect(failure.message).not.toContain('검토 제안'); From 64b8954cb0929892e857c1f21309b6526c129a44 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:43:38 -0700 Subject: [PATCH 18/63] feat(review): bind decisions to revision transition evidence --- src/review/index.ts | 122 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 1 deletion(-) diff --git a/src/review/index.ts b/src/review/index.ts index 09d62982..3933fa2b 100644 --- a/src/review/index.ts +++ b/src/review/index.ts @@ -7,7 +7,15 @@ * notifications, audit, and cross-revision re-anchoring policy. */ -import type { CwlEditorDocumentRevision } from '../documentEnvelopeRevision.js'; +import type { DocumentEnvelopeLimits } from '../documentEnvelope.js'; +import type { + CwlEditorDocumentRevision, + DocumentEnvelopeDigestProvider, +} from '../documentEnvelopeRevision.js'; +import { + createDocumentEnvelopeTransitionEvidence, + type CwlEditorDocumentTransitionEvidence, +} from '../documentTransitionEvidence.js'; import { TEXT_POSITION_PROJECTION_ID, TEXT_POSITION_PROJECTION_VERSION, @@ -50,6 +58,36 @@ export class CwlReviewSuggestionError extends Error { } } +/** Stable redacted failure codes for review-operation evidence. */ +export type CwlReviewOperationErrorCode = + | 'invalid_operation' + | 'accepted_operation_unchanged' + | 'rejected_operation_changed'; + +const REVIEW_OPERATION_ERROR_MESSAGES: Record< + CwlReviewOperationErrorCode, + string +> = { + invalid_operation: 'Review operation is invalid.', + accepted_operation_unchanged: + 'Accepted review operation must change the document revision.', + rejected_operation_changed: + 'Rejected review operation must preserve the document revision.', +}; + +/** Raised when before/after review-operation evidence violates the contract. */ +export class CwlReviewOperationError extends Error { + /** Stable machine-readable failure category. */ + readonly code: CwlReviewOperationErrorCode; + + /** Create one payload-redacted review-operation error. */ + constructor(code: CwlReviewOperationErrorCode) { + super(REVIEW_OPERATION_ERROR_MESSAGES[code]); + this.name = 'CwlReviewOperationError'; + this.code = code; + } +} + /** * Immutable target for a host-owned comment or suggestion. * @@ -88,6 +126,16 @@ export type CwlReviewSuggestion = | CwlReviewInsertSuggestion | CwlReviewDeleteSuggestion; +/** Review decision whose effect is proven only through exact revision evidence. */ +export interface CwlReviewOperationResult { + readonly contractVersion: typeof INKSPAN_REVIEW_CONTRACT_VERSION; + readonly action: 'accept' | 'reject'; + readonly status: 'accepted' | 'rejected' | 'stale'; + readonly beforeRevision: CwlEditorDocumentRevision; + readonly resultingRevision?: CwlEditorDocumentRevision; + readonly transitionEvidence?: CwlEditorDocumentTransitionEvidence; +} + const REVIEW_TARGET_KEYS = [ 'contractVersion', 'revision', @@ -316,6 +364,77 @@ export function createReviewSuggestion(source: unknown): CwlReviewSuggestion { } } +/** + * Bind a host/editor review decision to exact before/after document revisions. + * + * This function does not apply an editor transaction and does not persist a + * review decision. The caller supplies the actual previous and resulting + * document envelopes after its authorized operation. Inkspan validates the + * proposal, derives canonical transition evidence, and refuses to classify an + * accepted operation that changed nothing or a rejected operation that changed + * the document. A stale proposal returns a compact `stale` result instead of + * silently re-anchoring it to the current revision. + * + * The result contains revisions and transition metadata only; proposal text and + * document bodies are not retained. Host-owned identity, authorization, + * persistence, exact-once durable state, audit, and conflict policy remain out + * of scope. + * + * @param suggestionSource - Untrusted provider-neutral insert/delete proposal. + * @param action - Host-authorized review decision to classify. + * @param previousSource - Exact document envelope observed before the operation. + * @param resultingSource - Exact document envelope observed after the operation. + * @param limits - Optional strict document-envelope resource limits. + * @param digestProvider - Optional SHA-256 provider for deterministic testing. + * @returns Frozen revision-only review-operation evidence. + * @throws {CwlReviewOperationError} When action/change semantics conflict. + */ +export async function createReviewOperationResult( + suggestionSource: unknown, + action: 'accept' | 'reject', + previousSource: unknown, + resultingSource: unknown, + limits?: DocumentEnvelopeLimits, + digestProvider?: DocumentEnvelopeDigestProvider | null, +): Promise { + if (action !== 'accept' && action !== 'reject') { + throw new CwlReviewOperationError('invalid_operation'); + } + const suggestion = createReviewSuggestion(suggestionSource); + const transition = await createDocumentEnvelopeTransitionEvidence( + previousSource, + resultingSource, + limits, + digestProvider, + ); + + if ( + transition.previousRevision.digestHex !== suggestion.target.revision.digestHex + ) { + return Object.freeze({ + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + action, + status: 'stale', + beforeRevision: transition.previousRevision, + }); + } + if (action === 'accept' && !transition.changed) { + throw new CwlReviewOperationError('accepted_operation_unchanged'); + } + if (action === 'reject' && transition.changed) { + throw new CwlReviewOperationError('rejected_operation_changed'); + } + + return Object.freeze({ + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + action, + status: action === 'accept' ? 'accepted' : 'rejected', + beforeRevision: transition.previousRevision, + resultingRevision: transition.resultingRevision, + transitionEvidence: transition, + }); +} + export { TEXT_POSITION_PROJECTION_ID, TEXT_POSITION_PROJECTION_VERSION, @@ -336,3 +455,4 @@ export type { CwlEditorDocumentRevision, DocumentEnvelopeDigestProvider, } from '../documentEnvelopeRevision.js'; +export type { CwlEditorDocumentTransitionEvidence } from '../documentTransitionEvidence.js'; From 8f57c9a824ab8c5b1d44b719116c10ac738e6fb2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:48:32 -0700 Subject: [PATCH 19/63] test(review): verify packed operation contract exports --- scripts/verify-review-package.mjs | 66 ++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/scripts/verify-review-package.mjs b/scripts/verify-review-package.mjs index 5249e4e7..97b585fe 100644 --- a/scripts/verify-review-package.mjs +++ b/scripts/verify-review-package.mjs @@ -100,20 +100,28 @@ import { INKSPAN_REVIEW_CONTRACT_VERSION, TEXT_POSITION_PROJECTION_ID, TEXT_POSITION_PROJECTION_VERSION, + CwlReviewOperationError, + CwlReviewSuggestionError, CwlReviewTargetError, DocumentEnvelopeRevisionError, TextPositionSelectorEvidenceError, createDocumentEnvelopeRevision, + createReviewOperationResult, + createReviewSuggestion, createReviewTarget, createTextPositionSelector, } from '${packageJson.name}/review'; assert.equal(INKSPAN_REVIEW_CONTRACT_VERSION, 1); assert.equal(TEXT_POSITION_PROJECTION_ID, 'inkspan-prosemirror-text'); assert.equal(TEXT_POSITION_PROJECTION_VERSION, 1); +assert.equal(typeof CwlReviewOperationError, 'function'); +assert.equal(typeof CwlReviewSuggestionError, 'function'); assert.equal(typeof CwlReviewTargetError, 'function'); assert.equal(typeof DocumentEnvelopeRevisionError, 'function'); assert.equal(typeof TextPositionSelectorEvidenceError, 'function'); assert.equal(typeof createDocumentEnvelopeRevision, 'function'); +assert.equal(typeof createReviewOperationResult, 'function'); +assert.equal(typeof createReviewSuggestion, 'function'); assert.equal(typeof createReviewTarget, 'function'); assert.equal(typeof createTextPositionSelector, 'function'); const digestHex = 'a'.repeat(64); @@ -134,6 +142,22 @@ assert.throws( () => createReviewTarget({ ...target, contractVersion: 2 }), CwlReviewTargetError, ); +const insertionTarget = createReviewTarget({ + ...target, + selector: { type: 'TextPositionSelector', start: 1, end: 1 }, +}); +const suggestion = createReviewSuggestion({ + contractVersion: 1, + kind: 'insert', + target: insertionTarget, + text: 'proposal', +}); +assert.equal(suggestion.kind, 'insert'); +assert.equal(Object.isFrozen(suggestion), true); +assert.throws( + () => createReviewSuggestion({ ...suggestion, text: '' }), + CwlReviewSuggestionError, +); `, 'utf8', ); @@ -145,8 +169,12 @@ const review = require('${packageJson.name}/review'); assert.equal(review.INKSPAN_REVIEW_CONTRACT_VERSION, 1); assert.equal(review.TEXT_POSITION_PROJECTION_ID, 'inkspan-prosemirror-text'); assert.equal(review.TEXT_POSITION_PROJECTION_VERSION, 1); +assert.equal(typeof review.CwlReviewOperationError, 'function'); +assert.equal(typeof review.CwlReviewSuggestionError, 'function'); assert.equal(typeof review.CwlReviewTargetError, 'function'); assert.equal(typeof review.createDocumentEnvelopeRevision, 'function'); +assert.equal(typeof review.createReviewOperationResult, 'function'); +assert.equal(typeof review.createReviewSuggestion, 'function'); assert.equal(typeof review.createReviewTarget, 'function'); assert.equal(typeof review.createTextPositionSelector, 'function'); const digestHex = 'b'.repeat(64); @@ -162,6 +190,14 @@ const target = review.createReviewTarget({ }); assert.equal(target.revision.digestHex, digestHex); assert.equal(Object.isFrozen(target.projection), true); +const suggestion = review.createReviewSuggestion({ + contractVersion: 1, + kind: 'insert', + target, + text: 'proposal', +}); +assert.equal(suggestion.text, 'proposal'); +assert.equal(Object.isFrozen(suggestion), true); `, 'utf8', ); @@ -176,8 +212,16 @@ function verifyDeclarationConsumer() { sourcePath, `import { INKSPAN_REVIEW_CONTRACT_VERSION, + CwlReviewOperationError, + CwlReviewSuggestionError, CwlReviewTargetError, + createReviewOperationResult, + createReviewSuggestion, createReviewTarget, + type CwlReviewOperationErrorCode, + type CwlReviewOperationResult, + type CwlReviewSuggestion, + type CwlReviewSuggestionErrorCode, type CwlReviewTarget, type CwlReviewTargetErrorCode, type CwlEditorDocumentRevision, @@ -194,13 +238,31 @@ const target: CwlReviewTarget = { projection, }; const detachedTarget: CwlReviewTarget = createReviewTarget(target); -const code: CwlReviewTargetErrorCode = new CwlReviewTargetError().code; +const suggestion: CwlReviewSuggestion = createReviewSuggestion({ + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + kind: 'delete', + target: { + ...target, + selector: { type: 'TextPositionSelector', start: 0, end: 1 }, + }, +}); +const operationPromise: Promise = + createReviewOperationResult(suggestion, 'reject', {}, {}); +const targetCode: CwlReviewTargetErrorCode = new CwlReviewTargetError().code; +const suggestionCode: CwlReviewSuggestionErrorCode = + new CwlReviewSuggestionError().code; +const operationCode: CwlReviewOperationErrorCode = + new CwlReviewOperationError('invalid_operation').code; void [ detachedTarget.revision, detachedTarget.selector.start, detachedTarget.selector.end, detachedTarget.projection.id, - code, + suggestion.kind, + operationPromise, + targetCode, + suggestionCode, + operationCode, ]; `, 'utf8', From 9e56bda85b504b4b0eb3f35d9e5c2d089e2b82d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:53:30 -0700 Subject: [PATCH 20/63] test(review): require bounded thread presentation contract --- src/review/presentation.test.ts | 151 ++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 src/review/presentation.test.ts diff --git a/src/review/presentation.test.ts b/src/review/presentation.test.ts new file mode 100644 index 00000000..7a5c8af9 --- /dev/null +++ b/src/review/presentation.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; +import * as reviewModule from './index.js'; + +interface ReviewPresentationSurface { + readonly CwlReviewPresentationError: new () => Error & { + readonly code: 'invalid_presentation'; + }; + readonly createReviewThreadPresentation: (source: unknown) => unknown; +} + +function reviewPresentationSurface(): ReviewPresentationSurface { + return reviewModule as unknown as ReviewPresentationSurface; +} + +function target() { + const digestHex = 'a'.repeat(64); + return { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { + type: 'TextPositionSelector', + start: 3, + end: 8, + }, + projection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + }; +} + +function presentation(overrides: Record = {}) { + return { + contractVersion: 1, + threadKey: 'thread_123', + target: target(), + state: 'unresolved', + commentCount: 2, + selected: true, + canReply: true, + canResolve: true, + ...overrides, + }; +} + +describe('review thread presentation contract', () => { + it('detaches and freezes bounded host presentation metadata without comment bodies', () => { + const { createReviewThreadPresentation } = reviewPresentationSurface(); + const source = presentation(); + const result = createReviewThreadPresentation(source) as { + readonly threadKey: string; + readonly target: { + readonly selector: { readonly start: number; readonly end: number }; + }; + readonly state: string; + readonly commentCount: number; + readonly selected: boolean; + readonly canReply: boolean; + readonly canResolve: boolean; + }; + + expect(result).toEqual(source); + expect(result).not.toBe(source); + expect(result.target).not.toBe(source.target); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.target)).toBe(true); + expect(Object.isFrozen(result.target.selector)).toBe(true); + expect(JSON.stringify(result)).not.toContain('commentBody'); + }); + + it('supports resolved and permission-disabled presentation without inventing actor authority', () => { + const { createReviewThreadPresentation } = reviewPresentationSurface(); + const result = createReviewThreadPresentation( + presentation({ + state: 'resolved', + selected: false, + canReply: false, + canResolve: false, + }), + ) as Record; + + expect(result).toMatchObject({ + state: 'resolved', + selected: false, + canReply: false, + canResolve: false, + }); + expect(result).not.toHaveProperty('actorId'); + expect(result).not.toHaveProperty('authorized'); + }); + + it('fails closed on body-like or otherwise unsupported presentation fields', () => { + const { + createReviewThreadPresentation, + CwlReviewPresentationError, + } = reviewPresentationSurface(); + const privateBody = 'private-review-body-must-not-leak'; + + expect(() => + createReviewThreadPresentation( + presentation({ commentBody: privateBody }), + ), + ).toThrow(CwlReviewPresentationError); + try { + createReviewThreadPresentation(presentation({ commentBody: privateBody })); + } catch (error) { + expect(error).toMatchObject({ code: 'invalid_presentation' }); + expect(String(error)).not.toContain(privateBody); + } + }); + + it('bounds opaque host thread keys and comment counts', () => { + const { createReviewThreadPresentation } = reviewPresentationSurface(); + + expect(() => + createReviewThreadPresentation(presentation({ threadKey: '' })), + ).toThrow(); + expect(() => + createReviewThreadPresentation( + presentation({ threadKey: `thread_${'x'.repeat(122)}` }), + ), + ).toThrow(); + expect(() => + createReviewThreadPresentation(presentation({ commentCount: 0 })), + ).toThrow(); + expect(() => + createReviewThreadPresentation(presentation({ commentCount: 10_001 })), + ).toThrow(); + }); + + it('rejects hostile accessors without invoking them', () => { + const { createReviewThreadPresentation } = reviewPresentationSurface(); + let reads = 0; + const source = presentation(); + Object.defineProperty(source, 'threadKey', { + enumerable: true, + configurable: true, + get() { + reads += 1; + throw new Error('private accessor payload'); + }, + }); + + expect(() => createReviewThreadPresentation(source)).toThrow(); + expect(reads).toBe(0); + }); +}); From b6e45cbf676d7d1aad47ac6e1af8a8cf860e13c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:55:05 -0700 Subject: [PATCH 21/63] feat(review): add bounded thread presentation metadata --- src/review/index.ts | 101 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 2 deletions(-) diff --git a/src/review/index.ts b/src/review/index.ts index 3933fa2b..3082caaf 100644 --- a/src/review/index.ts +++ b/src/review/index.ts @@ -58,6 +58,22 @@ export class CwlReviewSuggestionError extends Error { } } +/** Stable redacted failure code for malformed review presentation metadata. */ +export type CwlReviewPresentationErrorCode = 'invalid_presentation'; + +/** Raised when host-supplied thread presentation metadata violates the contract. */ +export class CwlReviewPresentationError extends Error { + /** Stable machine-readable failure category. */ + readonly code: CwlReviewPresentationErrorCode; + + /** Create one payload-redacted presentation validation error. */ + constructor() { + super('Review presentation metadata is invalid.'); + this.name = 'CwlReviewPresentationError'; + this.code = 'invalid_presentation'; + } +} + /** Stable redacted failure codes for review-operation evidence. */ export type CwlReviewOperationErrorCode = | 'invalid_operation' @@ -106,6 +122,19 @@ export interface CwlReviewTarget { readonly projection: CwlEditorTextProjectionIdentity; } +/** Bounded host-supplied metadata used to render one comment-thread target. */ +export interface CwlReviewThreadPresentation { + readonly contractVersion: typeof INKSPAN_REVIEW_CONTRACT_VERSION; + /** Opaque host-owned key carried for callback correlation, never generated here. */ + readonly threadKey: string; + readonly target: CwlReviewTarget; + readonly state: 'unresolved' | 'resolved'; + readonly commentCount: number; + readonly selected: boolean; + readonly canReply: boolean; + readonly canResolve: boolean; +} + /** Detached insertion proposal with no host identity or persistence authority. */ export interface CwlReviewInsertSuggestion { readonly contractVersion: typeof INKSPAN_REVIEW_CONTRACT_VERSION; @@ -142,6 +171,16 @@ const REVIEW_TARGET_KEYS = [ 'selector', 'projection', ] as const; +const REVIEW_PRESENTATION_KEYS = [ + 'contractVersion', + 'threadKey', + 'target', + 'state', + 'commentCount', + 'selected', + 'canReply', + 'canResolve', +] as const; const INSERT_SUGGESTION_KEYS = [ 'contractVersion', 'kind', @@ -153,7 +192,9 @@ const REVISION_KEYS = ['algorithm', 'digestHex', 'strongEntityTag'] as const; const SELECTOR_KEYS = ['type', 'start', 'end'] as const; const PROJECTION_KEYS = ['id', 'version'] as const; const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/u; +const REVIEW_THREAD_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; const MAX_REVIEW_INSERT_TEXT_CODE_UNITS = 65_536; +const MAX_REVIEW_COMMENT_COUNT = 10_000; /** Throw one fresh redacted public validation error. */ function rejectReviewTarget(): never { @@ -165,6 +206,11 @@ function rejectReviewSuggestion(): never { throw new CwlReviewSuggestionError(); } +/** Throw one fresh redacted public presentation validation error. */ +function rejectReviewPresentation(): never { + throw new CwlReviewPresentationError(); +} + /** * Snapshot exactly named enumerable data properties without invoking accessors. * @@ -254,7 +300,7 @@ export function createReviewTarget(source: unknown): CwlReviewTarget { typeof digestHex !== 'string' || digestHex.length !== 64 || !SHA256_HEX_PATTERN.test(digestHex) || - revision.strongEntityTag !== `\"sha256-${digestHex}\"` + revision.strongEntityTag !== `"sha256-${digestHex}"` ) { rejectReviewTarget(); } @@ -285,7 +331,7 @@ export function createReviewTarget(source: unknown): CwlReviewTarget { const detachedRevision: CwlEditorDocumentRevision = Object.freeze({ algorithm: 'SHA-256', digestHex, - strongEntityTag: `\"sha256-${digestHex}\"`, + strongEntityTag: `"sha256-${digestHex}"`, }); const detachedSelector: CwlEditorTextPositionSelector = Object.freeze({ type: 'TextPositionSelector', @@ -304,6 +350,57 @@ export function createReviewTarget(source: unknown): CwlReviewTarget { }); } +/** + * Validate and detach host-supplied comment-thread presentation metadata. + * + * The contract deliberately carries no comment body, actor identity, + * authorization assertion, timestamp, persistence state, or durable audit data. + * `threadKey` is an opaque bounded host-owned correlation key only; Inkspan does + * not generate, persist, authenticate, or interpret it. `commentCount`, status, + * selection, and capability booleans are presentation inputs for later + * controlled UI surfaces and grant no host authority by themselves. + * + * @param source - Untrusted host presentation metadata. + * @returns A detached, deeply frozen bounded presentation snapshot. + * @throws {CwlReviewPresentationError} When any field or shape is invalid. + */ +export function createReviewThreadPresentation( + source: unknown, +): CwlReviewThreadPresentation { + try { + const presentation = readExactDataRecord(source, REVIEW_PRESENTATION_KEYS); + if ( + presentation.contractVersion !== INKSPAN_REVIEW_CONTRACT_VERSION || + typeof presentation.threadKey !== 'string' || + !REVIEW_THREAD_KEY_PATTERN.test(presentation.threadKey) || + (presentation.state !== 'unresolved' && presentation.state !== 'resolved') || + typeof presentation.commentCount !== 'number' || + !Number.isSafeInteger(presentation.commentCount) || + presentation.commentCount < 1 || + presentation.commentCount > MAX_REVIEW_COMMENT_COUNT || + typeof presentation.selected !== 'boolean' || + typeof presentation.canReply !== 'boolean' || + typeof presentation.canResolve !== 'boolean' + ) { + rejectReviewPresentation(); + } + + const target = createReviewTarget(presentation.target); + return Object.freeze({ + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + threadKey: presentation.threadKey, + target, + state: presentation.state, + commentCount: presentation.commentCount, + selected: presentation.selected, + canReply: presentation.canReply, + canResolve: presentation.canResolve, + }); + } catch { + rejectReviewPresentation(); + } +} + /** * Validate and detach an untrusted insert/delete suggestion proposal. * From 06561c75a32e3e607f39ee2efa6f8f0683576029 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:29:25 -0700 Subject: [PATCH 22/63] test(review): define controlled accessible React thread list --- src/review-react/index.test.tsx | 141 ++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 src/review-react/index.test.tsx diff --git a/src/review-react/index.test.tsx b/src/review-react/index.test.tsx new file mode 100644 index 00000000..c928a9cc --- /dev/null +++ b/src/review-react/index.test.tsx @@ -0,0 +1,141 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createReviewThreadPresentation } from '../review/index.js'; +import { CwlReviewThreadList } from './index.js'; + +afterEach(cleanup); + +function target(digest = 'a') { + const digestHex = digest.repeat(64); + return { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { + type: 'TextPositionSelector', + start: 3, + end: 8, + }, + projection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + }; +} + +function presentation( + threadKey: string, + overrides: Record = {}, +) { + return { + contractVersion: 1, + threadKey, + target: target(threadKey === 'thread_1' ? 'a' : 'b'), + state: 'unresolved', + commentCount: 2, + selected: false, + canReply: true, + canResolve: true, + ...overrides, + }; +} + +const labels = { + region: 'Document review', + thread: (thread: ReturnType, index: number) => + `Thread ${index + 1}: ${thread.state}, ${thread.commentCount} comments`, + reply: 'Reply', + resolve: 'Resolve', +}; + +describe('CwlReviewThreadList', () => { + it('renders a controlled accessible thread list and emits detached presentation intents', () => { + const onSelectThread = vi.fn(); + const onReplyThread = vi.fn(); + const onResolveThread = vi.fn(); + const first = presentation('thread_1', { selected: true }); + const second = presentation('thread_2', { + state: 'resolved', + canReply: false, + canResolve: true, + }); + + render( + , + ); + + const region = screen.getByRole('region', { name: 'Document review' }); + expect(region).toBeInTheDocument(); + + const firstThread = screen.getByRole('button', { + name: 'Thread 1: unresolved, 2 comments', + }); + const secondThread = screen.getByRole('button', { + name: 'Thread 2: resolved, 2 comments', + }); + expect(firstThread).toHaveAttribute('aria-pressed', 'true'); + expect(secondThread).toHaveAttribute('aria-pressed', 'false'); + + fireEvent.click(secondThread); + expect(onSelectThread).toHaveBeenCalledTimes(1); + const selected = onSelectThread.mock.calls[0]?.[0] as ReturnType< + typeof createReviewThreadPresentation + >; + expect(selected.threadKey).toBe('thread_2'); + expect(selected).not.toBe(second); + expect(Object.isFrozen(selected)).toBe(true); + + const replyButtons = screen.getAllByRole('button', { name: 'Reply' }); + const resolveButtons = screen.getAllByRole('button', { name: 'Resolve' }); + expect(replyButtons[0]).toBeEnabled(); + expect(replyButtons[1]).toBeDisabled(); + expect(resolveButtons[0]).toBeEnabled(); + expect(resolveButtons[1]).toBeDisabled(); + + fireEvent.click(replyButtons[0]!); + fireEvent.click(resolveButtons[0]!); + expect(onReplyThread).toHaveBeenCalledWith(expect.objectContaining({ threadKey: 'thread_1' })); + expect(onResolveThread).toHaveBeenCalledWith( + expect.objectContaining({ threadKey: 'thread_1' }), + ); + }); + + it('keeps capability booleans presentation-only when host action callbacks are absent', () => { + render( + , + ); + + expect(screen.getByRole('button', { name: 'Reply' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Resolve' })).toBeDisabled(); + }); + + it('fails closed through the review contract before rendering hostile thread metadata', () => { + const hostile = presentation('thread_1', { + commentBody: 'private-body-must-not-render', + }); + + expect(() => + render( + , + ), + ).toThrow(/Review presentation metadata is invalid/u); + expect(screen.queryByText('private-body-must-not-render')).not.toBeInTheDocument(); + }); +}); From c024d0aac1203a3fca3cc072343d311b1161805c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:32:47 -0700 Subject: [PATCH 23/63] feat(review): render controlled accessible thread list --- src/review-react/index.tsx | 98 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/review-react/index.tsx diff --git a/src/review-react/index.tsx b/src/review-react/index.tsx new file mode 100644 index 00000000..68277b5c --- /dev/null +++ b/src/review-react/index.tsx @@ -0,0 +1,98 @@ +import { + createReviewThreadPresentation, + type CwlReviewThreadPresentation, +} from '../review/index.js'; + +/** Host-owned visible copy for Inkspan's bounded review-thread list. */ +export interface CwlReviewThreadListLabels { + /** Accessible name for the review region. */ + readonly region: string; + /** Visible and accessible label for one validated thread. */ + readonly thread: ( + thread: CwlReviewThreadPresentation, + index: number, + ) => string; + /** Visible label for the host-owned reply intent. */ + readonly reply: string; + /** Visible label for the host-owned resolve intent. */ + readonly resolve: string; +} + +/** Controlled inputs and intent callbacks for the review-thread list. */ +export interface CwlReviewThreadListProps { + /** Untrusted host presentation records validated before rendering. */ + readonly presentations: readonly unknown[]; + /** Host-supplied localized visible and accessible copy. */ + readonly labels: CwlReviewThreadListLabels; + /** Selection intent; the host remains the controlled-state authority. */ + readonly onSelectThread: (thread: CwlReviewThreadPresentation) => void; + /** Optional reply intent; absence keeps reply controls disabled. */ + readonly onReplyThread?: (thread: CwlReviewThreadPresentation) => void; + /** Optional resolve intent; absence keeps resolve controls disabled. */ + readonly onResolveThread?: (thread: CwlReviewThreadPresentation) => void; +} + +/** + * Render a controlled accessible list of bounded review-thread presentations. + * + * Every source record passes through the React-free review validator before any + * host metadata is rendered. The component emits only intent callbacks with the + * detached, frozen presentation snapshot; it does not authorize, persist, + * transport, mutate, resolve, or reply to host-owned review records. + */ +export function CwlReviewThreadList({ + presentations, + labels, + onSelectThread, + onReplyThread, + onResolveThread, +}: CwlReviewThreadListProps) { + const validatedPresentations = presentations.map((presentation) => + createReviewThreadPresentation(presentation), + ); + + return ( +
+
    + {validatedPresentations.map((presentation, index) => { + const replyHandler = + presentation.canReply && onReplyThread !== undefined + ? () => onReplyThread(presentation) + : undefined; + const resolveHandler = + presentation.state === 'unresolved' && + presentation.canResolve && + onResolveThread !== undefined + ? () => onResolveThread(presentation) + : undefined; + + return ( +
  • + + + +
  • + ); + })} +
+
+ ); +} From 22dd0177e651d7c60716201514175a07ed671705 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:35:51 -0700 Subject: [PATCH 24/63] test(review): require packaged React presentation adapter --- src/reviewPackageContract.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/reviewPackageContract.test.ts b/src/reviewPackageContract.test.ts index c95fa1cb..831b5dcc 100644 --- a/src/reviewPackageContract.test.ts +++ b/src/reviewPackageContract.test.ts @@ -32,4 +32,18 @@ describe('review package contract', () => { 'scripts/verify-review-package.mjs', ); }); + + it('publishes the controlled React review adapter as a separate subpath', () => { + expect(manifest.exports?.['./review-react']).toEqual({ + types: './dist/review-react/index.d.ts', + import: './dist/cwl-review-react.js', + require: './dist/cwl-review-react.cjs', + }); + expect(manifest.scripts?.build).toContain( + 'vite build --config vite.review-react.config.ts', + ); + expect(manifest.scripts?.['verify:package']).toContain( + 'scripts/verify-review-react-package.mjs', + ); + }); }); From 5038e35310527f94318725dcc84170fab1a3955d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:40:39 -0700 Subject: [PATCH 25/63] feat(review): package controlled React presentation adapter --- package.json | 9 +- scripts/verify-review-react-package.mjs | 250 ++++++++++++++++++++++++ vite.review-react.config.ts | 40 ++++ 3 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 scripts/verify-review-react-package.mjs create mode 100644 vite.review-react.config.ts diff --git a/package.json b/package.json index d0b17a47..aa83c356 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,11 @@ "import": "./dist/cwl-review.js", "require": "./dist/cwl-review.cjs" }, + "./review-react": { + "types": "./dist/review-react/index.d.ts", + "import": "./dist/cwl-review-react.js", + "require": "./dist/cwl-review-react.cjs" + }, "./markdown": { "types": "./dist/markdown/index.d.ts", "import": "./dist/cwl-markdown.js", @@ -104,7 +109,7 @@ ], "scripts": { "dev": "vite", - "build": "tsc --noEmit && vite build && vite build --config vite.collaboration.config.ts && vite build --config vite.converter.config.ts && vite build --config vite.envelope-identity.config.ts && vite build --config vite.revision-evidence.config.ts && vite build --config vite.autosave.config.ts && vite build --config vite.text-position-selector.config.ts && vite build --config vite.review.config.ts && vite build --config vite.markdown.config.ts && node ./scripts/copy-styles.mjs", + "build": "tsc --noEmit && vite build && vite build --config vite.collaboration.config.ts && vite build --config vite.converter.config.ts && vite build --config vite.envelope-identity.config.ts && vite build --config vite.revision-evidence.config.ts && vite build --config vite.autosave.config.ts && vite build --config vite.text-position-selector.config.ts && vite build --config vite.review.config.ts && vite build --config vite.review-react.config.ts && vite build --config vite.markdown.config.ts && node ./scripts/copy-styles.mjs", "build:demo": "vite build --config vite.demo.config.ts", "fonts": "node ./scripts/fetch-fonts.mjs", "preview": "vite preview", @@ -113,7 +118,7 @@ "test:watch": "vitest", "coverage": "vitest run --coverage", "test:package-config": "node --test ./scripts/revision-evidence-consumer-config.test.mjs ./scripts/release-metadata.test.mjs ./scripts/javascript-runtime-authority.test.mjs", - "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./tests/package/verify-editor-placeholder-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs && node ./scripts/verify-text-position-selector-subpath-package.mjs && node ./scripts/verify-review-package.mjs && node ./scripts/verify-markdown-subpath-package.mjs" + "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./tests/package/verify-editor-placeholder-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs && node ./scripts/verify-text-position-selector-subpath-package.mjs && node ./scripts/verify-review-package.mjs && node ./scripts/verify-review-react-package.mjs && node ./scripts/verify-markdown-subpath-package.mjs" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", diff --git a/scripts/verify-review-react-package.mjs b/scripts/verify-review-react-package.mjs new file mode 100644 index 00000000..27bdc74d --- /dev/null +++ b/scripts/verify-review-react-package.mjs @@ -0,0 +1,250 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageJson = JSON.parse( + readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), +); +const verificationRoot = mkdtempSync(join(tmpdir(), 'inkspan-review-react-')); +const extractionDirectory = join(verificationRoot, 'extracted'); +const consumerDirectory = join(verificationRoot, 'consumer'); +const packageDirectory = join( + consumerDirectory, + 'node_modules', + ...packageJson.name.split('/'), +); + +const ambientAuthorityPattern = + /(?:\bfetch\s*\(|\bXMLHttpRequest\b|\bWebSocket\b|\bEventSource\b|\bprocess\.env\b|\bimport\.meta\.env\b|\bDeno\.env\b|\bBun\.env\b)/u; +const dynamicImportPattern = /\bimport\s*\(/u; +const esmSpecifierPattern = + /\b(?:import|export)\s+(?:[^'";]*?\sfrom\s*)?['"]([^'"]+)['"]/gu; +const requireSpecifierPattern = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/gu; +const allowedRuntimeSpecifiers = new Set(['react', 'react/jsx-runtime']); + +function run(command, argumentsList, cwd = repositoryRoot) { + return execFileSync(command, argumentsList, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }); +} + +function linkDependency(name) { + const source = join(repositoryRoot, 'node_modules', ...name.split('/')); + const target = join(consumerDirectory, 'node_modules', ...name.split('/')); + assert.ok(existsSync(source), `repository dependency missing: ${name}`); + mkdirSync(dirname(target), { recursive: true }); + symlinkSync(source, target, 'dir'); +} + +function preparePackage() { + mkdirSync(extractionDirectory, { recursive: true }); + mkdirSync(dirname(packageDirectory), { recursive: true }); + const packOutput = run('npm', [ + 'pack', + '--json', + '--ignore-scripts', + '--pack-destination', + verificationRoot, + ]); + const packResult = JSON.parse(packOutput)[0]; + assert.equal(packResult.name, packageJson.name); + assert.equal(packResult.version, packageJson.version); + const tarballPath = join(verificationRoot, packResult.filename); + assert.ok(existsSync(tarballPath)); + run('tar', ['-xzf', tarballPath, '-C', extractionDirectory]); + renameSync(join(extractionDirectory, 'package'), packageDirectory); + writeFileSync( + join(consumerDirectory, 'package.json'), + '{"name":"inkspan-review-react-consumer","private":true,"type":"module"}\n', + 'utf8', + ); + for (const dependency of [ + 'react', + 'react-dom', + '@types/react', + '@types/react-dom', + ]) { + linkDependency(dependency); + } +} + +function verifyBoundedRuntimeImports() { + const files = [ + ['cwl-review-react.js', esmSpecifierPattern], + ['cwl-review-react.cjs', requireSpecifierPattern], + ]; + for (const [filename, specifierPattern] of files) { + const source = readFileSync(join(packageDirectory, 'dist', filename), 'utf8'); + assert.equal( + ambientAuthorityPattern.test(source), + false, + `${filename} must not reference ambient network or credential authority`, + ); + assert.equal( + dynamicImportPattern.test(source), + false, + `${filename} must not dynamically import runtime authority`, + ); + specifierPattern.lastIndex = 0; + for (const match of source.matchAll(specifierPattern)) { + assert.ok( + allowedRuntimeSpecifiers.has(match[1]), + `${filename} imports unexpected runtime authority: ${match[1]}`, + ); + } + } +} + +function presentationFixture(digestCharacter) { + const digestHex = digestCharacter.repeat(64); + return { + contractVersion: 1, + threadKey: 'thread_1', + target: { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { type: 'TextPositionSelector', start: 3, end: 8 }, + projection: { id: 'inkspan-prosemirror-text', version: 1 }, + }, + state: 'unresolved', + commentCount: 2, + selected: true, + canReply: true, + canResolve: true, + }; +} + +function verifyRuntimeConsumers() { + const labelsSource = `{ + region: 'Document review', + thread: (_thread, index) => 'Thread ' + (index + 1), + reply: 'Reply', + resolve: 'Resolve', +}`; + const fixture = JSON.stringify(presentationFixture('a')); + const esmPath = join(consumerDirectory, 'consumer.mjs'); + writeFileSync( + esmPath, + `import assert from 'node:assert/strict'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { CwlReviewThreadList } from '${packageJson.name}/review-react'; +const html = renderToStaticMarkup(React.createElement(CwlReviewThreadList, { + presentations: [${fixture}], + labels: ${labelsSource}, + onSelectThread() {}, +})); +assert.match(html, /aria-label="Document review"/u); +assert.match(html, /aria-pressed="true"/u); +assert.match(html, />Reply]*>Reply|>Reply<\/button>/u); +`, + 'utf8', + ); + const cjsPath = join(consumerDirectory, 'consumer.cjs'); + writeFileSync( + cjsPath, + `const assert = require('node:assert/strict'); +const React = require('react'); +const { renderToStaticMarkup } = require('react-dom/server'); +const { CwlReviewThreadList } = require('${packageJson.name}/review-react'); +const html = renderToStaticMarkup(React.createElement(CwlReviewThreadList, { + presentations: [${JSON.stringify(presentationFixture('b'))}], + labels: ${labelsSource}, + onSelectThread() {}, +})); +assert.match(html, /aria-label="Document review"/u); +assert.match(html, /aria-pressed="true"/u); +assert.match(html, />Resolve String(index) + thread.state, + reply: 'Reply', + resolve: 'Resolve', +}; +const props: CwlReviewThreadListProps = { + presentations: [], + labels, + onSelectThread(thread) { + void thread.threadKey; + }, +}; +const component: typeof CwlReviewThreadList = CwlReviewThreadList; +void [props, component]; +`, + 'utf8', + ); + writeFileSync( + configurationPath, + `${JSON.stringify( + { + compilerOptions: { + noEmit: true, + strict: true, + skipLibCheck: false, + module: 'NodeNext', + moduleResolution: 'NodeNext', + target: 'ES2022', + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + jsx: 'react-jsx', + types: ['react'], + }, + files: ['./consumer.ts'], + }, + null, + 2, + )}\n`, + 'utf8', + ); + const compilerPath = join(repositoryRoot, 'node_modules', 'typescript', 'bin', 'tsc'); + assert.ok(existsSync(compilerPath)); + run(process.execPath, [compilerPath, '--project', configurationPath], consumerDirectory); +} + +try { + preparePackage(); + verifyBoundedRuntimeImports(); + verifyRuntimeConsumers(); + verifyDeclarationConsumer(); + console.log( + `Verified packed ${packageJson.name}/review-react through bounded ESM, CommonJS, and strict TypeScript consumers.`, + ); +} finally { + rmSync(verificationRoot, { recursive: true, force: true }); +} diff --git a/vite.review-react.config.ts b/vite.review-react.config.ts new file mode 100644 index 00000000..ac606bf0 --- /dev/null +++ b/vite.review-react.config.ts @@ -0,0 +1,40 @@ +import { resolve } from 'node:path'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +/** + * Build the controlled React presentation adapter separately from the + * framework-free review contract. React remains host-supplied peer authority. + */ +export default defineConfig({ + plugins: [ + react(), + dts({ + include: [ + 'src/review-react', + 'src/review', + 'src/documentEnvelopeRevision.ts', + 'src/textPositionSelectorEvidence.ts', + ], + exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts'], + rollupTypes: false, + entryRoot: 'src', + }), + ], + build: { + emptyOutDir: false, + lib: { + entry: resolve(__dirname, 'src/review-react/index.tsx'), + name: 'InkspanReviewReact', + fileName: (format) => + format === 'es' ? 'cwl-review-react.js' : 'cwl-review-react.cjs', + formats: ['es', 'cjs'], + }, + sourcemap: true, + rollupOptions: { + external: ['react', 'react-dom', 'react/jsx-runtime'], + output: { interop: 'auto' }, + }, + }, +}); From 80a726710ea0dea818d4ccb0379a1741b1c68f3a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:46:15 -0700 Subject: [PATCH 26/63] docs(review): document React review package boundary --- docs/package-distribution.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/package-distribution.md b/docs/package-distribution.md index dfbf032e..5effa500 100644 --- a/docs/package-distribution.md +++ b/docs/package-distribution.md @@ -18,6 +18,7 @@ integrations. | `@contextualwisdomlab/cwl-editor/revision-evidence` | Framework-independent revision evidence and document-transition evidence for local content equality/lineage claims | | `@contextualwisdomlab/cwl-editor/text-position-selector` | `implemented_on_protected_main` — React-free text-position projection core implementing W3C `TextPositionSelector`; interactive capture, revision binding, authorization, persistence, and re-anchoring remain outside this subpath | | `@contextualwisdomlab/cwl-editor/review` | `implemented_on_active_pr` — React-free deterministic review-target validation bound to exact local SHA-256 revision evidence plus Inkspan's W3C text-position projection; durable review records and policy remain host-owned | +| `@contextualwisdomlab/cwl-editor/review-react` | `implemented_on_active_pr` — controlled accessible React thread presentation over the bounded review contract; hosts supply labels and own actions, authorization, bodies, and persistence | | `@contextualwisdomlab/cwl-editor/markdown` | `implemented_on_active_pr` — headless deterministic Markdown/HTML/email/plain-text conversion with the same safe-link and strict inline-raster policies as the editor, without importing the React/TipTap editor graph | | `@contextualwisdomlab/cwl-editor/styles.css` | Editor layout and theming | | `@contextualwisdomlab/cwl-editor/fonts.css` | Full offline KR/EN/JP/SC/TC/VI font bundle | @@ -64,6 +65,13 @@ embedded in the npm tarball. database, provider credentials, or host transport. Their individual package-consumer gates additionally prevent framework dependencies from leaking into subpaths whose public contracts exclude them. +- The review-react subpath is an optional controlled React presentation adapter. + It validates every host-supplied presentation through the React-free review + contract before rendering, receives visible and accessible copy from the host, + and emits selection, reply, and resolve intent callbacks only. Presentation + capability flags never grant authority on their own; missing host callbacks + keep actions disabled. It owns no comment body, actor lookup, authorization, + persistence, notification, or transport. - The Markdown subpath exposes `markdownToHtml`, `htmlToMarkdown`, `normalizeMarkdown`, `markdownToEmailHtml`, `markdownToPlainText`, and `htmlToPlainText` plus their option types. It bundles deterministic conversion @@ -119,9 +127,9 @@ production library build. The verification chain: 4. rejects internal source, tests, demos, Office files, coverage output, and workflow files from the npm tarball; 5. imports the root, collaboration, converter, autosave, envelope-identity, - revision-evidence, text-position-selector, review, and Markdown surfaces - through their dedicated packed-consumer checks, including framework-free - isolation where that is part of the public contract; + revision-evidence, text-position-selector, review, review-react, and Markdown + surfaces through their dedicated packed-consumer checks, including + framework-free isolation where that is part of the public contract; 6. exercises supported ESM/CommonJS entrypoints and compiles strict TypeScript consumers against the published declaration surfaces; 7. resolves public CSS and font subpaths; and @@ -146,6 +154,13 @@ metadata, and rejects external runtime imports, dynamic module loaders, and ambient network or credential authority. This is active-PR evidence only until the review subpath is integrated into protected main. +The active review-react package check is configured to build and extract a real +npm tarball, exercise ESM and CommonJS server-render consumers plus strict +TypeScript declarations, permit only host-supplied React peer runtime imports, +and reject dynamic module loaders plus ambient network or credential authority. +Its result is active-PR evidence only until the review-react subpath is integrated +into protected main. + The Markdown package check likewise builds and extracts a real npm tarball, executes its ESM and CommonJS entrypoints outside the source tree, compiles a strict TypeScript consumer, and verifies representative safe-link, plain-text, From 46dba82c9312c334e3b34b657a81b0e22093a712 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:47:39 -0700 Subject: [PATCH 27/63] docs(review): expose React review adapter in README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 42ea48ad..8d58e930 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ runtime. | Revision evidence | `@contextualwisdomlab/cwl-editor/revision-evidence` | Framework-independent canonical envelope, strong revision, and transition evidence | | Text-position selector | `@contextualwisdomlab/cwl-editor/text-position-selector` | React-free deterministic W3C `TextPositionSelector` projection core | | Review target core | `@contextualwisdomlab/cwl-editor/review` | `implemented_on_active_pr` — React-free deterministic exact-revision review targets; durable review records and policy remain host-owned | +| Review React adapter | `@contextualwisdomlab/cwl-editor/review-react` | `implemented_on_active_pr` — controlled accessible thread presentation over the React-free review contract; hosts own actions, authorization, bodies, and persistence | | Autosave | `@contextualwisdomlab/cwl-editor/autosave` | Provider-neutral bounded single-flight persistence coordination | | Headless Markdown | `@contextualwisdomlab/cwl-editor/markdown` | React-free deterministic Markdown/HTML/email/plain-text conversion | | Styles | `@contextualwisdomlab/cwl-editor/styles.css` | Editor layout and theming | @@ -728,4 +729,4 @@ capabilities they require. - **Fonts:** Noto Sans families are SIL Open Font License 1.1. See [`LICENSE`](LICENSE), [`src/fonts/OFL.txt`](src/fonts/OFL.txt), and -[`src/fonts/NOTICE`](src/fonts/NOTICE). \ No newline at end of file +[`src/fonts/NOTICE`](src/fonts/NOTICE). From 34287e9ac1fafd754c59db3f0a6bced5252e27b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:09:23 -0700 Subject: [PATCH 28/63] fix(review): make packed React verifier syntax-safe --- scripts/verify-review-react-package.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/verify-review-react-package.mjs b/scripts/verify-review-react-package.mjs index 27bdc74d..7ee70e6f 100644 --- a/scripts/verify-review-react-package.mjs +++ b/scripts/verify-review-react-package.mjs @@ -156,7 +156,7 @@ const html = renderToStaticMarkup(React.createElement(CwlReviewThreadList, { assert.match(html, /aria-label="Document review"/u); assert.match(html, /aria-pressed="true"/u); assert.match(html, />Reply]*>Reply|>Reply<\/button>/u); +assert.match(html, new RegExp(']*disabled=""[^>]*>Reply', 'u')); `, 'utf8', ); @@ -175,6 +175,7 @@ const html = renderToStaticMarkup(React.createElement(CwlReviewThreadList, { assert.match(html, /aria-label="Document review"/u); assert.match(html, /aria-pressed="true"/u); assert.match(html, />Resolve]*disabled=""[^>]*>Resolve', 'u')); `, 'utf8', ); From 5f1a926704b69d81300a0eecd5f865cdf5e272a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:11:21 -0700 Subject: [PATCH 29/63] test(review): fail closed on changed stale operations --- src/review/operation.test.ts | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/src/review/operation.test.ts b/src/review/operation.test.ts index 0ab9f56a..60552fdf 100644 --- a/src/review/operation.test.ts +++ b/src/review/operation.test.ts @@ -12,11 +12,13 @@ interface ReviewOperationSurface { readonly CwlReviewOperationError: new ( code: | 'invalid_operation' + | 'stale_operation_changed' | 'accepted_operation_unchanged' | 'rejected_operation_changed', ) => Error & { readonly code: | 'invalid_operation' + | 'stale_operation_changed' | 'accepted_operation_unchanged' | 'rejected_operation_changed'; }; @@ -146,10 +148,9 @@ describe('provider-neutral review operation evidence', () => { expect(JSON.stringify(result)).not.toContain('검토 제안'); }); - it('returns a stable stale result rather than silently re-anchoring a mismatched target', async () => { + it('returns a stable stale result only when a mismatched target leaves the document unchanged', async () => { const provider = digestProvider(); const previousEnvelope = createDocumentEnvelope(BEFORE_DOCUMENT); - const resultingEnvelope = createDocumentEnvelope(AFTER_DOCUMENT); const staleDigest = 'f'.repeat(64); const staleRevision = Object.freeze({ algorithm: 'SHA-256' as const, @@ -161,7 +162,7 @@ describe('provider-neutral review operation evidence', () => { insertSuggestion(staleRevision), 'accept', previousEnvelope, - resultingEnvelope, + previousEnvelope, undefined, provider, )) as Record; @@ -171,6 +172,34 @@ describe('provider-neutral review operation evidence', () => { expect(result.beforeRevision).not.toEqual(staleRevision); expect(result).not.toHaveProperty('resultingRevision'); expect(result).not.toHaveProperty('transitionEvidence'); + expect(Object.isFrozen(result)).toBe(true); + }); + + it('fails closed when a stale proposal is reported with a changed resulting document', async () => { + const provider = digestProvider(); + const previousEnvelope = createDocumentEnvelope(BEFORE_DOCUMENT); + const resultingEnvelope = createDocumentEnvelope(AFTER_DOCUMENT); + const staleDigest = 'f'.repeat(64); + const staleRevision = Object.freeze({ + algorithm: 'SHA-256' as const, + digestHex: staleDigest, + strongEntityTag: `"sha256-${staleDigest}"`, + }); + + await expect( + reviewOperationSurface().createReviewOperationResult( + insertSuggestion(staleRevision), + 'accept', + previousEnvelope, + resultingEnvelope, + undefined, + provider, + ), + ).rejects.toMatchObject({ + name: 'CwlReviewOperationError', + code: 'stale_operation_changed', + message: 'Stale review operations must not change the document.', + }); }); it('requires accepted operations to change the document and rejected operations to preserve it', async () => { From 85c8b8fad88ce8b0571d3a4c5665910838241ac5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:12:59 -0700 Subject: [PATCH 30/63] fix(review): reject mutated stale operations --- src/review/index.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/review/index.ts b/src/review/index.ts index 3082caaf..6c035021 100644 --- a/src/review/index.ts +++ b/src/review/index.ts @@ -77,6 +77,7 @@ export class CwlReviewPresentationError extends Error { /** Stable redacted failure codes for review-operation evidence. */ export type CwlReviewOperationErrorCode = | 'invalid_operation' + | 'stale_operation_changed' | 'accepted_operation_unchanged' | 'rejected_operation_changed'; @@ -85,6 +86,7 @@ const REVIEW_OPERATION_ERROR_MESSAGES: Record< string > = { invalid_operation: 'Review operation is invalid.', + stale_operation_changed: 'Stale review operations must not change the document.', accepted_operation_unchanged: 'Accepted review operation must change the document revision.', rejected_operation_changed: @@ -469,8 +471,9 @@ export function createReviewSuggestion(source: unknown): CwlReviewSuggestion { * document envelopes after its authorized operation. Inkspan validates the * proposal, derives canonical transition evidence, and refuses to classify an * accepted operation that changed nothing or a rejected operation that changed - * the document. A stale proposal returns a compact `stale` result instead of - * silently re-anchoring it to the current revision. + * the document. A stale proposal returns a compact `stale` result only when the + * actual document remained unchanged; stale evidence paired with a mutation is + * rejected fail-closed rather than hiding an out-of-contract document change. * * The result contains revisions and transition metadata only; proposal text and * document bodies are not retained. Host-owned identity, authorization, @@ -508,6 +511,9 @@ export async function createReviewOperationResult( if ( transition.previousRevision.digestHex !== suggestion.target.revision.digestHex ) { + if (transition.changed) { + throw new CwlReviewOperationError('stale_operation_changed'); + } return Object.freeze({ contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, action, From 315fbfc49e6b0214cfdaa81e78b14de3e9901fe5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:17:13 -0700 Subject: [PATCH 31/63] fix(review): stage declaration runtime dependencies --- scripts/verify-review-react-package.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/verify-review-react-package.mjs b/scripts/verify-review-react-package.mjs index 7ee70e6f..a4fd794b 100644 --- a/scripts/verify-review-react-package.mjs +++ b/scripts/verify-review-react-package.mjs @@ -78,6 +78,8 @@ function preparePackage() { 'react-dom', '@types/react', '@types/react-dom', + '@tiptap/core', + '@tiptap/pm', ]) { linkDependency(dependency); } From 4b949f222eaae16562e3a60644199ab73174eb96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:59:41 -0700 Subject: [PATCH 32/63] test(review): reject duplicate thread presentation keys --- src/review-react/index.test.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/review-react/index.test.tsx b/src/review-react/index.test.tsx index c928a9cc..cfe67bde 100644 --- a/src/review-react/index.test.tsx +++ b/src/review-react/index.test.tsx @@ -122,6 +122,22 @@ describe('CwlReviewThreadList', () => { expect(screen.getByRole('button', { name: 'Resolve' })).toBeDisabled(); }); + it('fails closed before rendering duplicate host thread keys', () => { + const first = presentation('thread_1'); + const duplicate = presentation('thread_1', { selected: true }); + + expect(() => + render( + , + ), + ).toThrow(/Review presentation metadata is invalid/u); + expect(screen.queryByRole('region', { name: 'Document review' })).not.toBeInTheDocument(); + }); + it('fails closed through the review contract before rendering hostile thread metadata', () => { const hostile = presentation('thread_1', { commentBody: 'private-body-must-not-render', From 6aea2ac9f1280849f2a78768f65de5c13d38934c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:03:10 -0700 Subject: [PATCH 33/63] fix(review): reject duplicate thread keys --- src/review-react/index.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/review-react/index.tsx b/src/review-react/index.tsx index 68277b5c..8eca3551 100644 --- a/src/review-react/index.tsx +++ b/src/review-react/index.tsx @@ -1,5 +1,6 @@ import { createReviewThreadPresentation, + CwlReviewPresentationError, type CwlReviewThreadPresentation, } from '../review/index.js'; @@ -50,6 +51,13 @@ export function CwlReviewThreadList({ const validatedPresentations = presentations.map((presentation) => createReviewThreadPresentation(presentation), ); + const threadKeys = new Set(); + for (const presentation of validatedPresentations) { + if (threadKeys.has(presentation.threadKey)) { + throw new CwlReviewPresentationError(); + } + threadKeys.add(presentation.threadKey); + } return (
From aa2dec881aefd30d631c32ddfee81d1412b512dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 04:44:49 -0700 Subject: [PATCH 34/63] test(review): reject malformed presentation collections --- src/review-react/index.test.tsx | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/review-react/index.test.tsx b/src/review-react/index.test.tsx index cfe67bde..b41b9f01 100644 --- a/src/review-react/index.test.tsx +++ b/src/review-react/index.test.tsx @@ -138,6 +138,27 @@ describe('CwlReviewThreadList', () => { expect(screen.queryByRole('region', { name: 'Document review' })).not.toBeInTheDocument(); }); + it('fails closed before invoking malformed presentation collection behavior', () => { + const privateSentinel = 'private-presentation-collection-must-not-leak'; + const hostileCollection = Object.defineProperty({}, 'map', { + enumerable: true, + get() { + throw new Error(privateSentinel); + }, + }) as unknown as readonly unknown[]; + + expect(() => + render( + , + ), + ).toThrow('Review presentation metadata is invalid.'); + expect(screen.queryByRole('region', { name: 'Document review' })).not.toBeInTheDocument(); + }); + it('fails closed through the review contract before rendering hostile thread metadata', () => { const hostile = presentation('thread_1', { commentBody: 'private-body-must-not-render', From fac9e8015647a44dab591a10d83cbc4407ebb90d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 04:47:29 -0700 Subject: [PATCH 35/63] fix(review): normalize malformed presentation collections --- src/review-react/index.tsx | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/src/review-react/index.tsx b/src/review-react/index.tsx index 8eca3551..eee520b3 100644 --- a/src/review-react/index.tsx +++ b/src/review-react/index.tsx @@ -33,6 +33,30 @@ export interface CwlReviewThreadListProps { readonly onResolveThread?: (thread: CwlReviewThreadPresentation) => void; } +function validateReviewThreadPresentations( + presentations: readonly unknown[], +): readonly CwlReviewThreadPresentation[] { + try { + if (!Array.isArray(presentations)) { + throw new CwlReviewPresentationError(); + } + + const validatedPresentations = presentations.map((presentation) => + createReviewThreadPresentation(presentation), + ); + const threadKeys = new Set(); + for (const presentation of validatedPresentations) { + if (threadKeys.has(presentation.threadKey)) { + throw new CwlReviewPresentationError(); + } + threadKeys.add(presentation.threadKey); + } + return validatedPresentations; + } catch { + throw new CwlReviewPresentationError(); + } +} + /** * Render a controlled accessible list of bounded review-thread presentations. * @@ -48,16 +72,8 @@ export function CwlReviewThreadList({ onReplyThread, onResolveThread, }: CwlReviewThreadListProps) { - const validatedPresentations = presentations.map((presentation) => - createReviewThreadPresentation(presentation), - ); - const threadKeys = new Set(); - for (const presentation of validatedPresentations) { - if (threadKeys.has(presentation.threadKey)) { - throw new CwlReviewPresentationError(); - } - threadKeys.add(presentation.threadKey); - } + const validatedPresentations = + validateReviewThreadPresentations(presentations); return (
From e06141367dc2d4c1a7afa2cc93a525a28e2a5aa3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 05:35:05 -0700 Subject: [PATCH 36/63] test(review): reject hostile inaccessible labels --- src/review-react/index.test.tsx | 67 +++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/review-react/index.test.tsx b/src/review-react/index.test.tsx index b41b9f01..f9c8e638 100644 --- a/src/review-react/index.test.tsx +++ b/src/review-react/index.test.tsx @@ -175,4 +175,71 @@ describe('CwlReviewThreadList', () => { ).toThrow(/Review presentation metadata is invalid/u); expect(screen.queryByText('private-body-must-not-render')).not.toBeInTheDocument(); }); + + it('fails closed before invoking accessor-backed or inaccessible host labels', () => { + const privateSentinel = 'private-review-label-must-not-leak'; + let regionGetterCalls = 0; + const hostileLabels = { + thread: labels.thread, + reply: labels.reply, + resolve: labels.resolve, + } as Record; + Object.defineProperty(hostileLabels, 'region', { + enumerable: true, + get() { + regionGetterCalls += 1; + throw new Error(privateSentinel); + }, + }); + + expect(() => + render( + , + ), + ).toThrow('Review presentation metadata is invalid.'); + expect(regionGetterCalls).toBe(0); + expect(screen.queryByRole('region')).not.toBeInTheDocument(); + + expect(() => + render( + , + ), + ).toThrow('Review presentation metadata is invalid.'); + }); + + it('fails closed on invalid or throwing per-thread accessible labels', () => { + expect(() => + render( + '' }} + onSelectThread={vi.fn()} + />, + ), + ).toThrow('Review presentation metadata is invalid.'); + + const privateSentinel = 'private-thread-label-must-not-leak'; + expect(() => + render( + , + ), + ).toThrow('Review presentation metadata is invalid.'); + }); }); From b769019b097d1c812f0df8c7499995e1af20698e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 05:35:52 -0700 Subject: [PATCH 37/63] fix(review): fail closed on inaccessible host labels --- src/review-react/index.tsx | 105 ++++++++++++++++++++++++++++++++++--- 1 file changed, 98 insertions(+), 7 deletions(-) diff --git a/src/review-react/index.tsx b/src/review-react/index.tsx index eee520b3..a01f42fa 100644 --- a/src/review-react/index.tsx +++ b/src/review-react/index.tsx @@ -33,6 +33,89 @@ export interface CwlReviewThreadListProps { readonly onResolveThread?: (thread: CwlReviewThreadPresentation) => void; } +const REVIEW_LABEL_KEYS = ['region', 'thread', 'reply', 'resolve'] as const; +const MAX_REVIEW_LABEL_CODE_UNITS = 512; + +type ReviewThreadLabelFactory = CwlReviewThreadListLabels['thread']; + +interface ValidatedReviewThreadListLabels { + readonly region: string; + readonly thread: ReviewThreadLabelFactory; + readonly reply: string; + readonly resolve: string; +} + +function rejectReviewPresentation(): never { + throw new CwlReviewPresentationError(); +} + +function requireVisibleLabel(value: unknown): string { + if ( + typeof value !== 'string' || + value.trim().length === 0 || + value.length > MAX_REVIEW_LABEL_CODE_UNITS + ) { + rejectReviewPresentation(); + } + return value; +} + +function validateReviewThreadListLabels( + source: unknown, +): ValidatedReviewThreadListLabels { + try { + if (typeof source !== 'object' || source === null) { + rejectReviewPresentation(); + } + const ownKeys = Reflect.ownKeys(source); + if ( + ownKeys.length !== REVIEW_LABEL_KEYS.length || + ownKeys.some( + (key) => typeof key !== 'string' || !REVIEW_LABEL_KEYS.includes(key), + ) + ) { + rejectReviewPresentation(); + } + + const values: Record = {}; + for (const key of REVIEW_LABEL_KEYS) { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if ( + descriptor === undefined || + descriptor.enumerable !== true || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + rejectReviewPresentation(); + } + values[key] = descriptor.value; + } + + if (typeof values.thread !== 'function') { + rejectReviewPresentation(); + } + return Object.freeze({ + region: requireVisibleLabel(values.region), + thread: values.thread as ReviewThreadLabelFactory, + reply: requireVisibleLabel(values.reply), + resolve: requireVisibleLabel(values.resolve), + }); + } catch { + rejectReviewPresentation(); + } +} + +function createThreadLabel( + labelFactory: ReviewThreadLabelFactory, + presentation: CwlReviewThreadPresentation, + index: number, +): string { + try { + return requireVisibleLabel(labelFactory(presentation, index)); + } catch { + rejectReviewPresentation(); + } +} + function validateReviewThreadPresentations( presentations: readonly unknown[], ): readonly CwlReviewThreadPresentation[] { @@ -61,9 +144,13 @@ function validateReviewThreadPresentations( * Render a controlled accessible list of bounded review-thread presentations. * * Every source record passes through the React-free review validator before any - * host metadata is rendered. The component emits only intent callbacks with the - * detached, frozen presentation snapshot; it does not authorize, persist, - * transport, mutate, resolve, or reply to host-owned review records. + * host metadata is rendered. Host labels must be exact enumerable data fields, + * bounded non-empty visible strings, and one explicit thread-label function; + * accessor-backed labels and thrown/private label failures are normalized to the + * same redacted presentation error before React commits inaccessible content. + * The component emits only intent callbacks with the detached, frozen + * presentation snapshot; it does not authorize, persist, transport, mutate, + * resolve, or reply to host-owned review records. */ export function CwlReviewThreadList({ presentations, @@ -74,9 +161,13 @@ export function CwlReviewThreadList({ }: CwlReviewThreadListProps) { const validatedPresentations = validateReviewThreadPresentations(presentations); + const validatedLabels = validateReviewThreadListLabels(labels); + const threadLabels = validatedPresentations.map((presentation, index) => + createThreadLabel(validatedLabels.thread, presentation, index), + ); return ( -
+
    {validatedPresentations.map((presentation, index) => { const replyHandler = @@ -97,21 +188,21 @@ export function CwlReviewThreadList({ aria-pressed={presentation.selected} onClick={() => onSelectThread(presentation)} > - {labels.thread(presentation, index)} + {threadLabels[index]} ); From ac712c0d08633152e8888168d1ca3d44155c5330 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 05:39:20 -0700 Subject: [PATCH 38/63] fix(review): preserve label-key type narrowing --- src/review-react/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/review-react/index.tsx b/src/review-react/index.tsx index a01f42fa..2d44ee2d 100644 --- a/src/review-react/index.tsx +++ b/src/review-react/index.tsx @@ -71,7 +71,9 @@ function validateReviewThreadListLabels( if ( ownKeys.length !== REVIEW_LABEL_KEYS.length || ownKeys.some( - (key) => typeof key !== 'string' || !REVIEW_LABEL_KEYS.includes(key), + (key) => + typeof key !== 'string' || + !REVIEW_LABEL_KEYS.some((candidate) => candidate === key), ) ) { rejectReviewPresentation(); From f7b97b73ed6f869bd120c0af9657882dc376f54d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 05:44:44 -0700 Subject: [PATCH 39/63] test(review): cover fail-closed label schema --- src/review-react/index.test.tsx | 97 +++++++++++++++++++-------------- 1 file changed, 56 insertions(+), 41 deletions(-) diff --git a/src/review-react/index.test.tsx b/src/review-react/index.test.tsx index f9c8e638..161c1fd2 100644 --- a/src/review-react/index.test.tsx +++ b/src/review-react/index.test.tsx @@ -51,6 +51,18 @@ const labels = { resolve: 'Resolve', }; +function expectInvalidLabels(candidate: unknown) { + expect(() => + render( + , + ), + ).toThrow('Review presentation metadata is invalid.'); +} + describe('CwlReviewThreadList', () => { it('renders a controlled accessible thread list and emits detached presentation intents', () => { const onSelectThread = vi.fn(); @@ -192,54 +204,57 @@ describe('CwlReviewThreadList', () => { }, }); - expect(() => - render( - , - ), - ).toThrow('Review presentation metadata is invalid.'); + expectInvalidLabels(hostileLabels); expect(regionGetterCalls).toBe(0); expect(screen.queryByRole('region')).not.toBeInTheDocument(); - expect(() => - render( - , - ), - ).toThrow('Review presentation metadata is invalid.'); + expectInvalidLabels({ ...labels, region: '' }); + expectInvalidLabels({ ...labels, region: 'x'.repeat(513) }); + }); + + it('rejects malformed host label containers and descriptor shapes', () => { + expectInvalidLabels(null); + expectInvalidLabels({ + region: labels.region, + thread: labels.thread, + reply: labels.reply, + unexpected: labels.resolve, + }); + expectInvalidLabels({ + region: labels.region, + thread: labels.thread, + reply: labels.reply, + [Symbol('resolve')]: labels.resolve, + }); + expectInvalidLabels({ ...labels, thread: 'not-a-function' }); + + const hiddenRegion = { ...labels }; + Object.defineProperty(hiddenRegion, 'region', { + configurable: true, + enumerable: false, + value: labels.region, + }); + expectInvalidLabels(hiddenRegion); + + const missingDescriptor = new Proxy( + {}, + { + ownKeys: () => ['region', 'thread', 'reply', 'resolve'], + getOwnPropertyDescriptor: () => undefined, + }, + ); + expectInvalidLabels(missingDescriptor); }); it('fails closed on invalid or throwing per-thread accessible labels', () => { - expect(() => - render( - '' }} - onSelectThread={vi.fn()} - />, - ), - ).toThrow('Review presentation metadata is invalid.'); + expectInvalidLabels({ ...labels, thread: () => '' }); const privateSentinel = 'private-thread-label-must-not-leak'; - expect(() => - render( - , - ), - ).toThrow('Review presentation metadata is invalid.'); + expectInvalidLabels({ + ...labels, + thread() { + throw new Error(privateSentinel); + }, + }); }); }); From 7ff4451698943f826a15df4bb18b7d226e65822b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 09:07:54 -0700 Subject: [PATCH 40/63] test(review): require thread-specific action names --- src/review-react/index.test.tsx | 34 ++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/review-react/index.test.tsx b/src/review-react/index.test.tsx index 161c1fd2..553b20da 100644 --- a/src/review-react/index.test.tsx +++ b/src/review-react/index.test.tsx @@ -106,8 +106,24 @@ describe('CwlReviewThreadList', () => { expect(selected).not.toBe(second); expect(Object.isFrozen(selected)).toBe(true); - const replyButtons = screen.getAllByRole('button', { name: 'Reply' }); - const resolveButtons = screen.getAllByRole('button', { name: 'Resolve' }); + const replyButtons = [ + screen.getByRole('button', { + name: 'Reply — Thread 1: unresolved, 2 comments', + }), + screen.getByRole('button', { + name: 'Reply — Thread 2: resolved, 2 comments', + }), + ]; + const resolveButtons = [ + screen.getByRole('button', { + name: 'Resolve — Thread 1: unresolved, 2 comments', + }), + screen.getByRole('button', { + name: 'Resolve — Thread 2: resolved, 2 comments', + }), + ]; + expect(replyButtons[0]).toHaveTextContent('Reply'); + expect(resolveButtons[0]).toHaveTextContent('Resolve'); expect(replyButtons[0]).toBeEnabled(); expect(replyButtons[1]).toBeDisabled(); expect(resolveButtons[0]).toBeEnabled(); @@ -130,8 +146,16 @@ describe('CwlReviewThreadList', () => { />, ); - expect(screen.getByRole('button', { name: 'Reply' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Resolve' })).toBeDisabled(); + expect( + screen.getByRole('button', { + name: 'Reply — Thread 1: unresolved, 2 comments', + }), + ).toBeDisabled(); + expect( + screen.getByRole('button', { + name: 'Resolve — Thread 1: unresolved, 2 comments', + }), + ).toBeDisabled(); }); it('fails closed before rendering duplicate host thread keys', () => { @@ -168,7 +192,7 @@ describe('CwlReviewThreadList', () => { />, ), ).toThrow('Review presentation metadata is invalid.'); - expect(screen.queryByRole('region', { name: 'Document review' })).not.toBeInTheDocument(); + expect(screen.queryByRole('region')).not.toBeInTheDocument(); }); it('fails closed through the review contract before rendering hostile thread metadata', () => { From 0713a5da961096d1209fa451c233426fc3a47571 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 09:09:14 -0700 Subject: [PATCH 41/63] fix(review): disambiguate thread action names --- src/review-react/index.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/review-react/index.tsx b/src/review-react/index.tsx index 2d44ee2d..4f84c54d 100644 --- a/src/review-react/index.tsx +++ b/src/review-react/index.tsx @@ -150,9 +150,11 @@ function validateReviewThreadPresentations( * bounded non-empty visible strings, and one explicit thread-label function; * accessor-backed labels and thrown/private label failures are normalized to the * same redacted presentation error before React commits inaccessible content. - * The component emits only intent callbacks with the detached, frozen - * presentation snapshot; it does not authorize, persist, transport, mutate, - * resolve, or reply to host-owned review records. + * Repeated reply/resolve controls include the already validated thread label in + * their accessible name so action lists remain disambiguated without changing + * visible host copy. The component emits only intent callbacks with the + * detached, frozen presentation snapshot; it does not authorize, persist, + * transport, mutate, resolve, or reply to host-owned review records. */ export function CwlReviewThreadList({ presentations, @@ -172,6 +174,10 @@ export function CwlReviewThreadList({
      {validatedPresentations.map((presentation, index) => { + const threadLabel = threadLabels[index]; + if (threadLabel === undefined) { + rejectReviewPresentation(); + } const replyHandler = presentation.canReply && onReplyThread !== undefined ? () => onReplyThread(presentation) @@ -190,10 +196,11 @@ export function CwlReviewThreadList({ aria-pressed={presentation.selected} onClick={() => onSelectThread(presentation)} > - {threadLabels[index]} + {threadLabel} From 8fe9e3fff718867826af59fc550b0621d581323e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 15:29:32 -0700 Subject: [PATCH 46/63] test(review): reject malformed intent callbacks --- .../intentCallbackValidation.test.tsx | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/review-react/intentCallbackValidation.test.tsx diff --git a/src/review-react/intentCallbackValidation.test.tsx b/src/review-react/intentCallbackValidation.test.tsx new file mode 100644 index 00000000..ab9ffc1c --- /dev/null +++ b/src/review-react/intentCallbackValidation.test.tsx @@ -0,0 +1,71 @@ +import { cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CwlReviewThreadList } from './index.js'; + +afterEach(cleanup); + +function presentation() { + const digestHex = 'a'.repeat(64); + return { + contractVersion: 1, + threadKey: 'thread_1', + target: { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { + type: 'TextPositionSelector', + start: 3, + end: 8, + }, + projection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + }, + state: 'unresolved', + commentCount: 1, + selected: false, + canReply: true, + canResolve: true, + }; +} + +const labels = { + region: 'Document review', + thread: () => 'Thread 1', + reply: 'Reply', + resolve: 'Resolve', +}; + +function expectInvalidIntentCallbacks( + overrides: Record, +): void { + expect(() => + render( + , + ), + ).toThrow('Review presentation metadata is invalid.'); +} + +describe('CwlReviewThreadList intent callback validation', () => { + it('fails closed before rendering when the required selection callback is malformed', () => { + expectInvalidIntentCallbacks({ onSelectThread: null }); + }); + + it('fails closed before rendering when an optional reply callback is malformed', () => { + expectInvalidIntentCallbacks({ onReplyThread: 'not-a-function' }); + }); + + it('fails closed before rendering when an optional resolve callback is malformed', () => { + expectInvalidIntentCallbacks({ onResolveThread: 42 }); + }); +}); From 9dab12cb7ca1d9fadf5a4bc4611a14dc619d4491 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 15:31:18 -0700 Subject: [PATCH 47/63] test(review): make intent callback RED type-valid --- .../intentCallbackValidation.test.tsx | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/review-react/intentCallbackValidation.test.tsx b/src/review-react/intentCallbackValidation.test.tsx index ab9ffc1c..25c4666d 100644 --- a/src/review-react/intentCallbackValidation.test.tsx +++ b/src/review-react/intentCallbackValidation.test.tsx @@ -1,6 +1,9 @@ import { cleanup, render } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { CwlReviewThreadList } from './index.js'; +import { + CwlReviewThreadList, + type CwlReviewThreadListProps, +} from './index.js'; afterEach(cleanup); @@ -44,16 +47,16 @@ const labels = { function expectInvalidIntentCallbacks( overrides: Record, ): void { - expect(() => - render( - , - ), - ).toThrow('Review presentation metadata is invalid.'); + const props = { + presentations: [presentation()], + labels, + onSelectThread: vi.fn(), + ...overrides, + } as unknown as CwlReviewThreadListProps; + + expect(() => render()).toThrow( + 'Review presentation metadata is invalid.', + ); } describe('CwlReviewThreadList intent callback validation', () => { From 91178c4dfcd62bae54a8d769353f5bb9b73365cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 15:33:54 -0700 Subject: [PATCH 48/63] fix(review): fail closed on malformed intent callbacks --- src/review-react/index.tsx | 47 ++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/src/review-react/index.tsx b/src/review-react/index.tsx index 36d52039..d5bb65e4 100644 --- a/src/review-react/index.tsx +++ b/src/review-react/index.tsx @@ -38,6 +38,7 @@ const REVIEW_LABEL_KEYS = ['region', 'thread', 'reply', 'resolve'] as const; const MAX_REVIEW_LABEL_CODE_UNITS = 512; type ReviewThreadLabelFactory = CwlReviewThreadListLabels['thread']; +type ReviewIntentCallback = CwlReviewThreadListProps['onSelectThread']; interface ValidatedReviewThreadListLabels { readonly region: string; @@ -46,6 +47,12 @@ interface ValidatedReviewThreadListLabels { readonly resolve: string; } +interface ValidatedReviewIntentCallbacks { + readonly onSelectThread: ReviewIntentCallback; + readonly onReplyThread: ReviewIntentCallback | undefined; + readonly onResolveThread: ReviewIntentCallback | undefined; +} + function rejectReviewPresentation(): never { throw new CwlReviewPresentationError(); } @@ -107,6 +114,27 @@ function validateReviewThreadListLabels( } } +function validateReviewIntentCallbacks( + onSelectThread: unknown, + onReplyThread: unknown, + onResolveThread: unknown, +): ValidatedReviewIntentCallbacks { + if (typeof onSelectThread !== 'function') { + rejectReviewPresentation(); + } + if (onReplyThread !== undefined && typeof onReplyThread !== 'function') { + rejectReviewPresentation(); + } + if (onResolveThread !== undefined && typeof onResolveThread !== 'function') { + rejectReviewPresentation(); + } + return Object.freeze({ + onSelectThread: onSelectThread as ReviewIntentCallback, + onReplyThread: onReplyThread as ReviewIntentCallback | undefined, + onResolveThread: onResolveThread as ReviewIntentCallback | undefined, + }); +} + function createThreadLabel( labelFactory: ReviewThreadLabelFactory, presentation: CwlReviewThreadPresentation, @@ -170,6 +198,9 @@ function reviewThreadFocusIndex( * bounded non-empty visible strings, and one explicit thread-label function; * accessor-backed labels and thrown/private label failures are normalized to the * same redacted presentation error before React commits inaccessible content. + * Required and optional host intent callbacks are preflighted and snapshotted + * before rendering so malformed runtime values fail closed at the same public + * presentation boundary rather than surfacing a native invocation TypeError. * Arrow Up/Down and Home/End move DOM focus only among thread-selection targets; * keyboard traversal never commits host-controlled thread selection. Repeated * reply/resolve controls include the already validated thread label in their @@ -189,6 +220,11 @@ export function CwlReviewThreadList({ const validatedPresentations = validateReviewThreadPresentations(presentations); const validatedLabels = validateReviewThreadListLabels(labels); + const validatedCallbacks = validateReviewIntentCallbacks( + onSelectThread, + onReplyThread, + onResolveThread, + ); return (
      @@ -200,14 +236,15 @@ export function CwlReviewThreadList({ index, ); const replyHandler = - presentation.canReply && onReplyThread !== undefined - ? () => onReplyThread(presentation) + presentation.canReply && + validatedCallbacks.onReplyThread !== undefined + ? () => validatedCallbacks.onReplyThread!(presentation) : undefined; const resolveHandler = presentation.state === 'unresolved' && presentation.canResolve && - onResolveThread !== undefined - ? () => onResolveThread(presentation) + validatedCallbacks.onResolveThread !== undefined + ? () => validatedCallbacks.onResolveThread!(presentation) : undefined; return ( @@ -218,7 +255,7 @@ export function CwlReviewThreadList({ }} type="button" aria-pressed={presentation.selected} - onClick={() => onSelectThread(presentation)} + onClick={() => validatedCallbacks.onSelectThread(presentation)} onKeyDown={(event) => { const targetIndex = reviewThreadFocusIndex( event.key, From 987c27692bfd8e164b1d99e65c14351bad9fe68f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 19:33:02 -0700 Subject: [PATCH 49/63] test(review): preflight presentation collection entries --- .../presentationCollectionBoundary.test.tsx | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 src/review-react/presentationCollectionBoundary.test.tsx diff --git a/src/review-react/presentationCollectionBoundary.test.tsx b/src/review-react/presentationCollectionBoundary.test.tsx new file mode 100644 index 00000000..ef30b5a8 --- /dev/null +++ b/src/review-react/presentationCollectionBoundary.test.tsx @@ -0,0 +1,91 @@ +import { cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CwlReviewThreadList } from './index.js'; + +afterEach(cleanup); + +function presentation(threadKey = 'thread_1') { + const digestHex = 'a'.repeat(64); + return { + contractVersion: 1, + threadKey, + target: { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { + type: 'TextPositionSelector', + start: 3, + end: 8, + }, + projection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + }, + state: 'unresolved', + commentCount: 1, + selected: false, + canReply: true, + canResolve: true, + }; +} + +const labels = { + region: 'Document review', + thread: () => 'Thread', + reply: 'Reply', + resolve: 'Resolve', +}; + +function renderPresentations(presentations: readonly unknown[]) { + return () => + render( + , + ); +} + +describe('CwlReviewThreadList presentation collection boundary', () => { + it('rejects accessor-backed array entries without invoking the accessor', () => { + let getterCalls = 0; + const presentations: unknown[] = []; + Object.defineProperty(presentations, '0', { + enumerable: true, + configurable: true, + get() { + getterCalls += 1; + return presentation(); + }, + }); + + expect(renderPresentations(presentations)).toThrow( + 'Review presentation metadata is invalid.', + ); + expect(getterCalls).toBe(0); + }); + + it('rejects oversized collections before inspecting any presentation entry', () => { + let getterCalls = 0; + const presentations = new Array(1_025); + Object.defineProperty(presentations, '0', { + enumerable: true, + configurable: true, + get() { + getterCalls += 1; + return presentation(); + }, + }); + + expect(renderPresentations(presentations)).toThrow( + 'Review presentation metadata is invalid.', + ); + expect(getterCalls).toBe(0); + }); +}); From 771c156d1eb787908e75dfcaea9a9d6c1c8fc39c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 19:37:37 -0700 Subject: [PATCH 50/63] fix(review): bound presentation collection preflight --- src/review-react/index.tsx | 59 +++++++++++++------ .../presentationCollectionBoundary.test.tsx | 20 +++++++ 2 files changed, 60 insertions(+), 19 deletions(-) diff --git a/src/review-react/index.tsx b/src/review-react/index.tsx index d5bb65e4..3417bc98 100644 --- a/src/review-react/index.tsx +++ b/src/review-react/index.tsx @@ -36,6 +36,7 @@ export interface CwlReviewThreadListProps { const REVIEW_LABEL_KEYS = ['region', 'thread', 'reply', 'resolve'] as const; const MAX_REVIEW_LABEL_CODE_UNITS = 512; +const MAX_REVIEW_THREAD_PRESENTATIONS = 1_024; type ReviewThreadLabelFactory = CwlReviewThreadListLabels['thread']; type ReviewIntentCallback = CwlReviewThreadListProps['onSelectThread']; @@ -151,13 +152,31 @@ function validateReviewThreadPresentations( presentations: readonly unknown[], ): readonly CwlReviewThreadPresentation[] { try { - if (!Array.isArray(presentations)) { + if ( + !Array.isArray(presentations) || + presentations.length > MAX_REVIEW_THREAD_PRESENTATIONS + ) { throw new CwlReviewPresentationError(); } - const validatedPresentations = presentations.map((presentation) => - createReviewThreadPresentation(presentation), - ); + const validatedPresentations: CwlReviewThreadPresentation[] = []; + for (let index = 0; index < presentations.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor( + presentations, + String(index), + ); + if ( + descriptor === undefined || + descriptor.enumerable !== true || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new CwlReviewPresentationError(); + } + validatedPresentations.push( + createReviewThreadPresentation(descriptor.value), + ); + } + const threadKeys = new Set(); for (const presentation of validatedPresentations) { if (threadKeys.has(presentation.threadKey)) { @@ -193,21 +212,23 @@ function reviewThreadFocusIndex( /** * Render a controlled accessible list of bounded review-thread presentations. * - * Every source record passes through the React-free review validator before any - * host metadata is rendered. Host labels must be exact enumerable data fields, - * bounded non-empty visible strings, and one explicit thread-label function; - * accessor-backed labels and thrown/private label failures are normalized to the - * same redacted presentation error before React commits inaccessible content. - * Required and optional host intent callbacks are preflighted and snapshotted - * before rendering so malformed runtime values fail closed at the same public - * presentation boundary rather than surfacing a native invocation TypeError. - * Arrow Up/Down and Home/End move DOM focus only among thread-selection targets; - * keyboard traversal never commits host-controlled thread selection. Repeated - * reply/resolve controls include the already validated thread label in their - * accessible name so action lists remain disambiguated without changing visible - * host copy. The component emits only intent callbacks with the detached, frozen - * presentation snapshot; it does not authorize, persist, transport, mutate, - * resolve, or reply to host-owned review records. + * The collection is capped before any item inspection. Every array slot must be + * a dense enumerable data property, so accessor-backed or sparse host entries + * fail closed without invoking host accessors before the React-free review + * validator inspects each value. Host labels must be exact enumerable data + * fields, bounded non-empty visible strings, and one explicit thread-label + * function; accessor-backed labels and thrown/private label failures are + * normalized to the same redacted presentation error before React commits + * inaccessible content. Required and optional host intent callbacks are + * preflighted and snapshotted before rendering so malformed runtime values fail + * closed at the same public presentation boundary rather than surfacing a native + * invocation TypeError. Arrow Up/Down and Home/End move DOM focus only among + * thread-selection targets; keyboard traversal never commits host-controlled + * thread selection. Repeated reply/resolve controls include the already validated + * thread label in their accessible name so action lists remain disambiguated + * without changing visible host copy. The component emits only intent callbacks + * with the detached, frozen presentation snapshot; it does not authorize, + * persist, transport, mutate, resolve, or reply to host-owned review records. */ export function CwlReviewThreadList({ presentations, diff --git a/src/review-react/presentationCollectionBoundary.test.tsx b/src/review-react/presentationCollectionBoundary.test.tsx index ef30b5a8..a7a28952 100644 --- a/src/review-react/presentationCollectionBoundary.test.tsx +++ b/src/review-react/presentationCollectionBoundary.test.tsx @@ -88,4 +88,24 @@ describe('CwlReviewThreadList presentation collection boundary', () => { ); expect(getterCalls).toBe(0); }); + + it('rejects sparse presentation arrays instead of silently skipping holes', () => { + expect(renderPresentations(new Array(1))).toThrow( + 'Review presentation metadata is invalid.', + ); + }); + + it('rejects non-enumerable presentation entries before value validation', () => { + const presentations: unknown[] = []; + Object.defineProperty(presentations, '0', { + enumerable: false, + configurable: true, + writable: true, + value: presentation(), + }); + + expect(renderPresentations(presentations)).toThrow( + 'Review presentation metadata is invalid.', + ); + }); }); From 6da14e1e934136f9c194be767301f72c870e430d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:08:08 -0700 Subject: [PATCH 51/63] test(review): require explicit accessible thread summaries --- src/review-react/semanticSummary.test.tsx | 106 ++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 src/review-react/semanticSummary.test.tsx diff --git a/src/review-react/semanticSummary.test.tsx b/src/review-react/semanticSummary.test.tsx new file mode 100644 index 00000000..6203d4c6 --- /dev/null +++ b/src/review-react/semanticSummary.test.tsx @@ -0,0 +1,106 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + CwlReviewThreadList, + type CwlReviewThreadListLabels, +} from './index.js'; + +afterEach(cleanup); + +function presentation( + threadKey: string, + state: 'unresolved' | 'resolved', + commentCount: number, +) { + const digestHex = (threadKey === 'thread_1' ? 'a' : 'b').repeat(64); + return { + contractVersion: 1, + threadKey, + target: { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { + type: 'TextPositionSelector', + start: 3, + end: 8, + }, + projection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + }, + state, + commentCount, + selected: false, + canReply: true, + canResolve: state === 'unresolved', + }; +} + +const labels = { + region: 'Document review', + thread: (_thread: unknown, index: number) => `Thread ${index + 1}`, + status: (thread: { readonly state: 'unresolved' | 'resolved' }) => + thread.state === 'resolved' ? 'Resolved' : 'Unresolved', + comments: (thread: { readonly commentCount: number }) => + `${thread.commentCount} comments`, + reply: 'Reply', + resolve: 'Resolve', +} as unknown as CwlReviewThreadListLabels; + +describe('CwlReviewThreadList semantic summaries', () => { + it('renders host-localized status and count text as the accessible description for each thread and action', () => { + render( + , + ); + + const firstThread = screen.getByRole('button', { name: 'Thread 1' }); + const secondThread = screen.getByRole('button', { name: 'Thread 2' }); + expect(firstThread).toHaveAccessibleDescription('Unresolved 2 comments'); + expect(secondThread).toHaveAccessibleDescription('Resolved 5 comments'); + expect(screen.getByText('Unresolved')).toBeVisible(); + expect(screen.getByText('Resolved')).toBeVisible(); + expect(screen.getByText('2 comments')).toBeVisible(); + expect(screen.getByText('5 comments')).toBeVisible(); + + expect( + screen.getByRole('button', { name: 'Reply — Thread 1' }), + ).toHaveAccessibleDescription('Unresolved 2 comments'); + expect( + screen.getByRole('button', { name: 'Resolve — Thread 1' }), + ).toHaveAccessibleDescription('Unresolved 2 comments'); + }); + + it('fails closed when only one semantic-summary label factory is supplied', () => { + const incompleteLabels = { + region: 'Document review', + thread: () => 'Thread 1', + status: () => 'Unresolved', + reply: 'Reply', + resolve: 'Resolve', + } as unknown as CwlReviewThreadListLabels; + + expect(() => + render( + , + ), + ).toThrow('Review presentation metadata is invalid.'); + }); +}); From 9724ef6408bf29885a78cb820b8856f965465f52 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:11:46 -0700 Subject: [PATCH 52/63] feat(review): expose accessible thread summaries --- src/review-react/index.tsx | 120 +++++++++++++++++++++++++++++++------ 1 file changed, 102 insertions(+), 18 deletions(-) diff --git a/src/review-react/index.tsx b/src/review-react/index.tsx index 3417bc98..1765c6e3 100644 --- a/src/review-react/index.tsx +++ b/src/review-react/index.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { useId, useRef } from 'react'; import { createReviewThreadPresentation, CwlReviewPresentationError, @@ -14,6 +14,16 @@ export interface CwlReviewThreadListLabels { thread: CwlReviewThreadPresentation, index: number, ) => string; + /** Optional visible status summary. Must be paired with `comments`. */ + readonly status?: ( + thread: CwlReviewThreadPresentation, + index: number, + ) => string; + /** Optional visible comment-count summary. Must be paired with `status`. */ + readonly comments?: ( + thread: CwlReviewThreadPresentation, + index: number, + ) => string; /** Visible label for the host-owned reply intent. */ readonly reply: string; /** Visible label for the host-owned resolve intent. */ @@ -35,15 +45,24 @@ export interface CwlReviewThreadListProps { } const REVIEW_LABEL_KEYS = ['region', 'thread', 'reply', 'resolve'] as const; +const REVIEW_SUMMARY_LABEL_KEYS = ['status', 'comments'] as const; const MAX_REVIEW_LABEL_CODE_UNITS = 512; const MAX_REVIEW_THREAD_PRESENTATIONS = 1_024; type ReviewThreadLabelFactory = CwlReviewThreadListLabels['thread']; +type ReviewThreadStatusLabelFactory = NonNullable< + CwlReviewThreadListLabels['status'] +>; +type ReviewThreadCommentsLabelFactory = NonNullable< + CwlReviewThreadListLabels['comments'] +>; type ReviewIntentCallback = CwlReviewThreadListProps['onSelectThread']; interface ValidatedReviewThreadListLabels { readonly region: string; readonly thread: ReviewThreadLabelFactory; + readonly status: ReviewThreadStatusLabelFactory | undefined; + readonly comments: ReviewThreadCommentsLabelFactory | undefined; readonly reply: string; readonly resolve: string; } @@ -77,19 +96,24 @@ function validateReviewThreadListLabels( rejectReviewPresentation(); } const ownKeys = Reflect.ownKeys(source); + const allowedKeys = [...REVIEW_LABEL_KEYS, ...REVIEW_SUMMARY_LABEL_KEYS]; + const hasSummaryLabels = + ownKeys.length === allowedKeys.length && + REVIEW_SUMMARY_LABEL_KEYS.every((key) => ownKeys.includes(key)); if ( - ownKeys.length !== REVIEW_LABEL_KEYS.length || + (ownKeys.length !== REVIEW_LABEL_KEYS.length && !hasSummaryLabels) || ownKeys.some( (key) => typeof key !== 'string' || - !REVIEW_LABEL_KEYS.some((candidate) => candidate === key), + !allowedKeys.some((candidate) => candidate === key), ) ) { rejectReviewPresentation(); } + const expectedKeys = hasSummaryLabels ? allowedKeys : REVIEW_LABEL_KEYS; const values: Record = {}; - for (const key of REVIEW_LABEL_KEYS) { + for (const key of expectedKeys) { const descriptor = Object.getOwnPropertyDescriptor(source, key); if ( descriptor === undefined || @@ -101,12 +125,23 @@ function validateReviewThreadListLabels( values[key] = descriptor.value; } - if (typeof values.thread !== 'function') { + if ( + typeof values.thread !== 'function' || + (hasSummaryLabels && + (typeof values.status !== 'function' || + typeof values.comments !== 'function')) + ) { rejectReviewPresentation(); } return Object.freeze({ region: requireVisibleLabel(values.region), thread: values.thread as ReviewThreadLabelFactory, + status: hasSummaryLabels + ? (values.status as ReviewThreadStatusLabelFactory) + : undefined, + comments: hasSummaryLabels + ? (values.comments as ReviewThreadCommentsLabelFactory) + : undefined, reply: requireVisibleLabel(values.reply), resolve: requireVisibleLabel(values.resolve), }); @@ -148,6 +183,20 @@ function createThreadLabel( } } +function createThreadSummaryLabel( + labelFactory: + | ReviewThreadStatusLabelFactory + | ReviewThreadCommentsLabelFactory, + presentation: CwlReviewThreadPresentation, + index: number, +): string { + try { + return requireVisibleLabel(labelFactory(presentation, index)); + } catch { + rejectReviewPresentation(); + } +} + function validateReviewThreadPresentations( presentations: readonly unknown[], ): readonly CwlReviewThreadPresentation[] { @@ -216,19 +265,23 @@ function reviewThreadFocusIndex( * a dense enumerable data property, so accessor-backed or sparse host entries * fail closed without invoking host accessors before the React-free review * validator inspects each value. Host labels must be exact enumerable data - * fields, bounded non-empty visible strings, and one explicit thread-label - * function; accessor-backed labels and thrown/private label failures are - * normalized to the same redacted presentation error before React commits - * inaccessible content. Required and optional host intent callbacks are - * preflighted and snapshotted before rendering so malformed runtime values fail - * closed at the same public presentation boundary rather than surfacing a native - * invocation TypeError. Arrow Up/Down and Home/End move DOM focus only among - * thread-selection targets; keyboard traversal never commits host-controlled - * thread selection. Repeated reply/resolve controls include the already validated - * thread label in their accessible name so action lists remain disambiguated - * without changing visible host copy. The component emits only intent callbacks - * with the detached, frozen presentation snapshot; it does not authorize, - * persist, transport, mutate, resolve, or reply to host-owned review records. + * fields and bounded non-empty visible strings. The required thread-label + * factory may be accompanied by paired status/comment-summary factories; + * supplying only one summary factory fails closed. Accessor-backed labels and + * thrown/private label failures are normalized to the same redacted + * presentation error before React commits inaccessible content. When summary + * factories are present their localized visible output also describes the + * thread-selection, reply, and resolve controls without changing action names. + * Required and optional host intent callbacks are preflighted and snapshotted + * before rendering so malformed runtime values fail closed at the same public + * presentation boundary rather than surfacing a native invocation TypeError. + * Arrow Up/Down and Home/End move DOM focus only among thread-selection targets; + * keyboard traversal never commits host-controlled thread selection. Repeated + * reply/resolve controls include the already validated thread label in their + * accessible name so action lists remain disambiguated without changing visible + * host copy. The component emits only intent callbacks with the detached, frozen + * presentation snapshot; it does not authorize, persist, transport, mutate, + * resolve, or reply to host-owned review records. */ export function CwlReviewThreadList({ presentations, @@ -237,6 +290,7 @@ export function CwlReviewThreadList({ onReplyThread, onResolveThread, }: CwlReviewThreadListProps) { + const listId = useId(); const threadButtons = useRef>([]); const validatedPresentations = validateReviewThreadPresentations(presentations); @@ -256,6 +310,27 @@ export function CwlReviewThreadList({ presentation, index, ); + const statusFactory = validatedLabels.status; + const commentsFactory = validatedLabels.comments; + const semanticSummary = + statusFactory !== undefined && commentsFactory !== undefined + ? { + status: createThreadSummaryLabel( + statusFactory, + presentation, + index, + ), + comments: createThreadSummaryLabel( + commentsFactory, + presentation, + index, + ), + } + : undefined; + const summaryId = + semanticSummary === undefined + ? undefined + : `${listId}-thread-${index}-summary`; const replyHandler = presentation.canReply && validatedCallbacks.onReplyThread !== undefined @@ -276,6 +351,7 @@ export function CwlReviewThreadList({ }} type="button" aria-pressed={presentation.selected} + aria-describedby={summaryId} onClick={() => validatedCallbacks.onSelectThread(presentation)} onKeyDown={(event) => { const targetIndex = reviewThreadFocusIndex( @@ -292,9 +368,16 @@ export function CwlReviewThreadList({ > {threadLabel} + {semanticSummary === undefined ? null : ( + + {semanticSummary.status}{' '} + {semanticSummary.comments} + + )}