From db5fc365f847876e1f32e6992dc269db974e7f98 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:14:26 -0700 Subject: [PATCH 01/85] 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 000000000..aaeba0df7 --- /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/85] 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 aaeba0df7..c95fa1cbb 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/85] 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 000000000..e60f74db6 --- /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/85] 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 000000000..420c5ff78 --- /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/85] 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 000000000..085a5f616 --- /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/85] 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 4e55d9241..d0b17a476 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/85] 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 000000000..5ebf33838 --- /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/85] 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 e60f74db6..2a85315bc 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/85] 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 085a5f616..5249e4e74 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/85] 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 ddb4df0ee..dfbf032eb 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/85] 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 f2b02332d..42ea48add 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/85] 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 5ebf33838..f62858355 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/85] 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 f62858355..fa86dc3e1 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/85] 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 000000000..a5c2daab7 --- /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/85] 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 2a85315bc..09d629825 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/85] 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 000000000..b2884b5ee --- /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/85] 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 b2884b5ee..0ab9f56a8 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/85] 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 09d629825..3933fa2be 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/85] 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 5249e4e74..97b585fe3 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/85] 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 000000000..7a5c8af9d --- /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/85] 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 3933fa2be..3082caaff 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/85] 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 000000000..c928a9cc8 --- /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/85] 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 000000000..68277b5c1 --- /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/85] 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 c95fa1cbb..831b5dcc2 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/85] 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 d0b17a476..aa83c356f 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 000000000..27bdc74da --- /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 000000000..ac606bf0c --- /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/85] 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 dfbf032eb..5effa500e 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/85] 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 42ea48add..8d58e9309 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/85] 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 27bdc74da..7ee70e6f5 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/85] 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 0ab9f56a8..60552fdf1 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/85] 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 3082caaff..6c035021b 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/85] 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 7ee70e6f5..a4fd794b8 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/85] 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 c928a9cc8..cfe67bde9 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/85] 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 68277b5c1..8eca35511 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/85] 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 cfe67bde9..b41b9f012 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/85] 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 8eca35511..eee520b33 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/85] 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 b41b9f012..f9c8e638a 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/85] 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 eee520b33..a01f42fa7 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/85] 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 a01f42fa7..2d44ee2d9 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/85] 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 f9c8e638a..161c1fd28 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/85] 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 161c1fd28..553b20daa 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/85] 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 2d44ee2d9..4f84c54da 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/85] 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 000000000..ab9ffc1cf --- /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/85] 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 ab9ffc1cf..25c4666d3 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/85] 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 36d520394..d5bb65e4b 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/85] 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 000000000..ef30b5a8e --- /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/85] 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 d5bb65e4b..3417bc984 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 ef30b5a8e..a7a289525 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/85] 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 000000000..6203d4c68 --- /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/85] 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 3417bc984..1765c6e30 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} + + )} + ); +} + /** * Render a controlled accessible list of bounded review-thread presentations. * From eefa7ca7434dc43d5087d0f1c4d57c3c19ef226a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 00:05:26 -0700 Subject: [PATCH 68/85] test(review): reject ambiguous multi-selected thread collections --- src/review-react/presentationCollectionBoundary.test.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/review-react/presentationCollectionBoundary.test.tsx b/src/review-react/presentationCollectionBoundary.test.tsx index a7a289525..2a734f89e 100644 --- a/src/review-react/presentationCollectionBoundary.test.tsx +++ b/src/review-react/presentationCollectionBoundary.test.tsx @@ -108,4 +108,13 @@ describe('CwlReviewThreadList presentation collection boundary', () => { 'Review presentation metadata is invalid.', ); }); + + it('rejects collections with more than one host-selected thread', () => { + expect( + renderPresentations([ + { ...presentation('thread_1'), selected: true }, + { ...presentation('thread_2'), selected: true }, + ]), + ).toThrow('Review presentation metadata is invalid.'); + }); }); From cbcd4110543875668ff109184ddf43ceb613c34d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 01:08:19 -0700 Subject: [PATCH 69/85] fix(review): reject ambiguous selected thread collections --- src/review-react/index.tsx | 56 ++++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/src/review-react/index.tsx b/src/review-react/index.tsx index 9479b407c..0bf154eed 100644 --- a/src/review-react/index.tsx +++ b/src/review-react/index.tsx @@ -237,11 +237,18 @@ function validateReviewThreadPresentations( } const threadKeys = new Set(); + let selectedThreadCount = 0; for (const presentation of validatedPresentations) { if (threadKeys.has(presentation.threadKey)) { throw new CwlReviewPresentationError(); } threadKeys.add(presentation.threadKey); + if (presentation.selected) { + selectedThreadCount += 1; + if (selectedThreadCount > 1) { + throw new CwlReviewPresentationError(); + } + } } return validatedPresentations; } catch { @@ -351,29 +358,30 @@ export function CwlReviewTargetMarker({ * 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 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. - * Private failures thrown by validated intent callbacks are likewise normalized - * to the public presentation error instead of leaking host details. Exactly one - * thread-selection target participates in the tab order: the host-selected - * thread is the initial rover when present, otherwise the first thread is. Once - * focus enters the list the rover is retained by stable validated `threadKey`. - * Arrow Up/Down and Home/End move that roving DOM focus only; 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. + * validator inspects each value. Collections with multiple host-selected + * threads likewise fail closed so controlled selection remains unambiguous. + * Host labels must be exact enumerable data 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. Private failures thrown by validated intent callbacks + * are likewise normalized to the public presentation error instead of leaking + * host details. Exactly one thread-selection target participates in the tab + * order: the host-selected thread is the initial rover when present, otherwise + * the first thread is. Once focus enters the list the rover is retained by + * stable validated `threadKey`. Arrow Up/Down and Home/End move that roving DOM + * focus only; 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, @@ -513,4 +521,4 @@ export function CwlReviewThreadList({
    ); -} +} \ No newline at end of file From 5ab8b31205788cb7edf6e7555a61e019a896a1c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 01:10:41 -0700 Subject: [PATCH 70/85] test(review): preflight presentation array length --- .../presentationCollectionBoundary.test.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/review-react/presentationCollectionBoundary.test.tsx b/src/review-react/presentationCollectionBoundary.test.tsx index 2a734f89e..842a8c415 100644 --- a/src/review-react/presentationCollectionBoundary.test.tsx +++ b/src/review-react/presentationCollectionBoundary.test.tsx @@ -89,6 +89,22 @@ describe('CwlReviewThreadList presentation collection boundary', () => { expect(getterCalls).toBe(0); }); + it('does not trust caller-controlled array length reads', () => { + let lengthReads = 0; + const presentations = new Proxy([presentation()], { + get(target, property, receiver) { + if (property === 'length') { + lengthReads += 1; + return 0; + } + return Reflect.get(target, property, receiver); + }, + }); + + expect(renderPresentations(presentations)).not.toThrow(); + expect(lengthReads).toBe(0); + }); + it('rejects sparse presentation arrays instead of silently skipping holes', () => { expect(renderPresentations(new Array(1))).toThrow( 'Review presentation metadata is invalid.', @@ -117,4 +133,4 @@ describe('CwlReviewThreadList presentation collection boundary', () => { ]), ).toThrow('Review presentation metadata is invalid.'); }); -}); +}); \ No newline at end of file From c90d44ec6fa25a6e842b5fc32f6d164d23c588b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 01:13:16 -0700 Subject: [PATCH 71/85] fix(review): snapshot presentation array length --- src/review-react/index.tsx | 75 +++++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 30 deletions(-) diff --git a/src/review-react/index.tsx b/src/review-react/index.tsx index 0bf154eed..93b55ea3c 100644 --- a/src/review-react/index.tsx +++ b/src/review-react/index.tsx @@ -211,15 +211,28 @@ function validateReviewThreadPresentations( presentations: readonly unknown[], ): readonly CwlReviewThreadPresentation[] { try { + if (!Array.isArray(presentations)) { + throw new CwlReviewPresentationError(); + } + + const lengthDescriptor = Object.getOwnPropertyDescriptor( + presentations, + 'length', + ); if ( - !Array.isArray(presentations) || - presentations.length > MAX_REVIEW_THREAD_PRESENTATIONS + lengthDescriptor === undefined || + !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value') || + typeof lengthDescriptor.value !== 'number' || + !Number.isSafeInteger(lengthDescriptor.value) || + lengthDescriptor.value < 0 || + lengthDescriptor.value > MAX_REVIEW_THREAD_PRESENTATIONS ) { throw new CwlReviewPresentationError(); } + const presentationCount = lengthDescriptor.value; const validatedPresentations: CwlReviewThreadPresentation[] = []; - for (let index = 0; index < presentations.length; index += 1) { + for (let index = 0; index < presentationCount; index += 1) { const descriptor = Object.getOwnPropertyDescriptor( presentations, String(index), @@ -355,33 +368,35 @@ export function CwlReviewTargetMarker({ /** * Render a controlled accessible list of bounded review-thread presentations. * - * 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. Collections with multiple host-selected - * threads likewise fail closed so controlled selection remains unambiguous. - * Host labels must be exact enumerable data 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. Private failures thrown by validated intent callbacks - * are likewise normalized to the public presentation error instead of leaking - * host details. Exactly one thread-selection target participates in the tab - * order: the host-selected thread is the initial rover when present, otherwise - * the first thread is. Once focus enters the list the rover is retained by - * stable validated `threadKey`. Arrow Up/Down and Home/End move that roving DOM - * focus only; 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 count is snapshotted from its own data descriptor before any + * item inspection, without invoking a caller-controlled `length` getter. 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. Collections with multiple + * host-selected threads likewise fail closed so controlled selection remains + * unambiguous. Host labels must be exact enumerable data 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. Private failures thrown by + * validated intent callbacks are likewise normalized to the public presentation + * error instead of leaking host details. Exactly one thread-selection target + * participates in the tab order: the host-selected thread is the initial rover + * when present, otherwise the first thread is. Once focus enters the list the + * rover is retained by stable validated `threadKey`. Arrow Up/Down and Home/End + * move that roving DOM focus only; 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, From 8c0aaf828f337675ca10e3b4e1fd5aef54c1b65f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 22:30:30 +0900 Subject: [PATCH 72/85] docs(review): trace proposed contract boundaries Signed-off-by: Seongho Bae --- docs/CONTRACTS.md | 9 +++++++++ docs/PRD.md | 4 ++++ docs/TRD.md | 8 +++++++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 5a62716e5..8a8a9218b 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -47,6 +47,14 @@ Local evidence records describe narrowly scoped facts such as revision identity, No single status collapses those authorities. Ordinary evidence must avoid embedding complete document bodies, selected quote text, credentials, tenant identifiers, prompts, model outputs, or private exception causes unless a separate authorized contract explicitly requires them. +## Review contract (Active PR / Proposed) + +The proposed `./review` subpath accepts only versioned, bounded, provider-neutral review targets, thread presentation records, and insert/delete suggestions. Targets use `inkspan-prosemirror-text` projection offsets and one exact canonical document revision. The surface validates and detaches untrusted metadata, rejects stale direct reuse, and can classify host-supplied before/after envelopes as accepted, rejected, or stale without retaining proposal text or document bodies in the result. + +Classification is evidence, not mutation authority. The host applies any authorized editor transaction and owns reviewer/thread identity, permissions, transport, durable persistence, resolution, notifications, audit, retention, and cross-revision re-anchoring. Acceptance must produce a changed revision; rejection must preserve the revision; a stale target paired with any document change fails closed. + +The proposed `./review-react` subpath renders controlled native-button target markers and thread lists from the validated presentation contract. It exposes selection, reply, and resolve intents only. Host-controlled selection remains authoritative, unavailable actions remain disabled, keyboard focus traversal does not commit selection, and host callbacks cannot turn presentation state into authorization or durable success. + ## W3C text-position selector evidence contract Protected `main` exposes `getTextPositionSelectorEvidence()` through the root package as a revision-scoped annotation-interoperability primitive. It does **not** reinterpret `CwlEditorSelectionSnapshot` or ProseMirror structural positions as W3C positions. It derives a separate W3C `TextPositionSelector` from the same captured immutable editor state that is used for revision derivation. @@ -149,6 +157,7 @@ Rollback must preserve readable canonical documents and must not require silentl | Markdown/HTML editing | deterministic editor state and supported import/export semantics | application workflow, document ownership, authorization | | document envelope/revision | schema validation, identity routing, canonical bytes, local equality evidence | migration orchestration, durable storage, signatures, tenant binding | | selection / W3C annotation evidence | exact-revision structural coordinates and versioned text-position projection | annotation identity/body, source IRI, authorization, persistence, audit, publication, re-anchoring | +| review targets and suggestions (Active PR / Proposed) | bounded exact-revision metadata, accessible controlled presentation, revision-only operation classification | editor mutation, reviewer/thread identity, authorization, persistence, resolution, notifications, audit, re-anchoring | | autosave | local ordering/state, callback contract, validator validation | transport, durable CAS, retry/offline policy, persistence | | collaboration | provider-neutral editor/Yjs binding | provider lifecycle, rooms, identity, authorization, persistence, awareness privacy | | Office rendering | deterministic bounded JSON→artifact conversion | file destination policy, downstream distribution, tenant authorization | diff --git a/docs/PRD.md b/docs/PRD.md index 3ccec07c5..9fe2f05cb 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -31,6 +31,7 @@ The product promise is: **author, convert, collaborate, and prove document chang 11. Produce reviewable package, security, compatibility, accessibility, SBOM/provenance, and release evidence tied to one exact protected source head. 12. Give security researchers a discoverable private vulnerability-reporting and coordinated-disclosure path without promising unsupported SLAs, bounties, certification, or legal safe harbor. 13. Identify a complete unsupported document-envelope generation safely enough for the host to select its own migration without accepting that generation as current Inkspan document semantics. +14. Review a revision-scoped comment or insert/delete proposal through an accessible controlled surface without moving reviewer identity, authorization, durable thread state, or document mutation authority into Inkspan. ## Required outcomes @@ -61,6 +62,7 @@ The product promise is: **author, convert, collaborate, and prove document chang - Autosave remains single-flight with bounded active/pending work and explicit conflict/failure recovery. - Durable saves use a host/server-selected strong validator; conflict or ambiguous failure never silently advances it. - Lifecycle observation emits only distinct externally visible document-free state transitions; construction and no-op operations do not manufacture notifications. +- Proposed review targets and insert/delete suggestions bind to one exact document revision and the named text projection. Stale targets fail closed; accepting must change the revision, rejecting must preserve it, and neither result may claim authorization or durable review state. ### SSR and native forms @@ -148,3 +150,5 @@ Protected `main` is the sole implemented baseline. Open PRs may describe Propose SafeClipboard, real Chromium/Firefox/WebKit release assurance, lifecycle observation, the root security disclosure lifecycle, toolbar shortcut accessibility metadata, SSR/native-form serialization, revision-scoped selection evidence, W3C text-position selector evidence, document-transition evidence, and envelope identity migration routing are implemented on protected `main`. A named editor-chrome theme-token catalog, DTCG 2025.10 interchange snapshot, and Storybook inventory for repeating toolbar/editor objects are Active PR / Proposed and are not shipped claims until protected integration. Hosts must check inventoried active-chrome contrast (`--cwl-accent` on `--cwl-accent-soft`) in addition to body text. + +The provider-neutral review contract and controlled accessible review-thread/target-marker surfaces are also Active PR / Proposed. They validate bounded host data, emit only detached intent snapshots, and classify exact-revision accept/reject outcomes; they do not apply editor transactions or own reviewer identity, authorization, thread persistence, audit, notifications, or cross-revision re-anchoring. diff --git a/docs/TRD.md b/docs/TRD.md index 80cec07ac..e9ad3fb23 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -54,6 +54,12 @@ Selection, transition, and W3C text-position evidence are implemented on protect The React-free text-position-selector package surface is also protected-main behavior. It reuses the deterministic projection helper without claiming editor state capture, annotation identity, authorization, durable persistence, or cross-revision re-anchoring. Hosts retain those authorities. +## Review boundary (Active PR / Proposed) + +The proposed React-free review surface validates bounded thread presentations and insert/delete proposals against the versioned text-position projection and exact canonical document revision. It returns detached frozen metadata and revision-only operation evidence. A stale proposal may be classified only when the document stayed unchanged; an accepted operation that changed nothing, a rejected operation that changed content, or any stale operation paired with a mutation fails closed. + +The proposed React surface is controlled and intent-only. Native buttons, one roving thread-selection tab stop, Arrow Up/Down and Home/End focus movement, host-supplied localized labels, and semantic status/comment summaries provide the bounded interaction layer. Inkspan does not apply the editor transaction, authorize the actor, persist or resolve a thread, send notifications, create durable audit, or re-anchor a target across revisions. + ## Autosave state machine and durable concurrency States are `idle`, `saving`, `blocked`, `closing`, and `closed`, with explicit blocked reasons. The local queue remains single-flight and retains bounded active/pending work and bounded flush waiters. Evidence supplied to a callback is immutable and validated before scheduling. @@ -141,4 +147,4 @@ Queued, cancelled, skipped-required, absent, stale-head, predecessor-head, statu Protected `main` is the sole shipped implementation baseline. SafeClipboard, cross-engine browser assurance, the security disclosure lifecycle, autosave lifecycle observation, toolbar shortcut accessibility metadata, accessible editor placeholder semantics, SSR/native-form serialization, revision-scoped selection evidence, W3C text-position selector evidence, the React-free text-position-selector subpath, document-transition evidence, envelope identity routing, framework-neutral deterministic Markdown conversion, CSS paged-media print output, DOCX informative PNG figures, bounded rich-text runs, bounded paragraph alignment, bounded heading alignment, and the OIDC-backed unified stable registry release train are `implemented_on_protected_main`. -The bounded DOCX rich-run external hyperlink contract in #137 is `implemented_on_active_pr` under Proposed ADR 0026. Open branches may extend the protected boundary, but no active-PR capability becomes shipped merely because its design, tests, or documentation are complete. +The bounded DOCX rich-run external hyperlink contract in #137 is `implemented_on_active_pr` under Proposed ADR 0026. The provider-neutral review contract and controlled React review presentation surfaces are likewise `implemented_on_active_pr`; direct editor mutation, browser/screen-reader acceptance, print/export behavior, and durable host workflows remain unaccepted. Open branches may extend the protected boundary, but no active-PR capability becomes shipped merely because its design, tests, or documentation are complete. From b1d8db3020d1df2a101bf6e61b25a1b4c1a09d7c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 22:37:06 +0900 Subject: [PATCH 73/85] test(review): cover cross-engine keyboard journey Signed-off-by: Seongho Bae --- tests/browser/harness.ts | 75 ++++++++++++++++++++++ tests/browser/playwright.config.ts | 2 +- tests/browser/specs/review.browser.spec.ts | 54 ++++++++++++++++ tests/browser/vite.config.ts | 4 ++ 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 tests/browser/specs/review.browser.spec.ts diff --git a/tests/browser/harness.ts b/tests/browser/harness.ts index c00b47a7d..d66080ddd 100644 --- a/tests/browser/harness.ts +++ b/tests/browser/harness.ts @@ -1,4 +1,6 @@ import { Editor } from '@tiptap/core'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; import { ClipboardSanitizationError, buildExtensions, @@ -6,6 +8,7 @@ import { type ClipboardConfig, type ClipboardSanitizationErrorCode, } from 'inkspan-browser-under-test'; +import { CwlReviewThreadList } from 'inkspan-review-react-under-test'; interface BrowserClipboardProbeRequest { readonly sourceHtml: string; @@ -23,6 +26,11 @@ interface BrowserHostileDocumentProbeResult { readonly message: string; } +interface BrowserReviewIntent { + readonly action: 'select' | 'reply' | 'resolve'; + readonly threadKey: string; +} + declare global { interface Window { runInkspanClipboardProbe( @@ -31,9 +39,76 @@ declare global { runInkspanHostileDocumentProbe( sourceHtml: string, ): BrowserHostileDocumentProbeResult; + mountInkspanReviewProbe(): void; + readInkspanReviewIntents(): readonly BrowserReviewIntent[]; } } +const reviewIntents: BrowserReviewIntent[] = []; +let reviewRoot: Root | null = null; + +const reviewRevision = Object.freeze({ + algorithm: 'SHA-256' as const, + digestHex: 'a'.repeat(64), + strongEntityTag: `"sha256-${'a'.repeat(64)}"`, +}); + +const reviewPresentation = ( + threadKey: string, + selected: boolean, + state: 'unresolved' | 'resolved', + canReply: boolean, + canResolve: boolean, +) => ({ + contractVersion: 1, + threadKey, + target: { + contractVersion: 1, + revision: reviewRevision, + selector: { type: 'TextPositionSelector', start: 0, end: 1 }, + projection: { id: 'inkspan-prosemirror-text', version: 1 }, + }, + state, + commentCount: state === 'resolved' ? 3 : 1, + selected, + canReply, + canResolve, +}); + +window.mountInkspanReviewProbe = (): void => { + const container = document.querySelector('#harness'); + if (!container) throw new Error('Review harness container is missing.'); + reviewIntents.length = 0; + reviewRoot?.unmount(); + reviewRoot = createRoot(container); + reviewRoot.render( + createElement(CwlReviewThreadList, { + presentations: [ + reviewPresentation('alpha', false, 'unresolved', true, true), + reviewPresentation('beta', true, 'unresolved', false, true), + reviewPresentation('gamma', false, 'resolved', true, false), + ], + labels: { + region: 'Document review', + thread: (thread) => `Thread ${thread.threadKey}`, + status: (thread) => + thread.state === 'resolved' ? 'Resolved' : 'Unresolved', + comments: (thread) => `${thread.commentCount} comments`, + reply: 'Reply', + resolve: 'Resolve', + }, + onSelectThread: (thread) => + reviewIntents.push({ action: 'select', threadKey: thread.threadKey }), + onReplyThread: (thread) => + reviewIntents.push({ action: 'reply', threadKey: thread.threadKey }), + onResolveThread: (thread) => + reviewIntents.push({ action: 'resolve', threadKey: thread.threadKey }), + }), + ); +}; + +window.readInkspanReviewIntents = () => structuredClone(reviewIntents); + window.runInkspanClipboardProbe = ( request: BrowserClipboardProbeRequest, ): BrowserClipboardProbeResult => { diff --git a/tests/browser/playwright.config.ts b/tests/browser/playwright.config.ts index d7d4c2a20..50014e58b 100644 --- a/tests/browser/playwright.config.ts +++ b/tests/browser/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig, devices } from '@playwright/test'; const HARNESS_ORIGIN = 'http://127.0.0.1:4173'; const HARNESS_URL = `${HARNESS_ORIGIN}/tests/browser/harness.html`; -const ENGINE_BROWSER_SPECS = /(?:clipboard|focus|print)\.browser\.spec\.ts/u; +const ENGINE_BROWSER_SPECS = /(?:clipboard|focus|print|review)\.browser\.spec\.ts/u; export default defineConfig({ testDir: './specs', diff --git a/tests/browser/specs/review.browser.spec.ts b/tests/browser/specs/review.browser.spec.ts new file mode 100644 index 000000000..661d4bb76 --- /dev/null +++ b/tests/browser/specs/review.browser.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.goto('/tests/browser/harness.html'); + await page.evaluate(() => window.mountInkspanReviewProbe()); +}); + +test('keeps review focus and host intent deterministic across real engines', async ({ + page, +}) => { + const region = page.getByRole('region', { name: 'Document review' }); + const alpha = region.getByRole('button', { + name: 'Thread alpha', + exact: true, + }); + const beta = region.getByRole('button', { + name: 'Thread beta', + exact: true, + }); + const gamma = region.getByRole('button', { + name: 'Thread gamma', + exact: true, + }); + + await beta.focus(); + await expect(beta).toBeFocused(); + await expect(beta).toHaveAttribute('aria-pressed', 'true'); + await expect(beta).toHaveAccessibleDescription('Unresolved 1 comments'); + + await page.keyboard.press('End'); + await expect(gamma).toBeFocused(); + await expect(gamma).toHaveAttribute('aria-pressed', 'false'); + await expect(gamma).toHaveAccessibleDescription('Resolved 3 comments'); + expect(await page.evaluate(() => window.readInkspanReviewIntents())).toEqual([]); + + await page.keyboard.press('Enter'); + expect(await page.evaluate(() => window.readInkspanReviewIntents())).toEqual([ + { action: 'select', threadKey: 'gamma' }, + ]); + + await page.keyboard.press('Home'); + await expect(alpha).toBeFocused(); + await page.keyboard.press('ArrowDown'); + await expect(beta).toBeFocused(); + await expect( + region.getByRole('button', { name: 'Reply — Thread beta' }), + ).toBeDisabled(); + await expect( + region.getByRole('button', { name: 'Resolve — Thread beta' }), + ).toBeEnabled(); + await expect( + region.getByRole('button', { name: 'Resolve — Thread gamma' }), + ).toBeDisabled(); +}); diff --git a/tests/browser/vite.config.ts b/tests/browser/vite.config.ts index 50c49dd19..b761eec27 100644 --- a/tests/browser/vite.config.ts +++ b/tests/browser/vite.config.ts @@ -8,11 +8,15 @@ const configuredPackageEntry = process.env.INKSPAN_BROWSER_PACKAGE_ENTRY?.trim() const packageEntry = configuredPackageEntry ? resolve(configuredPackageEntry) : resolve(repositoryRoot, 'src/index.ts'); +const reviewReactEntry = configuredPackageEntry + ? resolve(dirname(configuredPackageEntry), 'cwl-review-react.js') + : resolve(repositoryRoot, 'src/review-react/index.tsx'); export default defineConfig({ resolve: { alias: { 'inkspan-browser-under-test': packageEntry, + 'inkspan-review-react-under-test': reviewReactEntry, }, }, }); From b7c0f092625911d5b46027f588c94551901194ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 22:44:41 +0900 Subject: [PATCH 74/85] feat(review): define explicit print behavior Signed-off-by: Seongho Bae --- docs/CONTRACTS.md | 2 +- docs/TRD.md | 2 +- docs/print-output.md | 2 ++ scripts/verify-review-react-package.mjs | 8 +++++- src/printStyles.test.ts | 12 ++++++++- src/review-react/index.test.tsx | 27 +++++++++++++++++++ src/review-react/index.tsx | 30 ++++++++++++++++++++-- src/review-react/targetMarker.test.tsx | 26 +++++++++++++++++++ src/styles.css | 17 +++++++++++- tests/browser/harness.ts | 5 ++-- tests/browser/specs/review.browser.spec.ts | 16 ++++++++++++ 11 files changed, 138 insertions(+), 9 deletions(-) diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 8a8a9218b..d04bd5732 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -53,7 +53,7 @@ The proposed `./review` subpath accepts only versioned, bounded, provider-neutra Classification is evidence, not mutation authority. The host applies any authorized editor transaction and owns reviewer/thread identity, permissions, transport, durable persistence, resolution, notifications, audit, retention, and cross-revision re-anchoring. Acceptance must produce a changed revision; rejection must preserve the revision; a stale target paired with any document change fails closed. -The proposed `./review-react` subpath renders controlled native-button target markers and thread lists from the validated presentation contract. It exposes selection, reply, and resolve intents only. Host-controlled selection remains authoritative, unavailable actions remain disabled, keyboard focus traversal does not commit selection, and host callbacks cannot turn presentation state into authorization or durable success. +The proposed `./review-react` subpath renders controlled native-button target markers and thread lists from the validated presentation contract. It exposes selection, reply, and resolve intents only. Host-controlled selection remains authoritative, unavailable actions remain disabled, keyboard focus traversal does not commit selection, and host callbacks cannot turn presentation state into authorization or durable success. Review presentation defaults to `printMode="exclude"`; explicit `include` prints bounded labels and status/comment summaries while suppressing reply/resolve controls. ## W3C text-position selector evidence contract diff --git a/docs/TRD.md b/docs/TRD.md index e9ad3fb23..d5dd55e8f 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -58,7 +58,7 @@ The React-free text-position-selector package surface is also protected-main beh The proposed React-free review surface validates bounded thread presentations and insert/delete proposals against the versioned text-position projection and exact canonical document revision. It returns detached frozen metadata and revision-only operation evidence. A stale proposal may be classified only when the document stayed unchanged; an accepted operation that changed nothing, a rejected operation that changed content, or any stale operation paired with a mutation fails closed. -The proposed React surface is controlled and intent-only. Native buttons, one roving thread-selection tab stop, Arrow Up/Down and Home/End focus movement, host-supplied localized labels, and semantic status/comment summaries provide the bounded interaction layer. Inkspan does not apply the editor transaction, authorize the actor, persist or resolve a thread, send notifications, create durable audit, or re-anchor a target across revisions. +The proposed React surface is controlled and intent-only. Native buttons, one roving thread-selection tab stop, Arrow Up/Down and Home/End focus movement, host-supplied localized labels, and semantic status/comment summaries provide the bounded interaction layer. Review presentation is excluded from print by default; explicit inclusion retains bounded labels and summaries while suppressing reply/resolve controls. Inkspan does not apply the editor transaction, authorize the actor, persist or resolve a thread, send notifications, create durable audit, or re-anchor a target across revisions. ## Autosave state machine and durable concurrency diff --git a/docs/print-output.md b/docs/print-output.md index 3bf67d1c3..d180c9d44 100644 --- a/docs/print-output.md +++ b/docs/print-output.md @@ -27,6 +27,8 @@ The print stylesheet: These are paged-media hints, not a promise that every browser/printer combination will produce byte-identical pagination. Browser engines and printer/PDF drivers remain separate rendering authorities. +The Active PR / Proposed review surface defaults `printMode` to `exclude`, so target markers and thread panels do not enter print output accidentally. A host may choose `include` to print the validated thread labels plus status/comment summaries; reply and resolve controls remain hidden because they are interactive chrome. The option does not add comment bodies, actor identity, timestamps, authorization, durable audit, or a governed export receipt. + ## Accessibility and fidelity Printing must not depend on the visual color theme to distinguish authored links, and it must not leak collaborative presence or placeholder UI into the document. Author-provided semantic structure remains the source: headings remain headings, tables remain tables, links remain links, images retain their DOM alternative-text semantics, and code/preformatted text remains text rather than rasterized screen content. diff --git a/scripts/verify-review-react-package.mjs b/scripts/verify-review-react-package.mjs index a4fd794b8..f76427b95 100644 --- a/scripts/verify-review-react-package.mjs +++ b/scripts/verify-review-react-package.mjs @@ -157,6 +157,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, /data-cwl-review-print="exclude"/u); assert.match(html, />Reply]*disabled=""[^>]*>Reply', 'u')); `, @@ -172,10 +173,12 @@ const { CwlReviewThreadList } = require('${packageJson.name}/review-react'); const html = renderToStaticMarkup(React.createElement(CwlReviewThreadList, { presentations: [${JSON.stringify(presentationFixture('b'))}], labels: ${labelsSource}, + printMode: 'include', onSelectThread() {}, })); assert.match(html, /aria-label="Document review"/u); assert.match(html, /aria-pressed="true"/u); +assert.match(html, /data-cwl-review-print="include"/u); assert.match(html, />Resolve]*disabled=""[^>]*>Resolve', 'u')); `, @@ -192,9 +195,11 @@ function verifyDeclarationConsumer() { sourcePath, `import { CwlReviewThreadList, + type CwlReviewPrintMode, type CwlReviewThreadListLabels, type CwlReviewThreadListProps, } from '${packageJson.name}/review-react'; +const printMode: CwlReviewPrintMode = 'include'; const labels: CwlReviewThreadListLabels = { region: 'Document review', thread: (thread, index) => String(index) + thread.state, @@ -204,12 +209,13 @@ const labels: CwlReviewThreadListLabels = { const props: CwlReviewThreadListProps = { presentations: [], labels, + printMode, onSelectThread(thread) { void thread.threadKey; }, }; const component: typeof CwlReviewThreadList = CwlReviewThreadList; -void [props, component]; +void [props, component, printMode]; `, 'utf8', ); diff --git a/src/printStyles.test.ts b/src/printStyles.test.ts index 632a033b1..cd009ed65 100644 --- a/src/printStyles.test.ts +++ b/src/printStyles.test.ts @@ -29,6 +29,16 @@ describe('print stylesheet contract', () => { ); }); + it('excludes review chrome by default and prints only opted-in review summaries', () => { + const printStyles = styles.slice(styles.indexOf('@media print')); + expect(printStyles).toMatch( + /\.cwl-review\[data-cwl-review-print='exclude'\][\s\S]*display:\s*none\s*!important\s*;/u, + ); + expect(printStyles).toMatch( + /\.cwl-review\[data-cwl-review-print='include'\] \.cwl-review__action\s*\{[^}]*display:\s*none\s*!important\s*;/u, + ); + }); + it('does not print placeholder UI and keeps document blocks page-safe', () => { const printIndex = styles.indexOf('@media print'); expect(printIndex).toBeGreaterThan(-1); @@ -64,4 +74,4 @@ describe('print stylesheet contract', () => { expect(browserSpecification).not.toContain('/src/styles.css'); expect(browserConfiguration).toContain('pnpm --dir ../.. build'); }); -}); \ No newline at end of file +}); diff --git a/src/review-react/index.test.tsx b/src/review-react/index.test.tsx index 553b20daa..28900449d 100644 --- a/src/review-react/index.test.tsx +++ b/src/review-react/index.test.tsx @@ -64,6 +64,33 @@ function expectInvalidLabels(candidate: unknown) { } describe('CwlReviewThreadList', () => { + it('defaults review print output to excluded and accepts explicit inclusion', () => { + const { rerender } = render( + , + ); + expect(screen.getByRole('region')).toHaveAttribute( + 'data-cwl-review-print', + 'exclude', + ); + + rerender( + , + ); + expect(screen.getByRole('region')).toHaveAttribute( + 'data-cwl-review-print', + 'include', + ); + }); + it('renders a controlled accessible thread list and emits detached presentation intents', () => { const onSelectThread = vi.fn(); const onReplyThread = vi.fn(); diff --git a/src/review-react/index.tsx b/src/review-react/index.tsx index 93b55ea3c..e263b60e7 100644 --- a/src/review-react/index.tsx +++ b/src/review-react/index.tsx @@ -30,12 +30,17 @@ export interface CwlReviewThreadListLabels { readonly resolve: string; } +/** Whether validated review presentation participates in browser print output. */ +export type CwlReviewPrintMode = 'exclude' | 'include'; + /** 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; + /** Defaults to `exclude` so review chrome never enters print accidentally. */ + readonly printMode?: CwlReviewPrintMode; /** Selection intent; the host remains the controlled-state authority. */ readonly onSelectThread: (thread: CwlReviewThreadPresentation) => void; /** Optional reply intent; absence keeps reply controls disabled. */ @@ -50,6 +55,8 @@ export interface CwlReviewTargetMarkerProps { readonly presentation: unknown; /** Host-supplied localized visible and accessible marker label. */ readonly label: string; + /** Defaults to `exclude` so review chrome never enters print accidentally. */ + readonly printMode?: CwlReviewPrintMode; /** Selection intent; the host remains editor-selection authority. */ readonly onSelectThread: (thread: CwlReviewThreadPresentation) => void; } @@ -98,6 +105,12 @@ function requireVisibleLabel(value: unknown): string { return value; } +function validateReviewPrintMode(value: unknown): CwlReviewPrintMode { + if (value === undefined || value === 'exclude') return 'exclude'; + if (value === 'include') return value; + rejectReviewPresentation(); +} + function validateReviewThreadListLabels( source: unknown, ): ValidatedReviewThreadListLabels { @@ -341,10 +354,12 @@ function invokeReviewIntent( export function CwlReviewTargetMarker({ presentation, label, + printMode, onSelectThread, }: CwlReviewTargetMarkerProps) { const validatedPresentation = createReviewThreadPresentation(presentation); const validatedLabel = requireVisibleLabel(label); + const validatedPrintMode = validateReviewPrintMode(printMode); const validatedCallback = validateReviewIntentCallbacks( onSelectThread, undefined, @@ -353,9 +368,11 @@ export function CwlReviewTargetMarker({ return (
); -} \ No newline at end of file +} diff --git a/src/review-react/targetMarker.test.tsx b/src/review-react/targetMarker.test.tsx index adb8b26bf..5c5c63509 100644 --- a/src/review-react/targetMarker.test.tsx +++ b/src/review-react/targetMarker.test.tsx @@ -37,6 +37,32 @@ function presentation(overrides: Record = {}) { } describe('CwlReviewTargetMarker', () => { + it('uses the fail-safe print default and rejects unsupported print modes', () => { + render( + , + ); + expect(screen.getByRole('button')).toHaveAttribute( + 'data-cwl-review-print', + 'exclude', + ); + cleanup(); + + expect(() => + render( + , + ), + ).toThrow('Review presentation metadata is invalid.'); + }); + it('renders one accessible controlled inline target marker and emits only a detached host intent', () => { const source = presentation(); const onSelectThread = vi.fn(); diff --git a/src/styles.css b/src/styles.css index 3ba488008..10aa2cf3f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -282,10 +282,25 @@ .cwl-toolbar, .cwl-collaboration-status, .collaboration-cursor__caret, - .collaboration-cursor__label { + .collaboration-cursor__label, + .cwl-review[data-cwl-review-print='exclude'] { display: none !important; } + .cwl-review[data-cwl-review-print='include'] .cwl-review__action { + display: none !important; + } + + .cwl-review[data-cwl-review-print='include'].cwl-review__target, + .cwl-review[data-cwl-review-print='include'] .cwl-review__thread { + appearance: none; + border: 0; + padding: 0; + background: transparent; + color: inherit; + font: inherit; + } + .cwl-editor__surface { overflow: visible; max-height: none; diff --git a/tests/browser/harness.ts b/tests/browser/harness.ts index d66080ddd..f5fd86b6b 100644 --- a/tests/browser/harness.ts +++ b/tests/browser/harness.ts @@ -39,7 +39,7 @@ declare global { runInkspanHostileDocumentProbe( sourceHtml: string, ): BrowserHostileDocumentProbeResult; - mountInkspanReviewProbe(): void; + mountInkspanReviewProbe(printMode?: 'exclude' | 'include'): void; readInkspanReviewIntents(): readonly BrowserReviewIntent[]; } } @@ -75,7 +75,7 @@ const reviewPresentation = ( canResolve, }); -window.mountInkspanReviewProbe = (): void => { +window.mountInkspanReviewProbe = (printMode = 'exclude'): void => { const container = document.querySelector('#harness'); if (!container) throw new Error('Review harness container is missing.'); reviewIntents.length = 0; @@ -97,6 +97,7 @@ window.mountInkspanReviewProbe = (): void => { reply: 'Reply', resolve: 'Resolve', }, + printMode, onSelectThread: (thread) => reviewIntents.push({ action: 'select', threadKey: thread.threadKey }), onReplyThread: (thread) => diff --git a/tests/browser/specs/review.browser.spec.ts b/tests/browser/specs/review.browser.spec.ts index 661d4bb76..f9f05f3d6 100644 --- a/tests/browser/specs/review.browser.spec.ts +++ b/tests/browser/specs/review.browser.spec.ts @@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test'; test.beforeEach(async ({ page }) => { await page.goto('/tests/browser/harness.html'); + await page.addStyleTag({ url: '/dist/cwl-editor.css' }); await page.evaluate(() => window.mountInkspanReviewProbe()); }); @@ -52,3 +53,18 @@ test('keeps review focus and host intent deterministic across real engines', asy region.getByRole('button', { name: 'Resolve — Thread gamma' }), ).toBeDisabled(); }); + +test('prints review summaries only after explicit opt-in', async ({ page }) => { + const region = page.getByRole('region', { name: 'Document review' }); + await page.emulateMedia({ media: 'print' }); + await expect(region).toBeHidden(); + + await page.evaluate(() => window.mountInkspanReviewProbe('include')); + await expect(region).toBeVisible(); + await expect( + region.getByRole('button', { name: 'Thread beta', exact: true }), + ).toBeVisible(); + await expect( + region.getByRole('button', { name: 'Resolve — Thread beta' }), + ).toBeHidden(); +}); From a182d3babb987bf9efcc420dbd5e2aad26ebc642 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 22:53:58 +0900 Subject: [PATCH 75/85] feat(review): add workflow state inventory Signed-off-by: Seongho Bae --- src/review-react/index.tsx | 4 +- src/styles.css | 87 +++++++++++++++ stories/ReviewWorkflow.stories.tsx | 122 +++++++++++++++++++++ tests/browser/specs/review.browser.spec.ts | 37 +++++++ 4 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 stories/ReviewWorkflow.stories.tsx diff --git a/src/review-react/index.tsx b/src/review-react/index.tsx index e263b60e7..9f00ba74b 100644 --- a/src/review-react/index.tsx +++ b/src/review-react/index.tsx @@ -495,7 +495,7 @@ export function CwlReviewThreadList({ : undefined; return ( -
  • +
  • {semanticSummary === undefined ? null : ( - + {semanticSummary.status}{' '} {semanticSummary.comments} diff --git a/src/styles.css b/src/styles.css index 10aa2cf3f..d5292e671 100644 --- a/src/styles.css +++ b/src/styles.css @@ -262,6 +262,93 @@ padding-top: calc(16px + 1.6em); } +.cwl-review { + color: var(--cwl-fg, #1f2937); + font-family: var(--cwl-font, system-ui, sans-serif); +} + +.cwl-review__threads { + width: min(100%, 22rem); + border-inline-start: 1px solid var(--cwl-border, #d1d5db); + padding-inline-start: 8px; +} + +.cwl-review__threads ul { + margin: 0; + padding: 0; + list-style: none; +} + +.cwl-review__item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 4px; + padding: 8px 0; + border-bottom: 1px solid var(--cwl-border, #d1d5db); +} + +.cwl-review__thread, +.cwl-review__action, +.cwl-review__target { + min-height: 32px; + border: 1px solid var(--cwl-border, #d1d5db); + background: var(--cwl-surface, #ffffff); + color: inherit; + font: inherit; +} + +.cwl-review__thread { + min-width: 0; + padding: 4px 8px; + overflow-wrap: anywhere; + text-align: start; +} + +.cwl-review__thread[aria-pressed='true'] { + border-inline-start: 3px solid var(--cwl-accent, #0969da); + background: var(--cwl-accent-soft, #ddf4ff); +} + +.cwl-review__action { + padding: 4px 8px; +} + +.cwl-review__summary { + grid-column: 1 / -1; + color: var(--cwl-muted, #57606a); + font-size: 12px; +} + +.cwl-review__thread:focus-visible, +.cwl-review__action:focus-visible, +.cwl-review__target:focus-visible { + outline: 2px solid var(--cwl-accent, #0969da); + outline-offset: 2px; +} + +@media (max-width: 30rem) { + .cwl-review__item { + grid-template-columns: 1fr 1fr; + } + + .cwl-review__thread, + .cwl-review__summary { + grid-column: 1 / -1; + } +} + +@media (forced-colors: active) { + .cwl-review__thread[aria-pressed='true'] { + border-inline-start-color: Highlight; + } + + .cwl-review__thread:focus-visible, + .cwl-review__action:focus-visible, + .cwl-review__target:focus-visible { + outline-color: CanvasText; + } +} + @media print { .cwl-editor { --cwl-fg: #000000; diff --git a/stories/ReviewWorkflow.stories.tsx b/stories/ReviewWorkflow.stories.tsx new file mode 100644 index 000000000..65be5c6f6 --- /dev/null +++ b/stories/ReviewWorkflow.stories.tsx @@ -0,0 +1,122 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { + CwlReviewThreadList, + type CwlReviewThreadListLabels, +} from '../src/review-react/index.js'; +import '../src/styles.css'; + +const digestHex = 'a'.repeat(64); +const target = { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { type: 'TextPositionSelector', start: 4, end: 12 }, + projection: { id: 'inkspan-prosemirror-text', version: 1 }, +}; + +const presentation = ( + threadKey: string, + state: 'unresolved' | 'resolved', + selected: boolean, + canReply = true, + canResolve = state === 'unresolved', +) => ({ + contractVersion: 1, + threadKey, + target, + state, + commentCount: state === 'resolved' ? 3 : 1, + selected, + canReply, + canResolve, +}); + +const labels: CwlReviewThreadListLabels = { + region: 'Document review', + thread: (thread) => `Review ${thread.threadKey}`, + status: (thread) => + thread.state === 'resolved' ? 'Resolved' : 'Needs review', + comments: (thread) => `${thread.commentCount} comments`, + reply: 'Reply', + resolve: 'Resolve', +}; + +const disabledLabels: CwlReviewThreadListLabels = { + ...labels, + status: () => 'Target is out of date. Refresh the document before acting.', +}; + +const noOp = () => undefined; + +const meta = { + title: 'Review Workflow', + component: CwlReviewThreadList, + parameters: { layout: 'centered' }, + args: { + labels, + onSelectThread: noOp, + onReplyThread: noOp, + onResolveThread: noOp, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Normal: Story = { + args: { presentations: [presentation('A-104', 'unresolved', false)] }, +}; + +export const SelectedUnresolved: Story = { + args: { presentations: [presentation('A-104', 'unresolved', true)] }, +}; + +export const Resolved: Story = { + args: { presentations: [presentation('A-104', 'resolved', false)] }, +}; + +export const Stale: Story = { + args: { + presentations: [presentation('A-104', 'unresolved', true, false, false)], + labels: disabledLabels, + }, +}; + +export const PermissionDisabled: Story = { + args: { + presentations: [presentation('A-104', 'unresolved', false, false, false)], + }, +}; + +export const NarrowScreen: Story = { + decorators: [(Story) =>
    ], + args: { + presentations: [ + presentation('A-104', 'unresolved', true), + presentation('A-105', 'resolved', false), + ], + }, +}; + +export const ForcedColors: Story = { + name: 'Forced Colors (use OS/browser emulation)', + args: { presentations: [presentation('A-104', 'unresolved', true)] }, +}; + +export const PrintExcluded: Story = { + args: { + presentations: [presentation('A-104', 'unresolved', true)], + printMode: 'exclude', + }, +}; + +export const PrintIncluded: Story = { + args: { + presentations: [presentation('A-104', 'unresolved', true)], + printMode: 'include', + }, +}; diff --git a/tests/browser/specs/review.browser.spec.ts b/tests/browser/specs/review.browser.spec.ts index f9f05f3d6..18c8ec8d0 100644 --- a/tests/browser/specs/review.browser.spec.ts +++ b/tests/browser/specs/review.browser.spec.ts @@ -68,3 +68,40 @@ test('prints review summaries only after explicit opt-in', async ({ page }) => { region.getByRole('button', { name: 'Resolve — Thread beta' }), ).toBeHidden(); }); + +test('reflows review actions without hiding content on narrow screens', async ({ + page, +}) => { + await page.setViewportSize({ width: 360, height: 640 }); + + const item = page.locator('.cwl-review__item').first(); + const thread = item.locator('.cwl-review__thread'); + const reply = item.getByRole('button', { name: 'Reply — Thread alpha' }); + + await expect(item).toBeVisible(); + await expect(thread).toBeVisible(); + await expect(reply).toBeVisible(); + expect(await item.evaluate((element) => getComputedStyle(element).gridTemplateColumns)).not.toBe( + 'none', + ); + await expect(thread).toHaveCSS('grid-column', '1 / -1'); +}); + +test('preserves selected and keyboard focus cues in forced colors', async ({ + page, +}) => { + await page.emulateMedia({ forcedColors: 'active' }); + + const selected = page.getByRole('button', { + name: 'Thread beta', + exact: true, + }); + await selected.focus(); + + expect(await page.evaluate(() => matchMedia('(forced-colors: active)').matches)).toBe( + true, + ); + await expect(selected).toBeFocused(); + await expect(selected).toHaveCSS('outline-style', 'solid'); + await expect(selected).toHaveCSS('outline-width', '2px'); +}); From aac6a27b5e90e143226cf29fd3b1842ff25deea2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 22:59:17 +0900 Subject: [PATCH 76/85] docs(review): define non-print export boundary Signed-off-by: Seongho Bae --- docs/CONTRACTS.md | 2 ++ docs/PRD.md | 1 + docs/TRD.md | 2 ++ docs/print-output.md | 2 ++ src/reviewExportDocumentation.test.ts | 23 +++++++++++++++++++++++ 5 files changed, 30 insertions(+) create mode 100644 src/reviewExportDocumentation.test.ts diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index d04bd5732..7f66c1ba4 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -55,6 +55,8 @@ Classification is evidence, not mutation authority. The host applies any authori The proposed `./review-react` subpath renders controlled native-button target markers and thread lists from the validated presentation contract. It exposes selection, reply, and resolve intents only. Host-controlled selection remains authoritative, unavailable actions remain disabled, keyboard focus traversal does not commit selection, and host callbacks cannot turn presentation state into authorization or durable success. Review presentation defaults to `printMode="exclude"`; explicit `include` prints bounded labels and status/comment summaries while suppressing reply/resolve controls. +The print option is not a general export switch. Review metadata is excluded from non-print exports, and deterministic Markdown, HTML, email, plain-text, and Office conversion serializes canonical document content only. A host that includes review records in another artifact owns that separately governed export and its authorization, disclosure, provenance, retention, accessibility, and publication policy. + ## W3C text-position selector evidence contract Protected `main` exposes `getTextPositionSelectorEvidence()` through the root package as a revision-scoped annotation-interoperability primitive. It does **not** reinterpret `CwlEditorSelectionSnapshot` or ProseMirror structural positions as W3C positions. It derives a separate W3C `TextPositionSelector` from the same captured immutable editor state that is used for revision derivation. diff --git a/docs/PRD.md b/docs/PRD.md index 9fe2f05cb..f4166eea1 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -96,6 +96,7 @@ The product promise is: **author, convert, collaborate, and prove document chang - Toolbar shortcut metadata must reflect repository-level shipped behavior, including host/editor bindings such as link editing, rather than only extension-local defaults. - Application-visible saving/conflict/recovery messages must be derivable from programmatic state without Inkspan prescribing untranslated user-facing copy. - Export/print surfaces must not rely on color alone or inaccessible interaction-only state where the corresponding product surface exists. +- By default, review metadata is excluded from non-print exports: deterministic Markdown, HTML, email, plain-text, and Office conversion receives canonical document content only. A host that needs comments or review status in an artifact must build a separately governed export with its own authorization, disclosure, retention, and accessibility policy. ### Security disclosure and vulnerability handling diff --git a/docs/TRD.md b/docs/TRD.md index d5dd55e8f..ab3820223 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -60,6 +60,8 @@ The proposed React-free review surface validates bounded thread presentations an The proposed React surface is controlled and intent-only. Native buttons, one roving thread-selection tab stop, Arrow Up/Down and Home/End focus movement, host-supplied localized labels, and semantic status/comment summaries provide the bounded interaction layer. Review presentation is excluded from print by default; explicit inclusion retains bounded labels and summaries while suppressing reply/resolve controls. Inkspan does not apply the editor transaction, authorize the actor, persist or resolve a thread, send notifications, create durable audit, or re-anchor a target across revisions. +Outside browser print, review metadata is excluded from non-print exports. Markdown, HTML, email, plain-text, and Office converters continue to serialize canonical document content only; `printMode` does not alter those inputs. A host may compose review records into a separately governed export, but Inkspan does not acquire its authorization, disclosure, provenance, retention, or artifact-publication authority. + ## Autosave state machine and durable concurrency States are `idle`, `saving`, `blocked`, `closing`, and `closed`, with explicit blocked reasons. The local queue remains single-flight and retains bounded active/pending work and bounded flush waiters. Evidence supplied to a callback is immutable and validated before scheduling. diff --git a/docs/print-output.md b/docs/print-output.md index d180c9d44..4e0a1efc0 100644 --- a/docs/print-output.md +++ b/docs/print-output.md @@ -29,6 +29,8 @@ These are paged-media hints, not a promise that every browser/printer combinatio The Active PR / Proposed review surface defaults `printMode` to `exclude`, so target markers and thread panels do not enter print output accidentally. A host may choose `include` to print the validated thread labels plus status/comment summaries; reply and resolve controls remain hidden because they are interactive chrome. The option does not add comment bodies, actor identity, timestamps, authorization, durable audit, or a governed export receipt. +`printMode` affects browser print presentation only. Review metadata is excluded from non-print exports: Markdown, HTML, email, plain-text, and Office conversion continues to receive canonical document content only. A host that needs review records in another artifact must create a separately governed export with its own authorization, disclosure, provenance, retention, accessibility, and publication policy. + ## Accessibility and fidelity Printing must not depend on the visual color theme to distinguish authored links, and it must not leak collaborative presence or placeholder UI into the document. Author-provided semantic structure remains the source: headings remain headings, tables remain tables, links remain links, images retain their DOM alternative-text semantics, and code/preformatted text remains text rather than rasterized screen content. diff --git a/src/reviewExportDocumentation.test.ts b/src/reviewExportDocumentation.test.ts new file mode 100644 index 000000000..a85bd9356 --- /dev/null +++ b/src/reviewExportDocumentation.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const repositoryFile = (file: string): string => + readFileSync(resolve(process.cwd(), file), 'utf8'); + +describe('review non-print export authority', () => { + it('keeps review presentation outside deterministic document exports', () => { + for (const file of [ + 'docs/PRD.md', + 'docs/TRD.md', + 'docs/CONTRACTS.md', + 'docs/print-output.md', + ]) { + const document = repositoryFile(file).toLowerCase(); + expect(document).toContain('review metadata is excluded from non-print exports'); + expect(document).toContain('canonical document content'); + expect(document).toContain('separately governed export'); + } + }); +}); From 220dcfa22e39db3c59393426c137044f54817f5c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 22:59:50 +0900 Subject: [PATCH 77/85] docs(review): reconcile remaining acceptance Signed-off-by: Seongho Bae --- docs/TRD.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TRD.md b/docs/TRD.md index ab3820223..6e5a81650 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -149,4 +149,4 @@ Queued, cancelled, skipped-required, absent, stale-head, predecessor-head, statu Protected `main` is the sole shipped implementation baseline. SafeClipboard, cross-engine browser assurance, the security disclosure lifecycle, autosave lifecycle observation, toolbar shortcut accessibility metadata, accessible editor placeholder semantics, SSR/native-form serialization, revision-scoped selection evidence, W3C text-position selector evidence, the React-free text-position-selector subpath, document-transition evidence, envelope identity routing, framework-neutral deterministic Markdown conversion, CSS paged-media print output, DOCX informative PNG figures, bounded rich-text runs, bounded paragraph alignment, bounded heading alignment, and the OIDC-backed unified stable registry release train are `implemented_on_protected_main`. -The bounded DOCX rich-run external hyperlink contract in #137 is `implemented_on_active_pr` under Proposed ADR 0026. The provider-neutral review contract and controlled React review presentation surfaces are likewise `implemented_on_active_pr`; direct editor mutation, browser/screen-reader acceptance, print/export behavior, and durable host workflows remain unaccepted. Open branches may extend the protected boundary, but no active-PR capability becomes shipped merely because its design, tests, or documentation are complete. +The bounded DOCX rich-run external hyperlink contract in #137 is `implemented_on_active_pr` under Proposed ADR 0026. The provider-neutral review contract and controlled React review presentation surfaces are likewise `implemented_on_active_pr`; direct editor mutation, physical screen-reader acceptance, visual-regression acceptance, and durable host workflows remain unaccepted. Open branches may extend the protected boundary, but no active-PR capability becomes shipped merely because its design, tests, or documentation are complete. From 7bd3e4f88434315d28a41c960ab68cb8b24de4ab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 23:15:53 +0900 Subject: [PATCH 78/85] feat(review): apply exact-revision suggestions Signed-off-by: Seongho Bae --- README.md | 6 + docs/CONTRACTS.md | 2 +- docs/DATA_MODEL.md | 2 + docs/PRD.md | 2 +- docs/TEST_STRATEGY.md | 3 +- docs/THREAT_MODEL.md | 4 + docs/TRACEABILITY.md | 1 + docs/TRD.md | 4 +- docs/UML.md | 22 ++ .../0005-revision-scoped-review-evidence.md | 7 +- .../CwlEditor.reviewSuggestion.test.tsx | 193 ++++++++++++++++++ src/components/useEditorHandle.test.tsx | 3 + src/components/useEditorHandle.ts | 16 ++ src/review/editorMutation.ts | 106 ++++++++++ src/reviewTransactionDocumentation.test.ts | 26 +++ src/types.ts | 15 ++ tests/package/verify-package.mjs | 6 + 17 files changed, 411 insertions(+), 7 deletions(-) create mode 100644 src/components/CwlEditor.reviewSuggestion.test.tsx create mode 100644 src/review/editorMutation.ts create mode 100644 src/reviewTransactionDocumentation.test.ts diff --git a/README.md b/README.md index a63d0bada..5841bda33 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,12 @@ editorRef.current?.focus(); `insertValue` is mode-aware, inserts at the current selection, and triggers the normal `onChange` path without wiping the document. +The Active PR / Proposed review surface also exposes +`applyReviewSuggestionDecision()`. After host authorization, `accept` applies +one exact-revision insert/delete transaction that participates in editor +undo/redo; `reject` preserves the document. A concurrent edit or repeated +accept fails stale instead of re-anchoring the proposal. + ### Atomic revision-envelope capture Autosave, AI, template, and review operations should capture their document and diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 7f66c1ba4..97824c094 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -51,7 +51,7 @@ No single status collapses those authorities. Ordinary evidence must avoid embed The proposed `./review` subpath accepts only versioned, bounded, provider-neutral review targets, thread presentation records, and insert/delete suggestions. Targets use `inkspan-prosemirror-text` projection offsets and one exact canonical document revision. The surface validates and detaches untrusted metadata, rejects stale direct reuse, and can classify host-supplied before/after envelopes as accepted, rejected, or stale without retaining proposal text or document bodies in the result. -Classification is evidence, not mutation authority. The host applies any authorized editor transaction and owns reviewer/thread identity, permissions, transport, durable persistence, resolution, notifications, audit, retention, and cross-revision re-anchoring. Acceptance must produce a changed revision; rejection must preserve the revision; a stale target paired with any document change fails closed. +Classification is evidence, not authorization. After host authorization, `CwlEditorHandle.applyReviewSuggestionDecision()` may apply an insert/delete acceptance as one exact-revision editor transaction; rejection preserves the document and creates no history entry. The adapter captures one `EditorState`, verifies its canonical revision asynchronously, and refuses the operation if the live state changed before dispatch. Acceptance must produce a changed revision; rejection must preserve the revision; a stale target paired with any document change fails closed. The host owns reviewer/thread identity, permissions, transport, durable persistence, resolution, notifications, audit, retention, and cross-revision re-anchoring. The proposed `./review-react` subpath renders controlled native-button target markers and thread lists from the validated presentation contract. It exposes selection, reply, and resolve intents only. Host-controlled selection remains authoritative, unavailable actions remain disabled, keyboard focus traversal does not commit selection, and host callbacks cannot turn presentation state into authorization or durable success. Review presentation defaults to `printMode="exclude"`; explicit `include` prints bounded labels and status/comment summaries while suppressing reply/resolve controls. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 28b86bc56..a1a8869d2 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -86,6 +86,8 @@ These values may remain ephemeral or release-artifact metadata. Their presence i | `document_transition` | none required; host may store | change evidence | no | content-lineage evidence only | | `selection_evidence` | none required | review/selection capture | no | exact-revision ProseMirror coordinates only | | `text_position_selector_evidence` | none required; `implemented_on_protected_main` | interoperable review/annotation capture | no | exact-revision W3C text positions satisfying `0 <= start <= end <= projectedCodePointLength` under one versioned projection only | +| `review_suggestion` | host if persisted; Active PR / Proposed | one insert/delete proposal | insert text only for insertion proposals | exact-revision untrusted proposal, not authorization or durable state | +| `review_operation` | none required; host may persist its own decision | one accept/reject attempt | no | revision-only local result; acceptance is one undoable editor transaction and rejection is document-preserving | | `autosave_revision` | none required | queued local save evidence | envelope-bearing evidence may be retained boundedly by queue | local save ordering only | | `autosave_snapshot` | none required | lifecycle observation/coordination | no | local machine state only; validator fields remain confidential metadata | | `durable_validator` | host | durable version | no | host concurrency evidence, not authorization | diff --git a/docs/PRD.md b/docs/PRD.md index f4166eea1..8e7510708 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -152,4 +152,4 @@ SafeClipboard, real Chromium/Firefox/WebKit release assurance, lifecycle observa A named editor-chrome theme-token catalog, DTCG 2025.10 interchange snapshot, and Storybook inventory for repeating toolbar/editor objects are Active PR / Proposed and are not shipped claims until protected integration. Hosts must check inventoried active-chrome contrast (`--cwl-accent` on `--cwl-accent-soft`) in addition to body text. -The provider-neutral review contract and controlled accessible review-thread/target-marker surfaces are also Active PR / Proposed. They validate bounded host data, emit only detached intent snapshots, and classify exact-revision accept/reject outcomes; they do not apply editor transactions or own reviewer identity, authorization, thread persistence, audit, notifications, or cross-revision re-anchoring. +The provider-neutral review contract and controlled accessible review-thread/target-marker surfaces are also Active PR / Proposed. They validate bounded host data, emit only detached intent snapshots, and apply an authorized insert/delete acceptance as one exact-revision undoable editor transaction while rejection preserves the document. They do not own reviewer identity, authorization, thread persistence, audit, notifications, or cross-revision re-anchoring. diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index d9cde2003..f9ee8efb7 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -55,12 +55,13 @@ At minimum, maintain regressions for: - SSR client-controlled form values, escaping, hydration continuity, reset behavior, and absence of server editor construction; - autosave stale validators, conflict/failure recovery, ambiguous transport outcomes, duplicate/no-op lifecycle transitions, callback exceptions, queue bounds, flush/close behavior, and durable-validator coherence; - selection/revision races and document movement during asynchronous hashing; +- review suggestion decisions covering insert, delete, reject, repeated accept, Unicode projection boundaries, out-of-range selectors, undo/redo, and document movement during asynchronous hashing; - Office formula prefixes, invalid XML characters, malicious strings, path/publication races, invalid worksheet names, invalid freeze panes, cyclic input, pathological nesting, excessive container size, and partial write failure; - package/release stale draft assets, unexpected or non-regular local entries, exact four-file inventory violations, incomplete remote uploads, GitHub-vs-local digest mismatch, stale exact-head evidence, mutable provenance inputs, and isolated packed-consumer behavior. ## Concurrency and failure testing -Use deterministic barriers/fakes for local concurrency and real process/file boundaries where required. Prove that an observer exception cannot alter queue ordering; a stale digest cannot bind to a later editor state; an ambiguous durable save does not advance a validator; close/recovery does not leak waiters; and file publication either completes under the documented contract or fails without silently replacing unrelated content. +Use deterministic barriers/fakes for local concurrency and real process/file boundaries where required. Prove that an observer exception cannot alter queue ordering; a stale digest cannot bind to a later editor state or review transaction; an ambiguous durable save does not advance a validator; close/recovery does not leak waiters; and file publication either completes under the documented contract or fails without silently replacing unrelated content. Host persistence transactions, tenant isolation, distributed collaboration authorization, durable audit storage, and production network retry policy are host-owned and must be tested by the embedding product. Inkspan tests verify only the explicit adapter contract at those boundaries. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index e1337651d..e746c65e1 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -53,6 +53,10 @@ Local SHA-256 revisions identify deterministic content equality only. Selection Concurrent editors, delayed digests, stale selections, ambiguous transport failure, or stale durable validators can cause lost updates or false success. Inkspan must bind asynchronous evidence to one immutable local state, keep autosave single-flight with bounded pending work, fail closed on ambiguous durable outcomes, and require explicit recovery from blocked conflict/failure states. Hosts own atomic persistence transactions and durable conflict resolution. +### Review suggestions and transaction admission + +An untrusted review suggestion can target a stale revision, an unsupported projection boundary, or a range that no longer denotes the intended content. The Active PR / Proposed editor adapter validates the bounded proposal, hashes the captured document, verifies that the live `EditorState` did not change during hashing, and maps only an exact version-1 text boundary before dispatching one insert/delete transaction. Any mismatch fails closed without mutation. Hosts still authorize the actor and own durable exact-once decisions, audit, persistence, and re-anchoring; undoing a local transaction does not erase or rewrite host review history. + ### Collaboration and Yjs Inkspan may bind to Yjs-compatible document/awareness surfaces but does not own provider creation, room authorization, tenant identity, persistence, retention, or durable audit. Yjs updates and awareness metadata can contain sensitive tenant information. Host providers must authenticate rooms, authorize membership, bound awareness disclosure, and apply retention/encryption policy. Inkspan must not silently create a network provider or elevate an awareness update into authorization. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index ffe5d86d4..fc8809c7f 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -15,6 +15,7 @@ This record maps durable Inkspan product decisions to authoritative standards, p | Envelope version routing | A bounded identity-only inspector identifies `schemaId`/`schemaVersion` for dispatch while the current parser stays strict and the host owns migration execution | RFC 8259; RFC 7493; RFC 8785 for canonical current-schema bytes | ADR 0015, protected-main `documentEnvelopeIdentity` implementation/tests, envelope guide/doctoring and framework-independent packed consumers | Protected-main evidence proves only bounded routing metadata; identifying a schema generation does not validate that generation's document semantics, authorize migration, or prove durable persistence | | Canonical document bytes | Deterministic revision evidence is derived from canonicalized validated document content | RFC 8785, JSON Canonicalization Scheme | revision-evidence, transition-evidence, restore tests | A content digest proves equality only, not actor/time/authorization/durable write | | W3C text-position selector | Revision-scoped annotation interoperability uses a distinct versioned logical-text projection satisfying `0 <= start <= end <= projectedCodePointLength`, with inclusive `start`, exclusive `end`, Unicode-code-point offsets, grapheme-boundary validation, and same-state revision binding instead of relabeling ProseMirror coordinates | W3C Web Annotation Data Model; ProseMirror reference manual; ECMA-402 13th edition | ADR 0018, protected-main text-position selector implementation/tests, packed consumer verifier, selection lifecycle and doctoring | Protected-main evidence proves positions only for the named projection and exact revision; it does not prove actor, authorization, durable annotation acceptance, source IRI policy, or cross-revision re-anchoring | +| Exact-revision review mutation | Authorized insert/delete acceptance maps the versioned selector into the same captured editor state and dispatches one undoable transaction; rejection preserves the document | W3C Web Annotation Data Model; ProseMirror state, transaction, and history contracts | Active PR ADR 0005, review handle integration/concurrency/undo tests, packed root consumer | Local mutation evidence is not actor authorization, durable exact-once decision state, audit, persistence, or cross-revision re-anchoring | | Headless deterministic Markdown conversion | One serializer implementation and one framework-neutral safe-link/inline-raster policy are exposed through a self-contained ESM/CommonJS/TypeScript `./markdown` subpath | CommonMark 0.31.2; Node.js package `exports` documentation | protected-main #114 implementation, packed Node consumers, package-distribution contract, `docs/doctoring/headless-markdown-package.md` | `implemented_on_protected_main`; deterministic conversion does not grant MIME delivery, recipient, auth, tenant, persistence, network, credential, or model authority | | Provenance semantics | Local transition/release evidence keeps content lineage separate from actor/authorization/durable claims | W3C PROV family | transition evidence, release evidence, canonical data model | Inkspan does not claim complete PROV conformance or host audit provenance | | Accessibility | Native controls, keyboard semantics, shortcut metadata, semantic placeholder guidance, and host-facing status state support accessible embedding | W3C WCAG 2.2; WAI-ARIA 1.2 where used | protected toolbar/accessibility tests, SSR tests, autosave lifecycle data, protected #131 placeholder tests/packed consumer and `docs/doctoring/editor-placeholder-accessibility.md` | Component evidence alone is not a full host WCAG conformance claim; `aria-placeholder` supplements but never replaces the accessible name | diff --git a/docs/TRD.md b/docs/TRD.md index 6e5a81650..373457a07 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -58,7 +58,7 @@ The React-free text-position-selector package surface is also protected-main beh The proposed React-free review surface validates bounded thread presentations and insert/delete proposals against the versioned text-position projection and exact canonical document revision. It returns detached frozen metadata and revision-only operation evidence. A stale proposal may be classified only when the document stayed unchanged; an accepted operation that changed nothing, a rejected operation that changed content, or any stale operation paired with a mutation fails closed. -The proposed React surface is controlled and intent-only. Native buttons, one roving thread-selection tab stop, Arrow Up/Down and Home/End focus movement, host-supplied localized labels, and semantic status/comment summaries provide the bounded interaction layer. Review presentation is excluded from print by default; explicit inclusion retains bounded labels and summaries while suppressing reply/resolve controls. Inkspan does not apply the editor transaction, authorize the actor, persist or resolve a thread, send notifications, create durable audit, or re-anchor a target across revisions. +The proposed React surface is controlled and intent-only. Native buttons, one roving thread-selection tab stop, Arrow Up/Down and Home/End focus movement, host-supplied localized labels, and semantic status/comment summaries provide the bounded interaction layer. Review presentation is excluded from print by default; explicit inclusion retains bounded labels and summaries while suppressing reply/resolve controls. The shared editor handle applies an authorized insert/delete acceptance as one undoable transaction only while the captured `EditorState` and exact canonical revision still match; rejection creates no transaction. Inkspan does not authorize the actor, persist or resolve a thread, send notifications, create durable audit, or re-anchor a target across revisions. Outside browser print, review metadata is excluded from non-print exports. Markdown, HTML, email, plain-text, and Office converters continue to serialize canonical document content only; `printMode` does not alter those inputs. A host may compose review records into a separately governed export, but Inkspan does not acquire its authorization, disclosure, provenance, retention, or artifact-publication authority. @@ -149,4 +149,4 @@ Queued, cancelled, skipped-required, absent, stale-head, predecessor-head, statu Protected `main` is the sole shipped implementation baseline. SafeClipboard, cross-engine browser assurance, the security disclosure lifecycle, autosave lifecycle observation, toolbar shortcut accessibility metadata, accessible editor placeholder semantics, SSR/native-form serialization, revision-scoped selection evidence, W3C text-position selector evidence, the React-free text-position-selector subpath, document-transition evidence, envelope identity routing, framework-neutral deterministic Markdown conversion, CSS paged-media print output, DOCX informative PNG figures, bounded rich-text runs, bounded paragraph alignment, bounded heading alignment, and the OIDC-backed unified stable registry release train are `implemented_on_protected_main`. -The bounded DOCX rich-run external hyperlink contract in #137 is `implemented_on_active_pr` under Proposed ADR 0026. The provider-neutral review contract and controlled React review presentation surfaces are likewise `implemented_on_active_pr`; direct editor mutation, physical screen-reader acceptance, visual-regression acceptance, and durable host workflows remain unaccepted. Open branches may extend the protected boundary, but no active-PR capability becomes shipped merely because its design, tests, or documentation are complete. +The bounded DOCX rich-run external hyperlink contract in #137 is `implemented_on_active_pr` under Proposed ADR 0026. The provider-neutral review contract, exact-revision insert/delete transaction adapter, and controlled React review presentation surfaces are likewise `implemented_on_active_pr`; physical screen-reader acceptance, visual-regression acceptance, and durable host workflows remain unaccepted. Open branches may extend the protected boundary, but no active-PR capability becomes shipped merely because its design, tests, or documentation are complete. diff --git a/docs/UML.md b/docs/UML.md index de4e20d31..9a5a29f81 100644 --- a/docs/UML.md +++ b/docs/UML.md @@ -133,6 +133,28 @@ sequenceDiagram Model output never bypasses deterministic validation, host authorization, user review, or durable save concurrency. +## Review suggestion decision (Active PR / Proposed) + +```mermaid +sequenceDiagram + participant Host + participant Handle as CwlEditorHandle + participant State as Captured EditorState + participant History as ProseMirror history + Host->>Handle: authorized suggestion + accept/reject + Handle->>State: validate proposal and canonical revision + alt live state changed or selector unsupported + Handle-->>Host: stale/invalid result; no mutation + else reject + Handle-->>Host: revision-only rejected result; no history entry + else accept + Handle->>History: dispatch one insert/delete transaction + Handle-->>Host: before/after revision transition + end +``` + +The host remains identity, authorization, persistence, durable exact-once decision, audit, and cross-revision re-anchoring authority. Local undo/redo reverses or reapplies the editor transaction only; it does not rewrite host review records. + ## Import and export flow ```mermaid diff --git a/docs/adr/0005-revision-scoped-review-evidence.md b/docs/adr/0005-revision-scoped-review-evidence.md index 8dfa121df..aa4b9702c 100644 --- a/docs/adr/0005-revision-scoped-review-evidence.md +++ b/docs/adr/0005-revision-scoped-review-evidence.md @@ -11,14 +11,17 @@ Delayed review, annotation, AI assistance, and audit-like workflows need to refe - Store selected text or full documents in ordinary review metadata. Rejected because it duplicates sensitive content and still does not guarantee stable anchoring after edits. - Capture coordinates first and reread the document later for hashing. Rejected because asynchronous work can bind coordinates and revision to different editor states. - Capture structural coordinates and canonical content from one immutable editor snapshot, then derive minimum revision-scoped evidence. Selected because it preserves temporal consistency while minimizing disclosure. +- Let each host translate review selectors and dispatch its own editor steps. Rejected because it duplicates the projection boundary, produces inconsistent undo behavior, and moves deterministic editor mutation out of Inkspan. ## Decision Capture selection coordinates and the canonical document envelope from the same immutable editor state before asynchronous revision derivation. Transition evidence validates previous and resulting envelopes before deriving both revisions. Evidence contains only the minimum revision/coordinate/change metadata required by the versioned contract and excludes document bodies, actor, tenant, time, authorization, model identity, transport result, signature, and durable-write claims. +For an authorized insert/delete proposal, `CwlEditorHandle.applyReviewSuggestionDecision()` captures one immutable `EditorState`, validates the proposal and exact canonical revision, and confirms that the live state is still the captured state before dispatch. Acceptance maps the versioned text projection back to validated text positions and dispatches one ProseMirror transaction so normal undo/redo history remains authoritative. Rejection preserves the document and creates no transaction. The adapter never silently re-anchors a stale or unsupported selector. + ## Consequences -Hosts can detect stale review coordinates and content transitions without duplicating full document content in routine evidence. Hosts remain responsible for durable annotation IDs, cross-revision re-anchoring, actor/time attribution, authorization, audit storage, and model-use policy. +Hosts can detect stale review coordinates and content transitions without duplicating full document content in routine evidence. Authorized insert/delete decisions share one deterministic transaction and history boundary. Hosts remain responsible for durable annotation IDs, exact-once decision persistence, cross-revision re-anchoring, actor/time attribution, authorization, audit storage, and model-use policy. ## Failure and recovery @@ -34,7 +37,7 @@ Evidence shape and coordinate semantics are versioned contracts. Existing eviden ## Verification -Use concurrency tests, range/caret cases, transition ordering tests, recursive document-content absence checks, frozen-output checks, packed ESM/CommonJS/strict-TypeScript consumers, and exact-head coverage/security gates. +Use concurrency tests, insert/delete/reject and undo/redo cases, Unicode projection boundaries, range/caret cases, transition ordering tests, recursive document-content absence checks, frozen-output checks, packed ESM/CommonJS/strict-TypeScript consumers, and exact-head coverage/security gates. ## Rollback or supersession diff --git a/src/components/CwlEditor.reviewSuggestion.test.tsx b/src/components/CwlEditor.reviewSuggestion.test.tsx new file mode 100644 index 000000000..f07774d8b --- /dev/null +++ b/src/components/CwlEditor.reviewSuggestion.test.tsx @@ -0,0 +1,193 @@ +import { act, cleanup, render, waitFor } from '@testing-library/react'; +import { createRef } from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { DocumentEnvelopeDigestProvider } from '../documentEnvelopeRevision.js'; +import type { CwlReviewSuggestion } from '../review/index.js'; +import type { CwlEditorHandle } from '../types.js'; +import { CwlEditor } from './CwlEditor.js'; + +afterEach(cleanup); + +async function reviewFixture(defaultValue = 'Alpha beta') { + const editorRef = createRef(); + render(); + await waitFor(() => expect(editorRef.current?.getEditor()).not.toBeNull()); + const handle = editorRef.current!; + const revision = await handle.getDocumentEnvelopeRevision(); + return { handle, revision: revision! }; +} + +function suggestion( + revision: Awaited>, + kind: 'insert' | 'delete', +): CwlReviewSuggestion { + const target = { + contractVersion: 1 as const, + revision: revision!, + selector: { + type: 'TextPositionSelector' as const, + start: 6, + end: kind === 'insert' ? 6 : 10, + }, + projection: { id: 'inkspan-prosemirror-text' as const, version: 1 as const }, + }; + return kind === 'insert' + ? { contractVersion: 1, kind, target, text: 'new ' } + : { contractVersion: 1, kind, target }; +} + +describe('CwlEditor review suggestion decisions', () => { + it.each([ + ['insert', '

    Alpha new beta

    '], + ['delete', '

    Alpha

    '], + ] as const)('accepts %s once and keeps it in undo/redo history', async (kind, html) => { + const { handle, revision } = await reviewFixture(); + + await act(async () => { + await handle.applyReviewSuggestionDecision(suggestion(revision, kind), 'accept'); + }); + expect(handle.getHTML()).toBe(html); + + act(() => handle.getEditor()!.commands.undo()); + expect(handle.getHTML()).toBe('

    Alpha beta

    '); + act(() => handle.getEditor()!.commands.redo()); + expect(handle.getHTML()).toBe(html); + + await expect( + handle.applyReviewSuggestionDecision(suggestion(revision, kind), 'accept'), + ).rejects.toMatchObject({ code: 'stale_operation' }); + }); + + it('rejects deterministically without creating document history', async () => { + const { handle, revision } = await reviewFixture(); + const proposal = suggestion(revision, 'delete'); + + const first = await handle.applyReviewSuggestionDecision(proposal, 'reject'); + const retry = await handle.applyReviewSuggestionDecision(proposal, 'reject'); + + expect(first).toEqual(retry); + expect(first).toMatchObject({ action: 'reject', status: 'rejected' }); + expect(handle.getHTML()).toBe('

    Alpha beta

    '); + let undone = true; + act(() => { + undone = handle.getEditor()!.commands.undo(); + }); + expect(undone).toBe(false); + }); + + it('fails stale when the editor changes while revision hashing is pending', async () => { + const { handle, revision } = await reviewFixture(); + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + let started!: () => void; + const hashing = new Promise((resolve) => { + started = resolve; + }); + const provider: DocumentEnvelopeDigestProvider = { + async digest(_algorithm, source) { + started(); + await blocked; + return crypto.subtle.digest('SHA-256', source); + }, + }; + + const decision = handle.applyReviewSuggestionDecision( + suggestion(revision, 'insert'), + 'accept', + undefined, + provider, + ); + await hashing; + act(() => handle.setValue('Newer document')); + release(); + + await expect(decision).rejects.toMatchObject({ code: 'stale_operation' }); + expect(handle.getHTML()).toBe('

    Newer document

    '); + }); + + it('maps Unicode code-point offsets without splitting graphemes', async () => { + const { handle, revision } = await reviewFixture('A😀B'); + const proposal = suggestion(revision, 'insert'); + const unicodeProposal = { + ...proposal, + target: { + ...proposal.target, + selector: { type: 'TextPositionSelector' as const, start: 2, end: 2 }, + }, + }; + + await act(async () => { + await handle.applyReviewSuggestionDecision(unicodeProposal, 'accept'); + }); + expect(handle.getHTML()).toBe('

    A😀new B

    '); + }); + + it('maps the start of the document into its first text block', async () => { + const { handle, revision } = await reviewFixture(); + const proposal = suggestion(revision, 'insert'); + + await act(async () => { + await handle.applyReviewSuggestionDecision( + { + ...proposal, + target: { + ...proposal.target, + selector: { type: 'TextPositionSelector', start: 0, end: 0 }, + }, + }, + 'accept', + ); + }); + expect(handle.getHTML()).toBe('

    new Alpha beta

    '); + }); + + it('maps offsets after a projected block separator', async () => { + const { handle, revision } = await reviewFixture('Alpha\n\nBeta'); + const proposal = suggestion(revision, 'insert'); + + await act(async () => { + await handle.applyReviewSuggestionDecision( + { + ...proposal, + target: { + ...proposal.target, + selector: { type: 'TextPositionSelector', start: 6, end: 6 }, + }, + }, + 'accept', + ); + }); + expect(handle.getHTML()).toBe('

    Alpha

    new Beta

    '); + }); + + it('rejects unsupported decisions and out-of-range projection offsets', async () => { + const { handle, revision } = await reviewFixture(); + await expect( + handle.applyReviewSuggestionDecision( + suggestion(revision, 'insert'), + 'cancel' as 'accept', + ), + ).rejects.toMatchObject({ code: 'invalid_operation' }); + + const proposal = suggestion(revision, 'insert'); + await expect( + handle.applyReviewSuggestionDecision( + { + ...proposal, + target: { + ...proposal.target, + selector: { + type: 'TextPositionSelector', + start: 100, + end: 100, + }, + }, + }, + 'accept', + ), + ).rejects.toMatchObject({ code: 'invalid_operation' }); + expect(handle.getHTML()).toBe('

    Alpha beta

    '); + }); +}); diff --git a/src/components/useEditorHandle.test.tsx b/src/components/useEditorHandle.test.tsx index 912ce499f..48e909928 100644 --- a/src/components/useEditorHandle.test.tsx +++ b/src/components/useEditorHandle.test.tsx @@ -50,6 +50,9 @@ describe('useEditorHandle', () => { ).resolves.toBeNull(); await expect(handle.getSelectionRevisionEvidence()).resolves.toBeNull(); await expect(handle.getTextPositionSelectorEvidence()).resolves.toBeNull(); + await expect( + handle.applyReviewSuggestionDecision({}, 'accept'), + ).resolves.toBeNull(); expect(handle.validateDocumentEnvelope({})).toBe(false); expect(handle.validateDocumentEnvelopeBytes(new Uint8Array())).toBe(false); expect(handle.restoreDocumentEnvelope({})).toBeNull(); diff --git a/src/components/useEditorHandle.ts b/src/components/useEditorHandle.ts index 710fe883f..aeadebc0c 100644 --- a/src/components/useEditorHandle.ts +++ b/src/components/useEditorHandle.ts @@ -31,6 +31,7 @@ import { } from '../documentSchema.js'; import { createTextPositionSelector } from '../textPositionSelectorEvidence.js'; import type { CwlEditorHandle, EditorMode } from '../types.js'; +import { applyReviewSuggestionDecision } from '../review/editorMutation.js'; import { createEditorDocumentSnapshot } from './editorDocumentSnapshot.js'; import { editorHtmlToValue, editorValueToHtml } from './editorSerialization.js'; @@ -128,6 +129,21 @@ export function useEditorHandle( ); return Object.freeze({ revision, selector, textProjection }); }, + applyReviewSuggestionDecision: ( + suggestion, + action, + limits, + digestProvider, + ) => + editor + ? applyReviewSuggestionDecision( + editor, + suggestion, + action, + limits, + digestProvider, + ) + : Promise.resolve(null), setValue: (next: string) => { if (!editor) return; editor.commands.setContent( diff --git a/src/review/editorMutation.ts b/src/review/editorMutation.ts new file mode 100644 index 000000000..fe4bb8e01 --- /dev/null +++ b/src/review/editorMutation.ts @@ -0,0 +1,106 @@ +import type { Editor } from '@tiptap/react'; +import { TextSelection } from '@tiptap/pm/state'; +import { + createDocumentEnvelope, + type DocumentEnvelopeLimits, +} from '../documentEnvelope.js'; +import type { DocumentEnvelopeDigestProvider } from '../documentEnvelopeRevision.js'; +import { createTextPositionSelector } from '../textPositionSelectorEvidence.js'; +import { + assertReviewSuggestionCurrentRevision, + createReviewOperationResult, + CwlReviewOperationError, + type CwlReviewOperationResult, +} from './index.js'; + +const BLOCK_SEPARATOR = '\n'; +const LEAF_TEXT = '\uFFFC'; + +function projectedLength(editor: Editor, to: number): number { + return Array.from( + editor.state.doc.textBetween(0, to, BLOCK_SEPARATOR, LEAF_TEXT), + ).length; +} + +function firstPosition( + editor: Editor, + offset: number, + strictlyGreater: boolean, +): number { + let low = 0; + let high = editor.state.doc.content.size + 1; + while (low < high) { + const middle = Math.floor((low + high) / 2); + const length = projectedLength( + editor, + Math.min(middle, editor.state.doc.content.size), + ); + if (length > offset || (!strictlyGreater && length === offset)) { + high = middle; + } else { + low = middle + 1; + } + } + return low; +} + +function resolvePosition(editor: Editor, offset: number): number { + const first = firstPosition(editor, offset, false); + const after = firstPosition(editor, offset, true); + + for (let position = first; position < after; position += 1) { + try { + if (!editor.state.doc.resolve(position).parent.inlineContent) continue; + const selection = TextSelection.create(editor.state.doc, position); + const projected = createTextPositionSelector(editor.state.doc, selection); + /* v8 ignore next -- binary search and selector use the same v1 projection. */ + if (projected.selector.start !== offset) continue; + return position; + } catch {} + } + + throw new CwlReviewOperationError('invalid_operation'); +} + +/** Apply one exact-revision review decision to an active editor. */ +export async function applyReviewSuggestionDecision( + editor: Editor, + suggestionSource: unknown, + action: 'accept' | 'reject', + limits?: DocumentEnvelopeLimits, + digestProvider?: DocumentEnvelopeDigestProvider | null, +): Promise { + if (action !== 'accept' && action !== 'reject') { + throw new CwlReviewOperationError('invalid_operation'); + } + + const state = editor.state; + const previousEnvelope = createDocumentEnvelope(state.doc.toJSON(), limits); + const suggestion = await assertReviewSuggestionCurrentRevision( + suggestionSource, + previousEnvelope, + limits, + digestProvider, + ); + if (editor.state !== state) throw new CwlReviewOperationError('stale_operation'); + + if (action === 'accept') { + const from = resolvePosition(editor, suggestion.target.selector.start); + const to = resolvePosition(editor, suggestion.target.selector.end); + const transaction = + suggestion.kind === 'insert' + ? state.tr.insertText(suggestion.text, from) + : state.tr.delete(from, to); + editor.view.dispatch(transaction); + } + + const resultingEnvelope = createDocumentEnvelope(editor.state.doc.toJSON(), limits); + return createReviewOperationResult( + suggestion, + action, + previousEnvelope, + resultingEnvelope, + limits, + digestProvider, + ); +} diff --git a/src/reviewTransactionDocumentation.test.ts b/src/reviewTransactionDocumentation.test.ts new file mode 100644 index 000000000..2e65afdf1 --- /dev/null +++ b/src/reviewTransactionDocumentation.test.ts @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const repositoryFile = (file: string): string => + readFileSync(resolve(process.cwd(), file), 'utf8'); + +describe('review suggestion transaction documentation', () => { + it('keeps architecture and evidence records connected to the active contract', () => { + expect(repositoryFile('docs/adr/0005-revision-scoped-review-evidence.md')).toContain( + 'applyReviewSuggestionDecision', + ); + expect(repositoryFile('docs/UML.md')).toContain('Review suggestion decision'); + expect(repositoryFile('docs/DATA_MODEL.md')).toContain('`review_operation`'); + expect(repositoryFile('docs/THREAT_MODEL.md')).toContain( + 'Review suggestions and transaction admission', + ); + expect(repositoryFile('docs/TRACEABILITY.md')).toContain( + 'Exact-revision review mutation', + ); + expect(repositoryFile('docs/TEST_STRATEGY.md')).toContain( + 'review suggestion decisions', + ); + }); +}); diff --git a/src/types.ts b/src/types.ts index 0292d4a22..e3992bf1c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,8 @@ import type { JSONContent } from '@tiptap/core'; import type { Editor } from '@tiptap/react'; +import type { + CwlReviewOperationResult, +} from './review/index.js'; import type { CwlEditorDocumentEnvelope, DocumentEnvelopeLimits, @@ -174,6 +177,18 @@ export interface CwlEditorHandle { limits?: DocumentEnvelopeLimits, digestProvider?: DocumentEnvelopeDigestProvider | null, ): Promise; + /** + * Accept or reject one exact-revision insert/delete suggestion. + * + * Acceptance dispatches one history-aware editor transaction. Rejection + * preserves the document. Returns `null` before editor creation. + */ + applyReviewSuggestionDecision( + suggestion: unknown, + action: 'accept' | 'reject', + limits?: DocumentEnvelopeLimits, + digestProvider?: DocumentEnvelopeDigestProvider | null, + ): Promise; /** Replace the whole document from a string in the active `mode`. */ setValue(value: string): void; /** diff --git a/tests/package/verify-package.mjs b/tests/package/verify-package.mjs index 82a03e312..0978c0c7d 100644 --- a/tests/package/verify-package.mjs +++ b/tests/package/verify-package.mjs @@ -172,6 +172,9 @@ import { import type { CwlEditorDocumentRevisionEvidence, } from '${packageName}/revision-evidence'; +import type { + CwlReviewOperationResult, +} from '${packageName}/review'; const renderMarkdown: (markdown: string) => string = markdownToHtml; const safeHref: string = validateSafeLinkHref('/documents/current'); @@ -200,6 +203,8 @@ const expectedStrongEntityTag = '"sha256-' + '0'.repeat(64) + '"'; const currentSnapshot: CwlEditorDocumentSnapshot = editorHandle.getSnapshot(); const currentRevision: Promise = editorHandle.getDocumentEnvelopeRevision(undefined, digestProvider); +const reviewDecision: Promise = + editorHandle.applyReviewSuggestionDecision({}, 'accept', undefined, digestProvider); const conditionalRestore: Promise = editorHandle.restoreDocumentEnvelopeIfMatch( expectedStrongEntityTag, @@ -272,6 +277,7 @@ void [ documentSnapshot, currentSnapshot, currentRevision, + reviewDecision, conditionalRestore, conditionalEvidence, conditionalByteRestoreResult, From 38921509c8d6788643b1c0779c3cf3bf9e29473f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 23:24:05 +0900 Subject: [PATCH 79/85] fix(ci): preserve Python boundary coverage Signed-off-by: Seongho Bae (cherry picked from commit 870c2c3efffced3ce8d280b4487e4eda055c1ff1) --- .github/workflows/ci.yml | 2 +- office/tests/test_python_support_contract.py | 14 ++++++++++---- src/workflowExactHead.test.ts | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f7614e53..03a505671 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,7 +90,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ${{ github.event_name == 'pull_request' && fromJSON('["3.14"]') || fromJSON('["3.11", "3.12", "3.13", "3.14"]') }} + python-version: ${{ github.event_name == 'pull_request' && fromJSON('["3.11", "3.14"]') || fromJSON('["3.11", "3.12", "3.13", "3.14"]') }} defaults: run: working-directory: office diff --git a/office/tests/test_python_support_contract.py b/office/tests/test_python_support_contract.py index 7104fd661..9c9d2288b 100644 --- a/office/tests/test_python_support_contract.py +++ b/office/tests/test_python_support_contract.py @@ -1,12 +1,14 @@ """Cross-file contract for the Python versions advertised by Inkspan Office.""" from pathlib import Path +import json import re import tomllib REPOSITORY_ROOT = Path(__file__).resolve().parents[2] SUPPORTED_PYTHON_VERSIONS = ("3.11", "3.12", "3.13", "3.14") +PULL_REQUEST_PYTHON_VERSIONS = ("3.11", "3.14") PYTHON_312_LXML_LINUX_SHA256 = ( "bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814" ) @@ -33,7 +35,7 @@ def _workflow_job_block(workflow: str, job_name: str) -> str: def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: - """Require package metadata and the Office CI job to cover the same minors.""" + """Require PR boundary coverage and full protected-main compatibility coverage.""" pyproject = tomllib.loads(_repository_text("office/pyproject.toml")) project = pyproject["project"] @@ -50,10 +52,14 @@ def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: office_job = _workflow_job_block(workflow, "office") assert "runs-on: ubuntu-24.04" in office_job assert "runs-on: ubuntu-latest" not in office_job - matrix_match = re.search(r'python-version:\s*\[([^\]]+)\]', office_job) + matrix_match = re.search( + r"python-version:\s*\$\{\{\s*github\.event_name == 'pull_request'\s*" + r"&&\s*fromJSON\('([^']+)'\)\s*\|\|\s*fromJSON\('([^']+)'\)\s*\}\}", + office_job, + ) assert matrix_match is not None - matrix_versions = tuple(re.findall(r'"(3\.\d+)"', matrix_match.group(1))) - assert matrix_versions == SUPPORTED_PYTHON_VERSIONS + assert tuple(json.loads(matrix_match.group(1))) == PULL_REQUEST_PYTHON_VERSIONS + assert tuple(json.loads(matrix_match.group(2))) == SUPPORTED_PYTHON_VERSIONS def test_python_support_documentation_matches_the_fixed_ci_environment() -> None: diff --git a/src/workflowExactHead.test.ts b/src/workflowExactHead.test.ts index 828c28287..404fc6230 100644 --- a/src/workflowExactHead.test.ts +++ b/src/workflowExactHead.test.ts @@ -74,7 +74,7 @@ describe('exact-head CI workflow contract', () => { ); expect(workflow).toContain('cancel-in-progress: true'); expect(officeJob).toContain( - "python-version: ${{ github.event_name == 'pull_request' && fromJSON('[\"3.14\"]') || fromJSON('[\"3.11\", \"3.12\", \"3.13\", \"3.14\"]') }}", + "python-version: ${{ github.event_name == 'pull_request' && fromJSON('[\"3.11\", \"3.14\"]') || fromJSON('[\"3.11\", \"3.12\", \"3.13\", \"3.14\"]') }}", ); expect(releaseWorkflow).toContain( 'group: ${{ github.workflow }}-${{ github.repository }}-${{ github.ref_name }}', From 277bcf377eb728e247cb1735dc1a8e603e1a6b4b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 23:31:58 +0900 Subject: [PATCH 80/85] fix(ci): restore full Python PR matrix Signed-off-by: Seongho Bae (cherry picked from commit 93fd077adc8eb5c6980dc47af7cedaef2f211537) --- .github/workflows/ci.yml | 2 +- office/tests/test_python_support_contract.py | 14 ++++---------- src/workflowExactHead.test.ts | 2 +- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03a505671..eb28caf79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,7 +90,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ${{ github.event_name == 'pull_request' && fromJSON('["3.11", "3.14"]') || fromJSON('["3.11", "3.12", "3.13", "3.14"]') }} + python-version: ["3.11", "3.12", "3.13", "3.14"] defaults: run: working-directory: office diff --git a/office/tests/test_python_support_contract.py b/office/tests/test_python_support_contract.py index 9c9d2288b..7104fd661 100644 --- a/office/tests/test_python_support_contract.py +++ b/office/tests/test_python_support_contract.py @@ -1,14 +1,12 @@ """Cross-file contract for the Python versions advertised by Inkspan Office.""" from pathlib import Path -import json import re import tomllib REPOSITORY_ROOT = Path(__file__).resolve().parents[2] SUPPORTED_PYTHON_VERSIONS = ("3.11", "3.12", "3.13", "3.14") -PULL_REQUEST_PYTHON_VERSIONS = ("3.11", "3.14") PYTHON_312_LXML_LINUX_SHA256 = ( "bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814" ) @@ -35,7 +33,7 @@ def _workflow_job_block(workflow: str, job_name: str) -> str: def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: - """Require PR boundary coverage and full protected-main compatibility coverage.""" + """Require package metadata and the Office CI job to cover the same minors.""" pyproject = tomllib.loads(_repository_text("office/pyproject.toml")) project = pyproject["project"] @@ -52,14 +50,10 @@ def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: office_job = _workflow_job_block(workflow, "office") assert "runs-on: ubuntu-24.04" in office_job assert "runs-on: ubuntu-latest" not in office_job - matrix_match = re.search( - r"python-version:\s*\$\{\{\s*github\.event_name == 'pull_request'\s*" - r"&&\s*fromJSON\('([^']+)'\)\s*\|\|\s*fromJSON\('([^']+)'\)\s*\}\}", - office_job, - ) + matrix_match = re.search(r'python-version:\s*\[([^\]]+)\]', office_job) assert matrix_match is not None - assert tuple(json.loads(matrix_match.group(1))) == PULL_REQUEST_PYTHON_VERSIONS - assert tuple(json.loads(matrix_match.group(2))) == SUPPORTED_PYTHON_VERSIONS + matrix_versions = tuple(re.findall(r'"(3\.\d+)"', matrix_match.group(1))) + assert matrix_versions == SUPPORTED_PYTHON_VERSIONS def test_python_support_documentation_matches_the_fixed_ci_environment() -> None: diff --git a/src/workflowExactHead.test.ts b/src/workflowExactHead.test.ts index 404fc6230..615f75644 100644 --- a/src/workflowExactHead.test.ts +++ b/src/workflowExactHead.test.ts @@ -74,7 +74,7 @@ describe('exact-head CI workflow contract', () => { ); expect(workflow).toContain('cancel-in-progress: true'); expect(officeJob).toContain( - "python-version: ${{ github.event_name == 'pull_request' && fromJSON('[\"3.11\", \"3.14\"]') || fromJSON('[\"3.11\", \"3.12\", \"3.13\", \"3.14\"]') }}", + 'python-version: ["3.11", "3.12", "3.13", "3.14"]', ); expect(releaseWorkflow).toContain( 'group: ${{ github.workflow }}-${{ github.repository }}-${{ github.ref_name }}', From 67ae7201e33a3f566ad9534404006e8a23568c90 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:04:59 +0900 Subject: [PATCH 81/85] fix(review): preserve failure boundaries Signed-off-by: Seongho Bae --- .../CwlEditor.reviewSuggestion.test.tsx | 19 +++++++++++ src/review-react/index.tsx | 2 +- .../intentCallbackValidation.test.tsx | 32 +++++++++++++++++++ src/review/editorMutation.ts | 19 +++++++++-- 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/components/CwlEditor.reviewSuggestion.test.tsx b/src/components/CwlEditor.reviewSuggestion.test.tsx index f07774d8b..ff5e0f27b 100644 --- a/src/components/CwlEditor.reviewSuggestion.test.tsx +++ b/src/components/CwlEditor.reviewSuggestion.test.tsx @@ -107,6 +107,25 @@ describe('CwlEditor review suggestion decisions', () => { expect(handle.getHTML()).toBe('

    Newer document

    '); }); + it('does not mutate when accepted-operation evidence cannot be created', async () => { + const { handle, revision } = await reviewFixture(); + const provider: DocumentEnvelopeDigestProvider = { + async digest() { + throw new Error('private digest failure'); + }, + }; + + await expect( + handle.applyReviewSuggestionDecision( + suggestion(revision, 'insert'), + 'accept', + undefined, + provider, + ), + ).rejects.toThrow('Document envelope SHA-256 digest could not be created'); + expect(handle.getHTML()).toBe('

    Alpha beta

    '); + }); + it('maps Unicode code-point offsets without splitting graphemes', async () => { const { handle, revision } = await reviewFixture('A😀B'); const proposal = suggestion(revision, 'insert'); diff --git a/src/review-react/index.tsx b/src/review-react/index.tsx index 9f00ba74b..53815d5ca 100644 --- a/src/review-react/index.tsx +++ b/src/review-react/index.tsx @@ -333,7 +333,7 @@ function invokeReviewIntent( presentation: CwlReviewThreadPresentation, ): void { try { - callback(presentation); + void Promise.resolve(callback(presentation)).catch(() => undefined); } catch { rejectReviewPresentation(); } diff --git a/src/review-react/intentCallbackValidation.test.tsx b/src/review-react/intentCallbackValidation.test.tsx index d2e581b16..aaa74e4ca 100644 --- a/src/review-react/intentCallbackValidation.test.tsx +++ b/src/review-react/intentCallbackValidation.test.tsx @@ -105,6 +105,26 @@ function expectRedactedIntentFailure( } } +async function expectRedactedAsyncIntentFailure( + action: () => void, +): Promise { + const unhandledReasons: unknown[] = []; + const handleUnhandledRejection = (event: PromiseRejectionEvent): void => { + unhandledReasons.push(event.reason); + event.preventDefault(); + }; + + window.addEventListener('unhandledrejection', handleUnhandledRejection); + try { + action(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + window.removeEventListener('unhandledrejection', handleUnhandledRejection); + } + + expect(unhandledReasons).toEqual([]); +} + describe('CwlReviewThreadList intent callback validation', () => { it('fails closed before rendering when the required selection callback is malformed', () => { expectInvalidIntentCallbacks({ onSelectThread: null }); @@ -153,4 +173,16 @@ describe('CwlReviewThreadList intent callback validation', () => { }, 'private resolve sentinel'); expect(callback).toHaveBeenCalledTimes(1); }); + + it('redacts a rejected async host callback at the presentation boundary', async () => { + const callback = vi.fn(async () => { + throw new Error('private async selection sentinel'); + }); + renderWithCallbacks({ onSelectThread: callback }); + + await expectRedactedAsyncIntentFailure(() => { + fireEvent.click(screen.getByRole('button', { name: 'Thread 1' })); + }); + expect(callback).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/review/editorMutation.ts b/src/review/editorMutation.ts index fe4bb8e01..83e6d0051 100644 --- a/src/review/editorMutation.ts +++ b/src/review/editorMutation.ts @@ -91,15 +91,30 @@ export async function applyReviewSuggestionDecision( suggestion.kind === 'insert' ? state.tr.insertText(suggestion.text, from) : state.tr.delete(from, to); + const resultingEnvelope = createDocumentEnvelope( + transaction.doc.toJSON(), + limits, + ); + const result = await createReviewOperationResult( + suggestion, + action, + previousEnvelope, + resultingEnvelope, + limits, + digestProvider, + ); + if (editor.state !== state) { + throw new CwlReviewOperationError('stale_operation'); + } editor.view.dispatch(transaction); + return result; } - const resultingEnvelope = createDocumentEnvelope(editor.state.doc.toJSON(), limits); return createReviewOperationResult( suggestion, action, previousEnvelope, - resultingEnvelope, + previousEnvelope, limits, digestProvider, ); From 0bcd4aef3df960078e63c1492012a7451c0585ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:41:45 +0900 Subject: [PATCH 82/85] feat(review): add accessible suggestion decisions Signed-off-by: Seongho Bae --- README.md | 2 +- docs/CONTRACTS.md | 2 +- docs/package-distribution.md | 2 +- scripts/verify-review-react-package.mjs | 25 ++++- src/review-react/index.tsx | 87 ++++++++++++++- src/review-react/suggestionDecision.test.tsx | 105 +++++++++++++++++++ src/styles.css | 6 ++ stories/ReviewWorkflow.stories.tsx | 19 ++++ tests/browser/harness.ts | 33 +++++- tests/browser/specs/review.browser.spec.ts | 21 ++++ 10 files changed, 290 insertions(+), 12 deletions(-) create mode 100644 src/review-react/suggestionDecision.test.tsx diff --git a/README.md b/README.md index 5841bda33..ea8d26f36 100644 --- a/README.md +++ b/README.md @@ -71,7 +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 | +| Review React adapter | `@contextualwisdomlab/cwl-editor/review-react` | `implemented_on_active_pr` — controlled accessible thread and suggestion-decision 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 | diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 97824c094..f2788fd61 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -53,7 +53,7 @@ The proposed `./review` subpath accepts only versioned, bounded, provider-neutra Classification is evidence, not authorization. After host authorization, `CwlEditorHandle.applyReviewSuggestionDecision()` may apply an insert/delete acceptance as one exact-revision editor transaction; rejection preserves the document and creates no history entry. The adapter captures one `EditorState`, verifies its canonical revision asynchronously, and refuses the operation if the live state changed before dispatch. Acceptance must produce a changed revision; rejection must preserve the revision; a stale target paired with any document change fails closed. The host owns reviewer/thread identity, permissions, transport, durable persistence, resolution, notifications, audit, retention, and cross-revision re-anchoring. -The proposed `./review-react` subpath renders controlled native-button target markers and thread lists from the validated presentation contract. It exposes selection, reply, and resolve intents only. Host-controlled selection remains authoritative, unavailable actions remain disabled, keyboard focus traversal does not commit selection, and host callbacks cannot turn presentation state into authorization or durable success. Review presentation defaults to `printMode="exclude"`; explicit `include` prints bounded labels and status/comment summaries while suppressing reply/resolve controls. +The proposed `./review-react` subpath renders controlled native-button target markers, thread lists, and one-suggestion decision controls from the validated review contract. It exposes selection, reply, resolve, accept, and reject intents only. Host-controlled selection remains authoritative, unavailable actions remain disabled, keyboard focus traversal does not commit selection, and host callbacks cannot turn presentation state into authorization or durable success. Review presentation defaults to `printMode="exclude"`; explicit `include` prints bounded labels and status/comment summaries while suppressing interactive controls. The print option is not a general export switch. Review metadata is excluded from non-print exports, and deterministic Markdown, HTML, email, plain-text, and Office conversion serializes canonical document content only. A host that includes review records in another artifact owns that separately governed export and its authorization, disclosure, provenance, retention, accessibility, and publication policy. diff --git a/docs/package-distribution.md b/docs/package-distribution.md index 5effa500e..544bbc4a3 100644 --- a/docs/package-distribution.md +++ b/docs/package-distribution.md @@ -68,7 +68,7 @@ embedded in the npm tarball. - 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 + and emits selection, reply, resolve, accept, and reject 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. diff --git a/scripts/verify-review-react-package.mjs b/scripts/verify-review-react-package.mjs index f76427b95..c116bcde3 100644 --- a/scripts/verify-review-react-package.mjs +++ b/scripts/verify-review-react-package.mjs @@ -149,7 +149,7 @@ function verifyRuntimeConsumers() { `import assert from 'node:assert/strict'; import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { CwlReviewThreadList } from '${packageJson.name}/review-react'; +import { CwlReviewSuggestionDecision, CwlReviewThreadList } from '${packageJson.name}/review-react'; const html = renderToStaticMarkup(React.createElement(CwlReviewThreadList, { presentations: [${fixture}], labels: ${labelsSource}, @@ -160,6 +160,15 @@ assert.match(html, /aria-pressed="true"/u); assert.match(html, /data-cwl-review-print="exclude"/u); assert.match(html, />Reply]*disabled=""[^>]*>Reply', 'u')); +const suggestionHtml = renderToStaticMarkup(React.createElement(CwlReviewSuggestionDecision, { + suggestion: { contractVersion: 1, kind: 'delete', target: ${fixture}.target }, + label: 'Delete suggested wording', + acceptLabel: 'Accept', + rejectLabel: 'Reject', + onAccept() {}, +})); +assert.match(suggestionHtml, /aria-label="Accept — Delete suggested wording"/u); +assert.match(suggestionHtml, /data-cwl-review-print="exclude"/u); `, 'utf8', ); @@ -169,7 +178,7 @@ assert.match(html, new RegExp(']*disabled=""[^>]*>Reply', 'u' `const assert = require('node:assert/strict'); const React = require('react'); const { renderToStaticMarkup } = require('react-dom/server'); -const { CwlReviewThreadList } = require('${packageJson.name}/review-react'); +const { CwlReviewSuggestionDecision, CwlReviewThreadList } = require('${packageJson.name}/review-react'); const html = renderToStaticMarkup(React.createElement(CwlReviewThreadList, { presentations: [${JSON.stringify(presentationFixture('b'))}], labels: ${labelsSource}, @@ -181,6 +190,7 @@ assert.match(html, /aria-pressed="true"/u); assert.match(html, /data-cwl-review-print="include"/u); assert.match(html, />Resolve]*disabled=""[^>]*>Resolve', 'u')); +assert.equal(typeof CwlReviewSuggestionDecision, 'function'); `, 'utf8', ); @@ -195,7 +205,9 @@ function verifyDeclarationConsumer() { sourcePath, `import { CwlReviewThreadList, + CwlReviewSuggestionDecision, type CwlReviewPrintMode, + type CwlReviewSuggestionDecisionProps, type CwlReviewThreadListLabels, type CwlReviewThreadListProps, } from '${packageJson.name}/review-react'; @@ -215,7 +227,14 @@ const props: CwlReviewThreadListProps = { }, }; const component: typeof CwlReviewThreadList = CwlReviewThreadList; -void [props, component, printMode]; +const suggestionProps: CwlReviewSuggestionDecisionProps = { + suggestion: {}, + label: 'Suggestion', + acceptLabel: 'Accept', + rejectLabel: 'Reject', +}; +const suggestionComponent: typeof CwlReviewSuggestionDecision = CwlReviewSuggestionDecision; +void [props, component, suggestionProps, suggestionComponent, printMode]; `, 'utf8', ); diff --git a/src/review-react/index.tsx b/src/review-react/index.tsx index 53815d5ca..03adab3f8 100644 --- a/src/review-react/index.tsx +++ b/src/review-react/index.tsx @@ -1,7 +1,9 @@ import { useId, useRef, useState } from 'react'; import { + createReviewSuggestion, createReviewThreadPresentation, CwlReviewPresentationError, + type CwlReviewSuggestion, type CwlReviewThreadPresentation, } from '../review/index.js'; @@ -61,6 +63,24 @@ export interface CwlReviewTargetMarkerProps { readonly onSelectThread: (thread: CwlReviewThreadPresentation) => void; } +/** Controlled accessible accept/reject intents for one validated suggestion. */ +export interface CwlReviewSuggestionDecisionProps { + /** Untrusted insert/delete suggestion validated before rendering. */ + readonly suggestion: unknown; + /** Visible and accessible host-owned summary of the suggestion. */ + readonly label: string; + /** Visible host-owned label for accepting the suggestion. */ + readonly acceptLabel: string; + /** Visible host-owned label for rejecting the suggestion. */ + readonly rejectLabel: string; + /** Defaults to `exclude` so interactive decisions never print accidentally. */ + readonly printMode?: CwlReviewPrintMode; + /** Optional accept intent; absence keeps the action disabled. */ + readonly onAccept?: (suggestion: CwlReviewSuggestion) => void; + /** Optional reject intent; absence keeps the action disabled. */ + readonly onReject?: (suggestion: CwlReviewSuggestion) => void; +} + 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; @@ -328,17 +348,76 @@ function resolveReviewThreadKey( return initialReviewThreadKey(presentations); } -function invokeReviewIntent( - callback: ReviewIntentCallback, - presentation: CwlReviewThreadPresentation, +function invokeReviewIntent( + callback: (value: Value) => unknown, + value: Value, ): void { try { - void Promise.resolve(callback(presentation)).catch(() => undefined); + void Promise.resolve(callback(value)).catch(() => undefined); } catch { rejectReviewPresentation(); } } +/** Render host-controlled decision intents for one bounded insert/delete suggestion. */ +export function CwlReviewSuggestionDecision({ + suggestion, + label, + acceptLabel, + rejectLabel, + printMode, + onAccept, + onReject, +}: CwlReviewSuggestionDecisionProps) { + const validatedSuggestion = createReviewSuggestion(suggestion); + const validatedLabel = requireVisibleLabel(label); + const validatedAcceptLabel = requireVisibleLabel(acceptLabel); + const validatedRejectLabel = requireVisibleLabel(rejectLabel); + const validatedPrintMode = validateReviewPrintMode(printMode); + if (onAccept !== undefined && typeof onAccept !== 'function') { + rejectReviewPresentation(); + } + if (onReject !== undefined && typeof onReject !== 'function') { + rejectReviewPresentation(); + } + + return ( +
    + {validatedLabel} + + +
    + ); +} + /** * Render one controlled accessible inline marker for a validated review target. * diff --git a/src/review-react/suggestionDecision.test.tsx b/src/review-react/suggestionDecision.test.tsx new file mode 100644 index 000000000..a22e598e3 --- /dev/null +++ b/src/review-react/suggestionDecision.test.tsx @@ -0,0 +1,105 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CwlReviewSuggestionDecision } from './index.js'; + +afterEach(cleanup); + +function suggestion() { + const digestHex = 'a'.repeat(64); + return { + contractVersion: 1, + kind: 'insert', + target: { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { type: 'TextPositionSelector', start: 3, end: 3 }, + projection: { id: 'inkspan-prosemirror-text', version: 1 }, + }, + text: 'Suggested text', + }; +} + +describe('CwlReviewSuggestionDecision', () => { + it('emits detached accept and reject intents with disambiguated names', () => { + const onAccept = vi.fn(); + const onReject = vi.fn(); + const source = suggestion(); + render( + , + ); + + fireEvent.click( + screen.getByRole('button', { name: 'Accept — Insert suggested wording' }), + ); + fireEvent.click( + screen.getByRole('button', { name: 'Reject — Insert suggested wording' }), + ); + expect(onAccept).toHaveBeenCalledTimes(1); + expect(onReject).toHaveBeenCalledTimes(1); + expect(onAccept.mock.calls[0]?.[0]).not.toBe(source); + expect(Object.isFrozen(onAccept.mock.calls[0]?.[0])).toBe(true); + }); + + it('defaults to print exclusion and disables unavailable decisions', () => { + render( + , + ); + + expect(screen.getByRole('region')).toHaveAttribute( + 'data-cwl-review-print', + 'exclude', + ); + expect(screen.getByRole('button', { name: /Accept/u })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Reject/u })).toBeDisabled(); + }); + + it('fails closed on malformed suggestion decision inputs', () => { + expect(() => + render( + , + ), + ).toThrow(); + expect(() => + render( + , + ), + ).toThrow('Review presentation metadata is invalid.'); + expect(() => + render( + , + ), + ).toThrow('Review presentation metadata is invalid.'); + }); +}); diff --git a/src/styles.css b/src/styles.css index d5292e671..a6e891f20 100644 --- a/src/styles.css +++ b/src/styles.css @@ -273,6 +273,12 @@ padding-inline-start: 8px; } +.cwl-review__suggestion { + display: flex; + align-items: center; + gap: var(--cwl-space-2); +} + .cwl-review__threads ul { margin: 0; padding: 0; diff --git a/stories/ReviewWorkflow.stories.tsx b/stories/ReviewWorkflow.stories.tsx index 65be5c6f6..852b80675 100644 --- a/stories/ReviewWorkflow.stories.tsx +++ b/stories/ReviewWorkflow.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { + CwlReviewSuggestionDecision, CwlReviewThreadList, type CwlReviewThreadListLabels, } from '../src/review-react/index.js'; @@ -120,3 +121,21 @@ export const PrintIncluded: Story = { printMode: 'include', }, }; + +export const SuggestionDecision: StoryObj = { + render: () => ( + + ), +}; diff --git a/tests/browser/harness.ts b/tests/browser/harness.ts index f5fd86b6b..44c2fa50c 100644 --- a/tests/browser/harness.ts +++ b/tests/browser/harness.ts @@ -8,7 +8,10 @@ import { type ClipboardConfig, type ClipboardSanitizationErrorCode, } from 'inkspan-browser-under-test'; -import { CwlReviewThreadList } from 'inkspan-review-react-under-test'; +import { + CwlReviewSuggestionDecision, + CwlReviewThreadList, +} from 'inkspan-review-react-under-test'; interface BrowserClipboardProbeRequest { readonly sourceHtml: string; @@ -27,7 +30,7 @@ interface BrowserHostileDocumentProbeResult { } interface BrowserReviewIntent { - readonly action: 'select' | 'reply' | 'resolve'; + readonly action: 'select' | 'reply' | 'resolve' | 'accept' | 'reject'; readonly threadKey: string; } @@ -40,6 +43,7 @@ declare global { sourceHtml: string, ): BrowserHostileDocumentProbeResult; mountInkspanReviewProbe(printMode?: 'exclude' | 'include'): void; + mountInkspanSuggestionProbe(): void; readInkspanReviewIntents(): readonly BrowserReviewIntent[]; } } @@ -108,6 +112,31 @@ window.mountInkspanReviewProbe = (printMode = 'exclude'): void => { ); }; +window.mountInkspanSuggestionProbe = (): void => { + const container = document.querySelector('#harness'); + if (!container) throw new Error('Review harness container is missing.'); + reviewIntents.length = 0; + reviewRoot?.unmount(); + reviewRoot = createRoot(container); + reviewRoot.render( + createElement(CwlReviewSuggestionDecision, { + suggestion: { + contractVersion: 1, + kind: 'delete', + target: reviewPresentation('suggestion', false, 'unresolved', false, false) + .target, + }, + label: 'Delete suggested wording', + acceptLabel: 'Accept', + rejectLabel: 'Reject', + onAccept: () => + reviewIntents.push({ action: 'accept', threadKey: 'suggestion' }), + onReject: () => + reviewIntents.push({ action: 'reject', threadKey: 'suggestion' }), + }), + ); +}; + window.readInkspanReviewIntents = () => structuredClone(reviewIntents); window.runInkspanClipboardProbe = ( diff --git a/tests/browser/specs/review.browser.spec.ts b/tests/browser/specs/review.browser.spec.ts index 18c8ec8d0..e5d1b4d9f 100644 --- a/tests/browser/specs/review.browser.spec.ts +++ b/tests/browser/specs/review.browser.spec.ts @@ -105,3 +105,24 @@ test('preserves selected and keyboard focus cues in forced colors', async ({ await expect(selected).toHaveCSS('outline-style', 'solid'); await expect(selected).toHaveCSS('outline-width', '2px'); }); + +test('exposes keyboard-operable suggestion decision intents', async ({ page }) => { + await page.evaluate(() => window.mountInkspanSuggestionProbe()); + const region = page.getByRole('region', { name: 'Delete suggested wording' }); + const accept = region.getByRole('button', { + name: 'Accept — Delete suggested wording', + }); + const reject = region.getByRole('button', { + name: 'Reject — Delete suggested wording', + }); + + await accept.focus(); + await expect(accept).toBeFocused(); + await page.keyboard.press('Enter'); + await reject.focus(); + await page.keyboard.press('Space'); + expect(await page.evaluate(() => window.readInkspanReviewIntents())).toEqual([ + { action: 'accept', threadKey: 'suggestion' }, + { action: 'reject', threadKey: 'suggestion' }, + ]); +}); From d153e92e92215d989ddee2a9b778519520926e63 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:45:36 +0900 Subject: [PATCH 83/85] test(review): verify suggestion print behavior Signed-off-by: Seongho Bae --- src/review-react/suggestionDecision.test.tsx | 27 +++++++++++++++++++- tests/browser/harness.ts | 5 ++-- tests/browser/specs/review.browser.spec.ts | 9 +++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/review-react/suggestionDecision.test.tsx b/src/review-react/suggestionDecision.test.tsx index a22e598e3..b83df46b8 100644 --- a/src/review-react/suggestionDecision.test.tsx +++ b/src/review-react/suggestionDecision.test.tsx @@ -52,7 +52,7 @@ describe('CwlReviewSuggestionDecision', () => { }); it('defaults to print exclusion and disables unavailable decisions', () => { - render( + const { rerender } = render( { ); expect(screen.getByRole('button', { name: /Accept/u })).toBeDisabled(); expect(screen.getByRole('button', { name: /Reject/u })).toBeDisabled(); + + rerender( + , + ); + expect(screen.getByRole('region')).toHaveAttribute( + 'data-cwl-review-print', + 'include', + ); }); it('fails closed on malformed suggestion decision inputs', () => { @@ -101,5 +115,16 @@ describe('CwlReviewSuggestionDecision', () => { />, ), ).toThrow('Review presentation metadata is invalid.'); + expect(() => + render( + , + ), + ).toThrow('Review presentation metadata is invalid.'); }); }); diff --git a/tests/browser/harness.ts b/tests/browser/harness.ts index 44c2fa50c..e414b017f 100644 --- a/tests/browser/harness.ts +++ b/tests/browser/harness.ts @@ -43,7 +43,7 @@ declare global { sourceHtml: string, ): BrowserHostileDocumentProbeResult; mountInkspanReviewProbe(printMode?: 'exclude' | 'include'): void; - mountInkspanSuggestionProbe(): void; + mountInkspanSuggestionProbe(printMode?: 'exclude' | 'include'): void; readInkspanReviewIntents(): readonly BrowserReviewIntent[]; } } @@ -112,7 +112,7 @@ window.mountInkspanReviewProbe = (printMode = 'exclude'): void => { ); }; -window.mountInkspanSuggestionProbe = (): void => { +window.mountInkspanSuggestionProbe = (printMode = 'exclude'): void => { const container = document.querySelector('#harness'); if (!container) throw new Error('Review harness container is missing.'); reviewIntents.length = 0; @@ -129,6 +129,7 @@ window.mountInkspanSuggestionProbe = (): void => { label: 'Delete suggested wording', acceptLabel: 'Accept', rejectLabel: 'Reject', + printMode, onAccept: () => reviewIntents.push({ action: 'accept', threadKey: 'suggestion' }), onReject: () => diff --git a/tests/browser/specs/review.browser.spec.ts b/tests/browser/specs/review.browser.spec.ts index e5d1b4d9f..78888d400 100644 --- a/tests/browser/specs/review.browser.spec.ts +++ b/tests/browser/specs/review.browser.spec.ts @@ -67,6 +67,15 @@ test('prints review summaries only after explicit opt-in', async ({ page }) => { await expect( region.getByRole('button', { name: 'Resolve — Thread beta' }), ).toBeHidden(); + + await page.evaluate(() => window.mountInkspanSuggestionProbe('include')); + const suggestion = page.getByRole('region', { + name: 'Delete suggested wording', + }); + await expect(suggestion).toBeVisible(); + await expect(suggestion.getByText('Delete suggested wording')).toBeVisible(); + await expect(suggestion.getByRole('button', { name: /Accept/u })).toBeHidden(); + await expect(suggestion.getByRole('button', { name: /Reject/u })).toBeHidden(); }); test('reflows review actions without hiding content on narrow screens', async ({ From 306055b7e5e7d5d91f2933cbb6c7f10649c3044f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:09:41 +0900 Subject: [PATCH 84/85] fix(review): group suggestion decisions semantically Signed-off-by: Seongho Bae --- src/review-react/index.tsx | 7 +++---- src/review-react/suggestionDecision.test.tsx | 4 ++-- src/styles.css | 4 ++++ tests/browser/specs/review.browser.spec.ts | 8 ++++---- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/review-react/index.tsx b/src/review-react/index.tsx index 03adab3f8..ca53b3a62 100644 --- a/src/review-react/index.tsx +++ b/src/review-react/index.tsx @@ -382,12 +382,11 @@ export function CwlReviewSuggestionDecision({ } return ( -
    - {validatedLabel} + {validatedLabel} -
    + ); } diff --git a/src/review-react/suggestionDecision.test.tsx b/src/review-react/suggestionDecision.test.tsx index b83df46b8..ffacb1ac0 100644 --- a/src/review-react/suggestionDecision.test.tsx +++ b/src/review-react/suggestionDecision.test.tsx @@ -61,7 +61,7 @@ describe('CwlReviewSuggestionDecision', () => { />, ); - expect(screen.getByRole('region')).toHaveAttribute( + expect(screen.getByRole('group')).toHaveAttribute( 'data-cwl-review-print', 'exclude', ); @@ -77,7 +77,7 @@ describe('CwlReviewSuggestionDecision', () => { printMode="include" />, ); - expect(screen.getByRole('region')).toHaveAttribute( + expect(screen.getByRole('group')).toHaveAttribute( 'data-cwl-review-print', 'include', ); diff --git a/src/styles.css b/src/styles.css index a6e891f20..c999f1a4c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -277,6 +277,10 @@ display: flex; align-items: center; gap: var(--cwl-space-2); + min-inline-size: 0; + margin: 0; + border: 0; + padding: 0; } .cwl-review__threads ul { diff --git a/tests/browser/specs/review.browser.spec.ts b/tests/browser/specs/review.browser.spec.ts index 78888d400..9bbdbcb62 100644 --- a/tests/browser/specs/review.browser.spec.ts +++ b/tests/browser/specs/review.browser.spec.ts @@ -69,7 +69,7 @@ test('prints review summaries only after explicit opt-in', async ({ page }) => { ).toBeHidden(); await page.evaluate(() => window.mountInkspanSuggestionProbe('include')); - const suggestion = page.getByRole('region', { + const suggestion = page.getByRole('group', { name: 'Delete suggested wording', }); await expect(suggestion).toBeVisible(); @@ -117,11 +117,11 @@ test('preserves selected and keyboard focus cues in forced colors', async ({ test('exposes keyboard-operable suggestion decision intents', async ({ page }) => { await page.evaluate(() => window.mountInkspanSuggestionProbe()); - const region = page.getByRole('region', { name: 'Delete suggested wording' }); - const accept = region.getByRole('button', { + const group = page.getByRole('group', { name: 'Delete suggested wording' }); + const accept = group.getByRole('button', { name: 'Accept — Delete suggested wording', }); - const reject = region.getByRole('button', { + const reject = group.getByRole('button', { name: 'Reject — Delete suggested wording', }); From 6bb054d0d437b3553dc73fee17373cb898738127 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:37:10 +0900 Subject: [PATCH 85/85] test(review): cover late editor mutation --- .../CwlEditor.reviewSuggestion.test.tsx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/components/CwlEditor.reviewSuggestion.test.tsx b/src/components/CwlEditor.reviewSuggestion.test.tsx index ff5e0f27b..d2e69f121 100644 --- a/src/components/CwlEditor.reviewSuggestion.test.tsx +++ b/src/components/CwlEditor.reviewSuggestion.test.tsx @@ -126,6 +126,31 @@ describe('CwlEditor review suggestion decisions', () => { expect(handle.getHTML()).toBe('

    Alpha beta

    '); }); + it('fails stale when the editor changes while accepted evidence is created', async () => { + const { handle, revision } = await reviewFixture(); + let digestCount = 0; + const provider: DocumentEnvelopeDigestProvider = { + async digest(_algorithm, source) { + digestCount += 1; + const digest = await crypto.subtle.digest('SHA-256', source); + if (digestCount === 3) { + act(() => handle.setValue('Newer document')); + } + return digest; + }, + }; + + await expect( + handle.applyReviewSuggestionDecision( + suggestion(revision, 'insert'), + 'accept', + undefined, + provider, + ), + ).rejects.toMatchObject({ code: 'stale_operation' }); + expect(handle.getHTML()).toBe('

    Newer document

    '); + }); + it('maps Unicode code-point offsets without splitting graphemes', async () => { const { handle, revision } = await reviewFixture('A😀B'); const proposal = suggestion(revision, 'insert');