From 9f3c6dc219048363ad1699b693f415d32495ffe4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:29:54 +0900 Subject: [PATCH 01/21] test(diagnostics): define strict host diagnostic contract --- src/writingDiagnostics.test.ts | 337 +++++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 src/writingDiagnostics.test.ts diff --git a/src/writingDiagnostics.test.ts b/src/writingDiagnostics.test.ts new file mode 100644 index 00000000..edc9d95a --- /dev/null +++ b/src/writingDiagnostics.test.ts @@ -0,0 +1,337 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_WRITING_DIAGNOSTIC_LIMITS, + WritingDiagnosticError, + validateWritingDiagnostics, + type CwlWritingDiagnostic, +} from './writingDiagnostics.js'; + +const DIGEST = 'a'.repeat(64); + +function validDiagnostic( + overrides: Partial = {}, +): CwlWritingDiagnostic { + return { + diagnosticId: 'diag-1', + documentRevision: { + algorithm: 'SHA-256', + digestHex: DIGEST, + strongEntityTag: `"sha256-${DIGEST}"`, + }, + textProjection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + selector: { + type: 'TextPositionSelector', + start: 1, + end: 3, + }, + categoryCode: 'grammar.subject_verb', + priority: 'important', + title: 'Check agreement', + explanation: 'The host judged this range as worth reviewing.', + suggestedReplacement: 'were', + confidence: 0.75, + provenance: { + workflowId: 'naruon-writing-review', + workflowVersion: '2026-08-12', + judgePolicyVersion: 'criterion-set-v3', + orchestrationMode: 'quality', + }, + ...overrides, + }; +} + +function expectCode(input: unknown, code: WritingDiagnosticError['code']): void { + try { + validateWritingDiagnostics(input); + } catch (error) { + expect(error).toBeInstanceOf(WritingDiagnosticError); + expect((error as WritingDiagnosticError).code).toBe(code); + return; + } + throw new Error(`Expected WritingDiagnosticError(${code})`); +} + +describe('validateWritingDiagnostics', () => { + it('returns a deeply detached and frozen host-ordered tuple', () => { + const first = validDiagnostic(); + const second = validDiagnostic({ + diagnosticId: 'diag-2', + selector: { type: 'TextPositionSelector', start: 8, end: 8 }, + suggestedReplacement: undefined, + confidence: undefined, + }); + const input = [first, second]; + + const result = validateWritingDiagnostics(input); + + expect(result).toHaveLength(2); + expect(result.map((entry) => entry.diagnosticId)).toEqual(['diag-1', 'diag-2']); + expect(result).not.toBe(input); + expect(result[0]).not.toBe(first); + expect(result[0].documentRevision).not.toBe(first.documentRevision); + expect(result[0].selector).not.toBe(first.selector); + expect(result[0].provenance).not.toBe(first.provenance); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result[0])).toBe(true); + expect(Object.isFrozen(result[0].documentRevision)).toBe(true); + expect(Object.isFrozen(result[0].textProjection)).toBe(true); + expect(Object.isFrozen(result[0].selector)).toBe(true); + expect(Object.isFrozen(result[0].provenance)).toBe(true); + }); + + it('accepts an empty diagnostic array', () => { + expect(validateWritingDiagnostics([])).toEqual([]); + }); + + it('rejects duplicate diagnostic identifiers', () => { + expectCode([validDiagnostic(), validDiagnostic()], 'conflict'); + }); + + it('rejects non-array input and array own-property surprises', () => { + expectCode({}, 'contract'); + const input = [validDiagnostic()]; + Object.defineProperty(input, 'extra', { value: true, enumerable: true }); + expectCode(input, 'contract'); + }); + + it('rejects too many diagnostics before inspecting members', () => { + const member = new Proxy(validDiagnostic(), { + ownKeys() { + throw new Error('member should not be inspected'); + }, + }); + const input = Array.from( + { length: DEFAULT_WRITING_DIAGNOSTIC_LIMITS.maxDiagnostics + 1 }, + () => member, + ); + expectCode(input, 'limit'); + }); + + it('rejects unexpected, inherited, accessor-backed, non-enumerable, and symbol fields', () => { + expectCode([{ ...validDiagnostic(), unexpected: true }], 'contract'); + + const inherited = Object.create({ inherited: 'value' }) as CwlWritingDiagnostic; + Object.assign(inherited, validDiagnostic()); + expectCode([inherited], 'contract'); + + const accessor = { ...validDiagnostic() } as Record; + const getter = vi.fn(() => 'secret'); + Object.defineProperty(accessor, 'title', { enumerable: true, get: getter }); + expectCode([accessor], 'contract'); + expect(getter).not.toHaveBeenCalled(); + + const hidden = { ...validDiagnostic() } as Record; + Object.defineProperty(hidden, 'hidden', { value: true, enumerable: false }); + expectCode([hidden], 'contract'); + + const symbol = { ...validDiagnostic(), [Symbol('private')]: true }; + expectCode([symbol], 'contract'); + }); + + it('redacts hostile reflection failures', () => { + const input = [ + new Proxy(validDiagnostic(), { + ownKeys() { + throw new Error('private diagnostic payload'); + }, + }), + ]; + + try { + validateWritingDiagnostics(input); + } catch (error) { + expect(error).toBeInstanceOf(WritingDiagnosticError); + expect((error as Error).message).not.toContain('private diagnostic payload'); + return; + } + throw new Error('Expected hostile reflection to fail closed'); + }); + + it('bounds identifiers, metadata, prose, and replacement text', () => { + expectCode( + [validDiagnostic({ diagnosticId: 'x'.repeat(257) })], + 'limit', + ); + expectCode( + [validDiagnostic({ categoryCode: 'x'.repeat(129) })], + 'limit', + ); + expectCode([validDiagnostic({ title: 'x'.repeat(257) })], 'limit'); + expectCode( + [validDiagnostic({ explanation: 'x'.repeat(4001) })], + 'limit', + ); + expectCode( + [validDiagnostic({ suggestedReplacement: 'x'.repeat(20_001) })], + 'limit', + ); + expectCode( + [ + validDiagnostic({ + provenance: { + workflowId: 'x'.repeat(129), + workflowVersion: '1', + judgePolicyVersion: '1', + }, + }), + ], + 'limit', + ); + }); + + it('rejects empty required identifiers and malformed string runtime values', () => { + expectCode([validDiagnostic({ diagnosticId: '' })], 'contract'); + expectCode([validDiagnostic({ categoryCode: '' })], 'contract'); + expectCode([validDiagnostic({ title: '' })], 'contract'); + expectCode( + [validDiagnostic({ title: 7 as unknown as string })], + 'contract', + ); + expectCode( + [ + validDiagnostic({ + suggestedReplacement: 7 as unknown as string, + }), + ], + 'contract', + ); + }); + + it('accepts only the finite priority contract', () => { + for (const priority of ['advisory', 'important', 'critical'] as const) { + expect(validateWritingDiagnostics([validDiagnostic({ priority })])[0].priority).toBe( + priority, + ); + } + expectCode( + [validDiagnostic({ priority: 'urgent' as CwlWritingDiagnostic['priority'] })], + 'contract', + ); + }); + + it('accepts confidence only in the closed interval from zero through one', () => { + expect(validateWritingDiagnostics([validDiagnostic({ confidence: 0 })])[0].confidence).toBe(0); + expect(validateWritingDiagnostics([validDiagnostic({ confidence: 1 })])[0].confidence).toBe(1); + for (const confidence of [-0.01, 1.01, Number.NaN, Number.POSITIVE_INFINITY]) { + expectCode([validDiagnostic({ confidence })], 'contract'); + } + }); + + it('requires an exact SHA-256 revision shape and matching strong entity tag', () => { + expectCode( + [ + validDiagnostic({ + documentRevision: { + algorithm: 'SHA-256', + digestHex: 'A'.repeat(64), + strongEntityTag: `"sha256-${'A'.repeat(64)}"`, + }, + }), + ], + 'revision', + ); + expectCode( + [ + validDiagnostic({ + documentRevision: { + algorithm: 'SHA-256', + digestHex: DIGEST, + strongEntityTag: '"sha256-wrong"', + }, + }), + ], + 'revision', + ); + }); + + it('requires the exact supported text projection', () => { + expectCode( + [ + validDiagnostic({ + textProjection: { + id: 'other' as 'inkspan-prosemirror-text', + version: 1, + }, + }), + ], + 'projection', + ); + expectCode( + [ + validDiagnostic({ + textProjection: { + id: 'inkspan-prosemirror-text', + version: 2 as 1, + }, + }), + ], + 'projection', + ); + }); + + it('requires finite non-negative integral selectors in source order', () => { + for (const selector of [ + { type: 'TextPositionSelector' as const, start: -1, end: 1 }, + { type: 'TextPositionSelector' as const, start: 0.5, end: 1 }, + { type: 'TextPositionSelector' as const, start: 3, end: 2 }, + { type: 'TextPositionSelector' as const, start: 0, end: Number.POSITIVE_INFINITY }, + ]) { + expectCode([validDiagnostic({ selector })], 'selector'); + } + expect( + validateWritingDiagnostics([ + validDiagnostic({ + selector: { type: 'TextPositionSelector', start: 3, end: 3 }, + }), + ])[0].selector, + ).toEqual({ type: 'TextPositionSelector', start: 3, end: 3 }); + }); + + it('strictly validates provenance without evaluating accessors', () => { + const getter = vi.fn(() => 'secret'); + const provenance = { + workflowId: 'wf', + workflowVersion: '1', + judgePolicyVersion: '2', + } as Record; + Object.defineProperty(provenance, 'orchestrationMode', { + enumerable: true, + get: getter, + }); + expectCode( + [validDiagnostic({ provenance: provenance as CwlWritingDiagnostic['provenance'] })], + 'contract', + ); + expect(getter).not.toHaveBeenCalled(); + }); + + it('supports stricter caller limits without mutating defaults', () => { + const result = validateWritingDiagnostics([validDiagnostic()], { + ...DEFAULT_WRITING_DIAGNOSTIC_LIMITS, + maxTitleCodeUnits: 32, + }); + expect(result[0].title).toBe('Check agreement'); + expectCode( + [validDiagnostic({ title: 'x'.repeat(33) })], + 'limit', + ); + expect(DEFAULT_WRITING_DIAGNOSTIC_LIMITS.maxTitleCodeUnits).toBe(256); + }); + + it('rejects malformed limit configuration without reading diagnostics', () => { + const diagnostics = new Proxy([validDiagnostic()], { + get() { + throw new Error('diagnostics should not be read'); + }, + }); + expect(() => + validateWritingDiagnostics(diagnostics, { + ...DEFAULT_WRITING_DIAGNOSTIC_LIMITS, + maxDiagnostics: 0, + }), + ).toThrow(WritingDiagnosticError); + }); +}); From 52c1ae4e335410e2172da304477872d465271a65 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:31:11 +0900 Subject: [PATCH 02/21] ci(diagnostics): verify contract TDD red-green cycle --- .../writing-diagnostics-contract-tdd.yml | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/writing-diagnostics-contract-tdd.yml diff --git a/.github/workflows/writing-diagnostics-contract-tdd.yml b/.github/workflows/writing-diagnostics-contract-tdd.yml new file mode 100644 index 00000000..179d7dd3 --- /dev/null +++ b/.github/workflows/writing-diagnostics-contract-tdd.yml @@ -0,0 +1,35 @@ +name: Writing Diagnostics Contract TDD + +on: + push: + branches: + - feat/writing-diagnostics-contract + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: writing-diagnostics-contract-tdd-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + focused-contract: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Run focused contract regression + run: pnpm exec vitest run src/writingDiagnostics.test.ts From 67c5ac8010fe447c7e44c79661f1418cf1c6f34d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:31:47 +0900 Subject: [PATCH 03/21] feat(diagnostics): add strict writing diagnostic contract --- src/writingDiagnostics.ts | 623 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 623 insertions(+) create mode 100644 src/writingDiagnostics.ts diff --git a/src/writingDiagnostics.ts b/src/writingDiagnostics.ts new file mode 100644 index 00000000..66eb7873 --- /dev/null +++ b/src/writingDiagnostics.ts @@ -0,0 +1,623 @@ +import type { CwlEditorDocumentRevision } from './documentEnvelopeRevision.js'; +import { + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, + type CwlEditorTextPositionSelector, + type CwlEditorTextProjectionIdentity, +} from './textPositionSelectorEvidence.js'; + +/** Host-controlled presentation priority for one advisory writing diagnostic. */ +export type CwlWritingDiagnosticPriority = + | 'advisory' + | 'important' + | 'critical'; + +/** Bounded provenance identifiers supplied by the host that produced a diagnostic. */ +export interface CwlWritingDiagnosticProvenance { + /** Opaque host workflow identity. */ + readonly workflowId: string; + /** Opaque host workflow version. */ + readonly workflowVersion: string; + /** Opaque version of the host's judge/evaluation policy. */ + readonly judgePolicyVersion: string; + /** Optional opaque host orchestration-mode identity. */ + readonly orchestrationMode?: string; +} + +/** + * Provider-neutral advisory writing diagnostic supplied by a host application. + * + * Inkspan treats every semantic field as untrusted proposal data. The editor + * validates only structure, bounds, revision/projection identity, selectors, + * and lifecycle state; it does not infer grammar, spelling, tone, clarity, + * pragmatics, technical quality, or actionability. + */ +export interface CwlWritingDiagnostic { + /** Opaque host identifier unique within one submitted diagnostic set. */ + readonly diagnosticId: string; + /** Exact strong revision for the immutable document snapshot judged by the host. */ + readonly documentRevision: CwlEditorDocumentRevision; + /** Exact Inkspan text-projection identity used by `selector`. */ + readonly textProjection: CwlEditorTextProjectionIdentity; + /** W3C text-position selector over the declared projection. */ + readonly selector: CwlEditorTextPositionSelector; + /** Opaque host category code; Inkspan does not derive semantics from it. */ + readonly categoryCode: string; + /** Host-selected presentation priority. */ + readonly priority: CwlWritingDiagnosticPriority; + /** Human-readable host-supplied title rendered as plain text. */ + readonly title: string; + /** Human-readable host-supplied explanation rendered as plain text. */ + readonly explanation: string; + /** Optional plain-text replacement proposal. */ + readonly suggestedReplacement?: string; + /** Optional host confidence on the closed interval `[0, 1]`. */ + readonly confidence?: number; + /** Opaque, privacy-minimized host provenance identifiers. */ + readonly provenance: Readonly; +} + +/** Optional stricter local resource ceilings for diagnostic validation. */ +export interface WritingDiagnosticLimits { + /** Maximum number of diagnostics accepted for one immutable editor snapshot. */ + readonly maxDiagnostics?: number; + /** Maximum UTF-16 code-unit length of `diagnosticId`. */ + readonly maxDiagnosticIdCodeUnits?: number; + /** Maximum UTF-16 code-unit length of `categoryCode`. */ + readonly maxCategoryCodeUnits?: number; + /** Maximum UTF-16 code-unit length of each provenance identifier. */ + readonly maxProvenanceCodeUnits?: number; + /** Maximum UTF-16 code-unit length of `title`. */ + readonly maxTitleCodeUnits?: number; + /** Maximum UTF-16 code-unit length of `explanation`. */ + readonly maxExplanationCodeUnits?: number; + /** Maximum UTF-16 code-unit length of a plain-text replacement. */ + readonly maxReplacementCodeUnits?: number; +} + +interface ResolvedWritingDiagnosticLimits { + readonly maxDiagnostics: number; + readonly maxDiagnosticIdCodeUnits: number; + readonly maxCategoryCodeUnits: number; + readonly maxProvenanceCodeUnits: number; + readonly maxTitleCodeUnits: number; + readonly maxExplanationCodeUnits: number; + readonly maxReplacementCodeUnits: number; +} + +/** Hard package ceilings for one host-supplied writing-diagnostic set. */ +export const DEFAULT_WRITING_DIAGNOSTIC_LIMITS = Object.freeze({ + maxDiagnostics: 256, + maxDiagnosticIdCodeUnits: 256, + maxCategoryCodeUnits: 128, + maxProvenanceCodeUnits: 128, + maxTitleCodeUnits: 256, + maxExplanationCodeUnits: 4_000, + maxReplacementCodeUnits: 20_000, +}) satisfies Readonly; + +/** Stable public failure classifications for writing-diagnostic handling. */ +export type WritingDiagnosticErrorCode = + | 'contract' + | 'limit' + | 'revision' + | 'projection' + | 'selector' + | 'conflict' + | 'lifecycle'; + +const ERROR_MESSAGES: Readonly> = + Object.freeze({ + contract: 'Writing diagnostic input is invalid.', + limit: 'Writing diagnostic input exceeds the supported limit.', + revision: 'Writing diagnostic revision evidence is invalid.', + projection: 'Writing diagnostic text projection is unsupported.', + selector: 'Writing diagnostic text selector is invalid.', + conflict: 'Writing diagnostic input contains conflicting identifiers.', + lifecycle: 'Writing diagnostic lifecycle state is invalid.', + }); + +/** Raised when untrusted host diagnostic data cannot cross Inkspan's local boundary. */ +export class WritingDiagnosticError extends TypeError { + /** Stable redacted public failure classification. */ + readonly code: WritingDiagnosticErrorCode; + + constructor(code: WritingDiagnosticErrorCode) { + super(ERROR_MESSAGES[code]); + this.name = 'WritingDiagnosticError'; + this.code = code; + } +} + +const DIAGNOSTIC_FIELDS = Object.freeze([ + 'diagnosticId', + 'documentRevision', + 'textProjection', + 'selector', + 'categoryCode', + 'priority', + 'title', + 'explanation', + 'suggestedReplacement', + 'confidence', + 'provenance', +] as const); +const REQUIRED_DIAGNOSTIC_FIELDS = Object.freeze([ + 'diagnosticId', + 'documentRevision', + 'textProjection', + 'selector', + 'categoryCode', + 'priority', + 'title', + 'explanation', + 'provenance', +] as const); +const REVISION_FIELDS = Object.freeze([ + 'algorithm', + 'digestHex', + 'strongEntityTag', +] as const); +const PROJECTION_FIELDS = Object.freeze(['id', 'version'] as const); +const SELECTOR_FIELDS = Object.freeze(['type', 'start', 'end'] as const); +const PROVENANCE_FIELDS = Object.freeze([ + 'workflowId', + 'workflowVersion', + 'judgePolicyVersion', + 'orchestrationMode', +] as const); +const REQUIRED_PROVENANCE_FIELDS = Object.freeze([ + 'workflowId', + 'workflowVersion', + 'judgePolicyVersion', +] as const); +const LIMIT_FIELDS = Object.freeze([ + 'maxDiagnostics', + 'maxDiagnosticIdCodeUnits', + 'maxCategoryCodeUnits', + 'maxProvenanceCodeUnits', + 'maxTitleCodeUnits', + 'maxExplanationCodeUnits', + 'maxReplacementCodeUnits', +] as const); +const PRIORITIES = new Set([ + 'advisory', + 'important', + 'critical', +]); +const LOWERCASE_SHA256 = /^[0-9a-f]{64}$/u; + +type DataRecord = Readonly>; + +/** + * Validate and detach one complete host-supplied diagnostic set. + * + * The function performs no semantic text analysis and invokes no host callbacks, + * model/provider APIs, network services, persistence services, or editor + * transactions. Every accepted object is copied from own enumerable data + * properties only and the returned tuple is deeply frozen. + */ +export function validateWritingDiagnostics( + input: unknown, + limits?: WritingDiagnosticLimits, +): readonly CwlWritingDiagnostic[] { + const resolvedLimits = resolveLimits(limits); + try { + return validateWritingDiagnosticsWithLimits(input, resolvedLimits); + } catch (error) { + if (error instanceof WritingDiagnosticError) { + throw error; + } + throw new WritingDiagnosticError('contract'); + } +} + +function validateWritingDiagnosticsWithLimits( + input: unknown, + limits: ResolvedWritingDiagnosticLimits, +): readonly CwlWritingDiagnostic[] { + if (!safeArrayIsArray(input)) { + throw new WritingDiagnosticError('contract'); + } + + const length = readArrayLength(input); + if (length > limits.maxDiagnostics) { + throw new WritingDiagnosticError('limit'); + } + validateDenseExactArray(input, length); + + const result: CwlWritingDiagnostic[] = []; + const diagnosticIds = new Set(); + for (let index = 0; index < length; index += 1) { + const descriptor = safeOwnDescriptor(input, String(index), 'contract'); + if (descriptor === undefined || !isEnumerableDataDescriptor(descriptor)) { + throw new WritingDiagnosticError('contract'); + } + const diagnostic = validateDiagnostic(descriptor.value, limits); + if (diagnosticIds.has(diagnostic.diagnosticId)) { + throw new WritingDiagnosticError('conflict'); + } + diagnosticIds.add(diagnostic.diagnosticId); + result.push(diagnostic); + } + return Object.freeze(result); +} + +function validateDiagnostic( + value: unknown, + limits: ResolvedWritingDiagnosticLimits, +): CwlWritingDiagnostic { + const record = readExactObject( + value, + DIAGNOSTIC_FIELDS, + REQUIRED_DIAGNOSTIC_FIELDS, + 'contract', + ); + + const diagnosticId = boundedRequiredString( + record.diagnosticId, + limits.maxDiagnosticIdCodeUnits, + ); + const categoryCode = boundedRequiredString( + record.categoryCode, + limits.maxCategoryCodeUnits, + ); + const title = boundedRequiredString(record.title, limits.maxTitleCodeUnits); + const explanation = boundedString( + record.explanation, + limits.maxExplanationCodeUnits, + true, + ); + const suggestedReplacement = optionalBoundedString( + record.suggestedReplacement, + limits.maxReplacementCodeUnits, + ); + const priority = validatePriority(record.priority); + const confidence = validateConfidence(record.confidence); + const documentRevision = validateRevision(record.documentRevision); + const textProjection = validateProjection(record.textProjection); + const selector = validateSelector(record.selector); + const provenance = validateProvenance(record.provenance, limits); + + const detached: CwlWritingDiagnostic = { + diagnosticId, + documentRevision, + textProjection, + selector, + categoryCode, + priority, + title, + explanation, + provenance, + }; + if (suggestedReplacement !== undefined) { + Object.defineProperty(detached, 'suggestedReplacement', { + value: suggestedReplacement, + enumerable: true, + configurable: false, + writable: false, + }); + } + if (confidence !== undefined) { + Object.defineProperty(detached, 'confidence', { + value: confidence, + enumerable: true, + configurable: false, + writable: false, + }); + } + return Object.freeze(detached); +} + +function validateRevision(value: unknown): CwlEditorDocumentRevision { + const record = readExactObject( + value, + REVISION_FIELDS, + REVISION_FIELDS, + 'revision', + ); + const digestHex = record.digestHex; + if ( + record.algorithm !== 'SHA-256' || + typeof digestHex !== 'string' || + digestHex.length !== 64 || + !LOWERCASE_SHA256.test(digestHex) || + record.strongEntityTag !== `"sha256-${digestHex}"` + ) { + throw new WritingDiagnosticError('revision'); + } + return Object.freeze({ + algorithm: 'SHA-256' as const, + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }); +} + +function validateProjection(value: unknown): CwlEditorTextProjectionIdentity { + const record = readExactObject( + value, + PROJECTION_FIELDS, + PROJECTION_FIELDS, + 'projection', + ); + if ( + record.id !== TEXT_POSITION_PROJECTION_ID || + record.version !== TEXT_POSITION_PROJECTION_VERSION + ) { + throw new WritingDiagnosticError('projection'); + } + return Object.freeze({ + id: TEXT_POSITION_PROJECTION_ID, + version: TEXT_POSITION_PROJECTION_VERSION, + }); +} + +function validateSelector(value: unknown): CwlEditorTextPositionSelector { + const record = readExactObject( + value, + SELECTOR_FIELDS, + SELECTOR_FIELDS, + 'selector', + ); + const start = record.start; + const end = record.end; + if ( + record.type !== 'TextPositionSelector' || + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + (start as number) < 0 || + (end as number) < (start as number) + ) { + throw new WritingDiagnosticError('selector'); + } + return Object.freeze({ + type: 'TextPositionSelector' as const, + start: start as number, + end: end as number, + }); +} + +function validateProvenance( + value: unknown, + limits: ResolvedWritingDiagnosticLimits, +): Readonly { + const record = readExactObject( + value, + PROVENANCE_FIELDS, + REQUIRED_PROVENANCE_FIELDS, + 'contract', + ); + const workflowId = boundedRequiredString( + record.workflowId, + limits.maxProvenanceCodeUnits, + ); + const workflowVersion = boundedRequiredString( + record.workflowVersion, + limits.maxProvenanceCodeUnits, + ); + const judgePolicyVersion = boundedRequiredString( + record.judgePolicyVersion, + limits.maxProvenanceCodeUnits, + ); + const orchestrationMode = optionalBoundedString( + record.orchestrationMode, + limits.maxProvenanceCodeUnits, + ); + const result: CwlWritingDiagnosticProvenance = { + workflowId, + workflowVersion, + judgePolicyVersion, + }; + if (orchestrationMode !== undefined) { + Object.defineProperty(result, 'orchestrationMode', { + value: orchestrationMode, + enumerable: true, + configurable: false, + writable: false, + }); + } + return Object.freeze(result); +} + +function validatePriority(value: unknown): CwlWritingDiagnosticPriority { + if (typeof value !== 'string' || !PRIORITIES.has(value as CwlWritingDiagnosticPriority)) { + throw new WritingDiagnosticError('contract'); + } + return value as CwlWritingDiagnosticPriority; +} + +function validateConfidence(value: unknown): number | undefined { + if (value === undefined) { + return undefined; + } + if ( + typeof value !== 'number' || + !Number.isFinite(value) || + value < 0 || + value > 1 + ) { + throw new WritingDiagnosticError('contract'); + } + return value; +} + +function boundedRequiredString(value: unknown, maxCodeUnits: number): string { + return boundedString(value, maxCodeUnits, false); +} + +function optionalBoundedString( + value: unknown, + maxCodeUnits: number, +): string | undefined { + if (value === undefined) { + return undefined; + } + return boundedString(value, maxCodeUnits, true); +} + +function boundedString( + value: unknown, + maxCodeUnits: number, + allowEmpty: boolean, +): string { + if (typeof value !== 'string' || (!allowEmpty && value.length === 0)) { + throw new WritingDiagnosticError('contract'); + } + if (value.length > maxCodeUnits) { + throw new WritingDiagnosticError('limit'); + } + return value; +} + +function resolveLimits( + limits: WritingDiagnosticLimits | undefined, +): ResolvedWritingDiagnosticLimits { + if (limits === undefined) { + return DEFAULT_WRITING_DIAGNOSTIC_LIMITS; + } + const record = readExactObject(limits, LIMIT_FIELDS, [], 'contract'); + const resolved: Record = { + ...DEFAULT_WRITING_DIAGNOSTIC_LIMITS, + }; + for (const key of LIMIT_FIELDS) { + const value = record[key]; + if (value === undefined) { + continue; + } + const hardMaximum = DEFAULT_WRITING_DIAGNOSTIC_LIMITS[key]; + if ( + !Number.isSafeInteger(value) || + (value as number) < 1 || + (value as number) > hardMaximum + ) { + throw new WritingDiagnosticError('contract'); + } + resolved[key] = value as number; + } + return Object.freeze(resolved); +} + +function readExactObject( + value: unknown, + allowedKeys: readonly K[], + requiredKeys: readonly K[], + errorCode: WritingDiagnosticErrorCode, +): DataRecord { + if ( + typeof value !== 'object' || + value === null || + safeArrayIsArray(value) + ) { + throw new WritingDiagnosticError(errorCode); + } + + let prototype: object | null; + let keys: PropertyKey[]; + try { + prototype = Object.getPrototypeOf(value); + keys = Reflect.ownKeys(value); + } catch { + throw new WritingDiagnosticError(errorCode); + } + if (prototype !== Object.prototype && prototype !== null) { + throw new WritingDiagnosticError(errorCode); + } + + const allowed = new Set(allowedKeys); + const result: Record = {}; + for (const key of keys) { + if (typeof key !== 'string' || !allowed.has(key)) { + throw new WritingDiagnosticError(errorCode); + } + const descriptor = safeOwnDescriptor(value, key, errorCode); + if (descriptor === undefined || !isEnumerableDataDescriptor(descriptor)) { + throw new WritingDiagnosticError(errorCode); + } + Object.defineProperty(result, key, { + value: descriptor.value, + enumerable: true, + configurable: true, + writable: true, + }); + } + for (const key of requiredKeys) { + if (!Object.prototype.hasOwnProperty.call(result, key)) { + throw new WritingDiagnosticError(errorCode); + } + } + return result; +} + +function safeArrayIsArray(value: unknown): value is unknown[] { + try { + return Array.isArray(value); + } catch { + throw new WritingDiagnosticError('contract'); + } +} + +function readArrayLength(value: unknown[]): number { + const descriptor = safeOwnDescriptor(value, 'length', 'contract'); + if ( + descriptor === undefined || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') || + typeof descriptor.value !== 'number' || + !Number.isSafeInteger(descriptor.value) || + descriptor.value < 0 + ) { + throw new WritingDiagnosticError('contract'); + } + return descriptor.value; +} + +function validateDenseExactArray(value: unknown[], length: number): void { + let keys: PropertyKey[]; + try { + keys = Reflect.ownKeys(value); + } catch { + throw new WritingDiagnosticError('contract'); + } + if (keys.length !== length + 1 || !keys.includes('length')) { + throw new WritingDiagnosticError('contract'); + } + for (let index = 0; index < length; index += 1) { + if (!keys.includes(String(index))) { + throw new WritingDiagnosticError('contract'); + } + } + for (const key of keys) { + if (key === 'length') { + continue; + } + if ( + typeof key !== 'string' || + !Number.isSafeInteger(Number(key)) || + String(Number(key)) !== key || + Number(key) < 0 || + Number(key) >= length + ) { + throw new WritingDiagnosticError('contract'); + } + } +} + +function safeOwnDescriptor( + value: object, + key: PropertyKey, + errorCode: WritingDiagnosticErrorCode, +): PropertyDescriptor | undefined { + try { + return Object.getOwnPropertyDescriptor(value, key); + } catch { + throw new WritingDiagnosticError(errorCode); + } +} + +function isEnumerableDataDescriptor( + descriptor: PropertyDescriptor, +): descriptor is PropertyDescriptor & { value: unknown } { + return ( + descriptor.enumerable === true && + Object.prototype.hasOwnProperty.call(descriptor, 'value') + ); +} From 68c8665bf6519536e08ff1236c9b524aa827945f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:34:49 +0900 Subject: [PATCH 04/21] test(diagnostics): apply stricter limit in its own assertion --- src/writingDiagnostics.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/writingDiagnostics.test.ts b/src/writingDiagnostics.test.ts index edc9d95a..02d337e6 100644 --- a/src/writingDiagnostics.test.ts +++ b/src/writingDiagnostics.test.ts @@ -4,6 +4,7 @@ import { WritingDiagnosticError, validateWritingDiagnostics, type CwlWritingDiagnostic, + type WritingDiagnosticLimits, } from './writingDiagnostics.js'; const DIGEST = 'a'.repeat(64); @@ -43,9 +44,13 @@ function validDiagnostic( }; } -function expectCode(input: unknown, code: WritingDiagnosticError['code']): void { +function expectCode( + input: unknown, + code: WritingDiagnosticError['code'], + limits?: WritingDiagnosticLimits, +): void { try { - validateWritingDiagnostics(input); + validateWritingDiagnostics(input, limits); } catch (error) { expect(error).toBeInstanceOf(WritingDiagnosticError); expect((error as WritingDiagnosticError).code).toBe(code); @@ -309,14 +314,16 @@ describe('validateWritingDiagnostics', () => { }); it('supports stricter caller limits without mutating defaults', () => { - const result = validateWritingDiagnostics([validDiagnostic()], { + const stricterLimits = { ...DEFAULT_WRITING_DIAGNOSTIC_LIMITS, maxTitleCodeUnits: 32, - }); + }; + const result = validateWritingDiagnostics([validDiagnostic()], stricterLimits); expect(result[0].title).toBe('Check agreement'); expectCode( [validDiagnostic({ title: 'x'.repeat(33) })], 'limit', + stricterLimits, ); expect(DEFAULT_WRITING_DIAGNOSTIC_LIMITS.maxTitleCodeUnits).toBe(256); }); From 8b60c143ce92129cb0f8832298705c491a355a63 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:36:47 +0900 Subject: [PATCH 05/21] test(diagnostics): require root and framework-free exports --- src/writingDiagnosticsExports.test.ts | 75 +++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/writingDiagnosticsExports.test.ts diff --git a/src/writingDiagnosticsExports.test.ts b/src/writingDiagnosticsExports.test.ts new file mode 100644 index 00000000..4dd2abeb --- /dev/null +++ b/src/writingDiagnosticsExports.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import * as rootSurface from './index.js'; +import * as subpathSurface from './writing-diagnostics/index.js'; +import { + DEFAULT_WRITING_DIAGNOSTIC_LIMITS, + WritingDiagnosticError, + validateWritingDiagnostics, + type CwlWritingDiagnostic, + type CwlWritingDiagnosticPriority, + type CwlWritingDiagnosticProvenance, + type WritingDiagnosticErrorCode, + type WritingDiagnosticLimits, +} from './writingDiagnostics.js'; +import type { + CwlWritingDiagnostic as RootDiagnostic, + CwlWritingDiagnosticPriority as RootPriority, + CwlWritingDiagnosticProvenance as RootProvenance, + WritingDiagnosticErrorCode as RootErrorCode, + WritingDiagnosticLimits as RootLimits, +} from './index.js'; +import type { + CwlWritingDiagnostic as SubpathDiagnostic, + CwlWritingDiagnosticPriority as SubpathPriority, + CwlWritingDiagnosticProvenance as SubpathProvenance, + WritingDiagnosticErrorCode as SubpathErrorCode, + WritingDiagnosticLimits as SubpathLimits, +} from './writing-diagnostics/index.js'; + +type Exact = + (() => T extends A ? 1 : 2) extends + (() => T extends B ? 1 : 2) + ? (() => T extends B ? 1 : 2) extends + (() => T extends A ? 1 : 2) + ? true + : false + : false; + +const rootTypeContract: readonly true[] = [ + true as Exact, + true as Exact, + true as Exact, + true as Exact, + true as Exact, +]; +const subpathTypeContract: readonly true[] = [ + true as Exact, + true as Exact, + true as Exact, + true as Exact, + true as Exact, +]; + +describe('writing diagnostic public source exports', () => { + it('re-exports one identical runtime contract from the root surface', () => { + expect(rootSurface.DEFAULT_WRITING_DIAGNOSTIC_LIMITS).toBe( + DEFAULT_WRITING_DIAGNOSTIC_LIMITS, + ); + expect(rootSurface.WritingDiagnosticError).toBe(WritingDiagnosticError); + expect(rootSurface.validateWritingDiagnostics).toBe( + validateWritingDiagnostics, + ); + expect(rootTypeContract).toEqual([true, true, true, true, true]); + }); + + it('exposes the same framework-independent contract from the subpath', () => { + expect(subpathSurface.DEFAULT_WRITING_DIAGNOSTIC_LIMITS).toBe( + DEFAULT_WRITING_DIAGNOSTIC_LIMITS, + ); + expect(subpathSurface.WritingDiagnosticError).toBe(WritingDiagnosticError); + expect(subpathSurface.validateWritingDiagnostics).toBe( + validateWritingDiagnostics, + ); + expect(subpathTypeContract).toEqual([true, true, true, true, true]); + }); +}); From dfc8a8d0f1a3cf5050f6552b8a349e42b2a52fb8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:37:01 +0900 Subject: [PATCH 06/21] ci(diagnostics): verify source export contract --- .github/workflows/writing-diagnostics-contract-tdd.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/writing-diagnostics-contract-tdd.yml b/.github/workflows/writing-diagnostics-contract-tdd.yml index 179d7dd3..48542a2c 100644 --- a/.github/workflows/writing-diagnostics-contract-tdd.yml +++ b/.github/workflows/writing-diagnostics-contract-tdd.yml @@ -31,5 +31,10 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - - name: Run focused contract regression - run: pnpm exec vitest run src/writingDiagnostics.test.ts + - name: Run focused contract regressions + run: >- + pnpm exec vitest run + src/writingDiagnostics.test.ts + src/writingDiagnosticsExports.test.ts + - name: Typecheck public source contracts + run: pnpm typecheck From 6a2cae577282db61ca7aaac765fb4dbb53e07091 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:37:45 +0900 Subject: [PATCH 07/21] feat(diagnostics): add framework-free source subpath --- src/writing-diagnostics/index.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/writing-diagnostics/index.ts diff --git a/src/writing-diagnostics/index.ts b/src/writing-diagnostics/index.ts new file mode 100644 index 00000000..83c7afd0 --- /dev/null +++ b/src/writing-diagnostics/index.ts @@ -0,0 +1,19 @@ +/** + * Framework-independent public surface for host-supplied writing diagnostics. + * + * This subpath validates bounded revision-scoped diagnostic proposals. It does + * not import React, create editor instances, call models/providers or networks, + * persist authored content, infer language quality, or mutate a document. + */ +export { + DEFAULT_WRITING_DIAGNOSTIC_LIMITS, + WritingDiagnosticError, + validateWritingDiagnostics, +} from '../writingDiagnostics.js'; +export type { + CwlWritingDiagnostic, + CwlWritingDiagnosticPriority, + CwlWritingDiagnosticProvenance, + WritingDiagnosticErrorCode, + WritingDiagnosticLimits, +} from '../writingDiagnostics.js'; From bae0d552182c62e5469b30221131c634ef9d0932 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:38:10 +0900 Subject: [PATCH 08/21] feat(diagnostics): export host diagnostic contract --- src/index.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/index.ts b/src/index.ts index f7905449..8c1f20fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,20 @@ export type { TextPositionSelectorEvidenceErrorCode, } from './textPositionSelectorEvidence.js'; +// Host-owned, revision-scoped writing diagnostic contract. +export { + DEFAULT_WRITING_DIAGNOSTIC_LIMITS, + WritingDiagnosticError, + validateWritingDiagnostics, +} from './writingDiagnostics.js'; +export type { + CwlWritingDiagnostic, + CwlWritingDiagnosticPriority, + CwlWritingDiagnosticProvenance, + WritingDiagnosticErrorCode, + WritingDiagnosticLimits, +} from './writingDiagnostics.js'; + // Versioned, lossless persistence boundary. export { DEFAULT_DOCUMENT_ENVELOPE_LIMITS, From ce38a45096a3d361f056a509b6445dc099c64222 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:39:48 +0900 Subject: [PATCH 09/21] ci(diagnostics): enforce exact contract coverage --- .github/workflows/writing-diagnostics-contract-tdd.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/writing-diagnostics-contract-tdd.yml b/.github/workflows/writing-diagnostics-contract-tdd.yml index 48542a2c..e31f3217 100644 --- a/.github/workflows/writing-diagnostics-contract-tdd.yml +++ b/.github/workflows/writing-diagnostics-contract-tdd.yml @@ -31,10 +31,14 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - - name: Run focused contract regressions + - name: Run focused contract regressions with exact coverage run: >- pnpm exec vitest run src/writingDiagnostics.test.ts src/writingDiagnosticsExports.test.ts + --coverage + --coverage.include=src/writingDiagnostics.ts + --coverage.reporter=text + --coverage.reporter=json-summary - name: Typecheck public source contracts run: pnpm typecheck From 9bb01175876d4bdf7f5e6930cde30f0705153552 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:41:12 +0900 Subject: [PATCH 10/21] ci(diagnostics): report uncovered contract paths --- .../writing-diagnostics-contract-tdd.yml | 79 ++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/.github/workflows/writing-diagnostics-contract-tdd.yml b/.github/workflows/writing-diagnostics-contract-tdd.yml index e31f3217..5b6f1ba1 100644 --- a/.github/workflows/writing-diagnostics-contract-tdd.yml +++ b/.github/workflows/writing-diagnostics-contract-tdd.yml @@ -31,7 +31,9 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - - name: Run focused contract regressions with exact coverage + - name: Collect focused contract coverage + id: focused_coverage + continue-on-error: true run: >- pnpm exec vitest run src/writingDiagnostics.test.ts @@ -39,6 +41,81 @@ jobs: --coverage --coverage.include=src/writingDiagnostics.ts --coverage.reporter=text + --coverage.reporter=json --coverage.reporter=json-summary + - name: Report and enforce exact contract coverage + run: | + node <<'NODE' + const { readFileSync } = require('node:fs'); + const coverage = JSON.parse(readFileSync('coverage/coverage-final.json', 'utf8')); + const [filePath, fileCoverage] = Object.entries(coverage).find( + ([candidate]) => candidate.endsWith('/src/writingDiagnostics.ts'), + ) ?? []; + if (!filePath || !fileCoverage) { + console.error('::error file=src/writingDiagnostics.ts,line=1::Focused coverage record is missing.'); + process.exit(1); + } + + const missingStatementLines = [...new Set( + Object.entries(fileCoverage.s) + .filter(([, count]) => count === 0) + .map(([id]) => fileCoverage.statementMap[id].start.line), + )].sort((a, b) => a - b); + const missingFunctionLines = [...new Set( + Object.entries(fileCoverage.f) + .filter(([, count]) => count === 0) + .map(([id]) => fileCoverage.fnMap[id].decl.start.line), + )].sort((a, b) => a - b); + const missingBranches = []; + for (const [id, counts] of Object.entries(fileCoverage.b)) { + counts.forEach((count, index) => { + if (count === 0) { + const branch = fileCoverage.branchMap[id]; + const location = branch.locations?.[index] ?? branch.loc; + missingBranches.push(`${location.start.line}:${index}`); + } + }); + } + + const total = (values) => Object.values(values).reduce( + (sum, value) => sum + (Array.isArray(value) ? value.length : 1), + 0, + ); + const covered = (values) => Object.values(values).reduce( + (sum, value) => sum + (Array.isArray(value) + ? value.filter((count) => count > 0).length + : Number(value > 0)), + 0, + ); + const statementTotal = total(fileCoverage.s); + const statementCovered = covered(fileCoverage.s); + const functionTotal = total(fileCoverage.f); + const functionCovered = covered(fileCoverage.f); + const branchTotal = total(fileCoverage.b); + const branchCovered = covered(fileCoverage.b); + console.log( + `::notice file=src/writingDiagnostics.ts,line=1::` + + `Statements ${statementCovered}/${statementTotal}; ` + + `functions ${functionCovered}/${functionTotal}; ` + + `branches ${branchCovered}/${branchTotal}.`, + ); + + if ( + missingStatementLines.length || + missingFunctionLines.length || + missingBranches.length || + process.env.FOCUSED_COVERAGE_OUTCOME !== 'success' + ) { + console.error( + `::error file=src/writingDiagnostics.ts,line=1::` + + `Missing statement lines: ${missingStatementLines.join(', ') || 'none'}; ` + + `missing function lines: ${missingFunctionLines.join(', ') || 'none'}; ` + + `missing branches line:index: ${missingBranches.join(', ') || 'none'}.`, + ); + process.exit(1); + } + NODE + env: + FOCUSED_COVERAGE_OUTCOME: ${{ steps.focused_coverage.outcome }} - name: Typecheck public source contracts run: pnpm typecheck From 246614a3ddd01f806f0988544564fa4166923db5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:44:15 +0900 Subject: [PATCH 11/21] test(diagnostics): cover hostile structural boundaries --- src/writingDiagnosticsBoundary.test.ts | 182 +++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 src/writingDiagnosticsBoundary.test.ts diff --git a/src/writingDiagnosticsBoundary.test.ts b/src/writingDiagnosticsBoundary.test.ts new file mode 100644 index 00000000..dfd248cc --- /dev/null +++ b/src/writingDiagnosticsBoundary.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest'; +import { + WritingDiagnosticError, + validateWritingDiagnostics, + type CwlWritingDiagnostic, + type WritingDiagnosticLimits, +} from './writingDiagnostics.js'; + +const DIGEST = 'b'.repeat(64); + +function diagnostic( + overrides: Partial = {}, +): CwlWritingDiagnostic { + return { + diagnosticId: 'boundary-diagnostic', + documentRevision: { + algorithm: 'SHA-256', + digestHex: DIGEST, + strongEntityTag: `"sha256-${DIGEST}"`, + }, + textProjection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + selector: { + type: 'TextPositionSelector', + start: 0, + end: 1, + }, + categoryCode: 'host.category', + priority: 'advisory', + title: 'Host title', + explanation: '', + provenance: { + workflowId: 'workflow', + workflowVersion: '1', + judgePolicyVersion: '1', + }, + ...overrides, + }; +} + +function expectCode( + input: unknown, + code: WritingDiagnosticError['code'], + limits?: WritingDiagnosticLimits, +): void { + try { + validateWritingDiagnostics(input, limits); + } catch (error) { + expect(error).toBeInstanceOf(WritingDiagnosticError); + expect((error as WritingDiagnosticError).code).toBe(code); + return; + } + throw new Error(`Expected WritingDiagnosticError(${code})`); +} + +describe('writing diagnostic hostile-boundary coverage', () => { + it('accepts a genuinely partial stricter-limit object', () => { + const result = validateWritingDiagnostics([diagnostic()], { + maxTitleCodeUnits: 32, + }); + + expect(result[0].title).toBe('Host title'); + }); + + it('rejects non-object diagnostic and nested contract values', () => { + expectCode([null], 'contract'); + expectCode([[]], 'contract'); + expectCode(['diagnostic'], 'contract'); + expectCode( + [diagnostic({ documentRevision: [] as unknown as CwlWritingDiagnostic['documentRevision'] })], + 'revision', + ); + }); + + it('rejects a missing required own field', () => { + const missingTitle = { ...diagnostic() } as Partial; + delete missingTitle.title; + + expectCode([missingTitle], 'contract'); + }); + + it('redacts a revoked array proxy before any member access', () => { + const revocable = Proxy.revocable([diagnostic()], {}); + revocable.revoke(); + + expectCode(revocable.proxy, 'contract'); + }); + + it('rejects an invalid array length descriptor', () => { + const target = [diagnostic()]; + const input = new Proxy(target, { + getOwnPropertyDescriptor(currentTarget, key) { + if (key === 'length') { + return { + value: '1', + writable: true, + enumerable: false, + configurable: false, + }; + } + return Reflect.getOwnPropertyDescriptor(currentTarget, key); + }, + }); + + expectCode(input, 'contract'); + }); + + it('redacts an array own-key reflection failure', () => { + const input = new Proxy([diagnostic()], { + ownKeys() { + throw new Error('private array key material'); + }, + }); + + expectCode(input, 'contract'); + }); + + it('rejects a dense-array key set that omits the required index', () => { + const target = [diagnostic()]; + const replacement = diagnostic({ diagnosticId: 'replacement' }); + const input = new Proxy(target, { + ownKeys() { + return ['1', 'length']; + }, + getOwnPropertyDescriptor(currentTarget, key) { + if (key === '1') { + return { + value: replacement, + writable: true, + enumerable: true, + configurable: true, + }; + } + return Reflect.getOwnPropertyDescriptor(currentTarget, key); + }, + }); + + expectCode(input, 'contract'); + }); + + it('rejects an index hidden after the exact key inventory', () => { + const target = [diagnostic()]; + const input = new Proxy(target, { + getOwnPropertyDescriptor(currentTarget, key) { + if (key === '0') { + return undefined; + } + return Reflect.getOwnPropertyDescriptor(currentTarget, key); + }, + }); + + expectCode(input, 'contract'); + }); + + it('rejects an accessor-backed array member without evaluating it', () => { + const input: unknown[] = []; + Object.defineProperty(input, '0', { + enumerable: true, + configurable: true, + get() { + throw new Error('array accessor must never execute'); + }, + }); + + expectCode(input, 'contract'); + }); + + it('redacts an own-property-descriptor reflection failure', () => { + const input = new Proxy([diagnostic()], { + getOwnPropertyDescriptor(currentTarget, key) { + if (key === 'length') { + throw new Error('private descriptor material'); + } + return Reflect.getOwnPropertyDescriptor(currentTarget, key); + }, + }); + + expectCode(input, 'contract'); + }); +}); From a38d3d4a34582afae468be1ef299c721394a76c6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:44:46 +0900 Subject: [PATCH 12/21] ci(diagnostics): exercise hostile contract boundaries --- .github/workflows/writing-diagnostics-contract-tdd.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/writing-diagnostics-contract-tdd.yml b/.github/workflows/writing-diagnostics-contract-tdd.yml index 5b6f1ba1..cc9871d5 100644 --- a/.github/workflows/writing-diagnostics-contract-tdd.yml +++ b/.github/workflows/writing-diagnostics-contract-tdd.yml @@ -37,6 +37,7 @@ jobs: run: >- pnpm exec vitest run src/writingDiagnostics.test.ts + src/writingDiagnosticsBoundary.test.ts src/writingDiagnosticsExports.test.ts --coverage --coverage.include=src/writingDiagnostics.ts From 1cf16ac7fa7ce2bc7f1f0640e0b7c052b9fd635a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:47:26 +0900 Subject: [PATCH 13/21] refactor(diagnostics): remove unreachable validation branches --- src/writingDiagnostics.ts | 29 +++++------------------------ 1 file changed, 5 insertions(+), 24 deletions(-) diff --git a/src/writingDiagnostics.ts b/src/writingDiagnostics.ts index 66eb7873..b303a5cd 100644 --- a/src/writingDiagnostics.ts +++ b/src/writingDiagnostics.ts @@ -201,15 +201,7 @@ export function validateWritingDiagnostics( input: unknown, limits?: WritingDiagnosticLimits, ): readonly CwlWritingDiagnostic[] { - const resolvedLimits = resolveLimits(limits); - try { - return validateWritingDiagnosticsWithLimits(input, resolvedLimits); - } catch (error) { - if (error instanceof WritingDiagnosticError) { - throw error; - } - throw new WritingDiagnosticError('contract'); - } + return validateWritingDiagnosticsWithLimits(input, resolveLimits(limits)); } function validateWritingDiagnosticsWithLimits( @@ -420,7 +412,10 @@ function validateProvenance( } function validatePriority(value: unknown): CwlWritingDiagnosticPriority { - if (typeof value !== 'string' || !PRIORITIES.has(value as CwlWritingDiagnosticPriority)) { + if ( + typeof value !== 'string' || + !PRIORITIES.has(value as CwlWritingDiagnosticPriority) + ) { throw new WritingDiagnosticError('contract'); } return value as CwlWritingDiagnosticPriority; @@ -585,20 +580,6 @@ function validateDenseExactArray(value: unknown[], length: number): void { throw new WritingDiagnosticError('contract'); } } - for (const key of keys) { - if (key === 'length') { - continue; - } - if ( - typeof key !== 'string' || - !Number.isSafeInteger(Number(key)) || - String(Number(key)) !== key || - Number(key) < 0 || - Number(key) >= length - ) { - throw new WritingDiagnosticError('contract'); - } - } } function safeOwnDescriptor( From 67b0e56253f652466f13aef1997067d6f85c507a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:49:33 +0900 Subject: [PATCH 14/21] test(diagnostics): keep missing-field fixture type-safe --- src/writingDiagnosticsBoundary.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/writingDiagnosticsBoundary.test.ts b/src/writingDiagnosticsBoundary.test.ts index dfd248cc..a8c416fc 100644 --- a/src/writingDiagnosticsBoundary.test.ts +++ b/src/writingDiagnosticsBoundary.test.ts @@ -75,8 +75,8 @@ describe('writing diagnostic hostile-boundary coverage', () => { }); it('rejects a missing required own field', () => { - const missingTitle = { ...diagnostic() } as Partial; - delete missingTitle.title; + const { title: omittedTitle, ...missingTitle } = diagnostic(); + expect(omittedTitle).toBe('Host title'); expectCode([missingTitle], 'contract'); }); From 7c8dd980255e4656e1fbcdf27df9ad3a4658fb2d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:51:32 +0900 Subject: [PATCH 15/21] ci(diagnostics): run full package acceptance --- .github/workflows/writing-diagnostics-contract-tdd.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/writing-diagnostics-contract-tdd.yml b/.github/workflows/writing-diagnostics-contract-tdd.yml index cc9871d5..583f6201 100644 --- a/.github/workflows/writing-diagnostics-contract-tdd.yml +++ b/.github/workflows/writing-diagnostics-contract-tdd.yml @@ -19,7 +19,7 @@ env: jobs: focused-contract: runs-on: ubuntu-24.04 - timeout-minutes: 15 + timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -120,3 +120,11 @@ jobs: FOCUSED_COVERAGE_OUTCOME: ${{ steps.focused_coverage.outcome }} - name: Typecheck public source contracts run: pnpm typecheck + - name: Run complete production coverage gate + run: pnpm coverage + - name: Build all package entrypoints + run: pnpm build + - name: Verify isolated package consumers + run: pnpm verify:package + - name: Build the demonstration application + run: pnpm build:demo From c0baa935d5d0875b40f0bf19131aabca91704b79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:54:51 +0900 Subject: [PATCH 16/21] docs(adr): restore canonical quality contract --- docs/adr/README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/adr/README.md b/docs/adr/README.md index fbd520a2..3b73b384 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -44,4 +44,15 @@ Material changes add or supersede an ADR rather than silently rewriting history. ## ADR quality requirements -Each material ADR covers context, alternatives, decision, consequences, failure and recovery, security and privacy, compatibility and migration, verification, and rollback or supersession. Accessibility, operability, standards/research traceability, release impact, and synchronized PRD/TRD/Architecture/contracts/UML/data model/threat model/test strategy are required where affected. +Every material ADR documents the following evidence explicitly: + +- context and the problem boundary; +- materially distinct alternatives considered; +- the selected decision and its consequences; +- failure and recovery semantics; +- security and privacy impact; +- compatibility and migration behavior; +- verification/acceptance evidence; +- rollback or explicit supersession conditions. + +Accessibility, operability, standards/research traceability, release impact, and synchronized PRD/TRD/Architecture/contracts/UML/data model/threat model/test strategy are also required where affected. From 6697bfe3611c9c109de3bf99fc362d365792aff7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:58:34 +0900 Subject: [PATCH 17/21] docs(adr): inherit strict diagnostics v1 decision --- ...ting-diagnostics-v1-strict-invalidation.md | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/adr/0028-writing-diagnostics-v1-strict-invalidation.md diff --git a/docs/adr/0028-writing-diagnostics-v1-strict-invalidation.md b/docs/adr/0028-writing-diagnostics-v1-strict-invalidation.md new file mode 100644 index 00000000..10587ee0 --- /dev/null +++ b/docs/adr/0028-writing-diagnostics-v1-strict-invalidation.md @@ -0,0 +1,104 @@ +# ADR 0028: Strict invalidation and semantic-neutral accessibility for writing diagnostics v1 + +Status: Proposed + +## Context + +ADR 0027, its design specification, and its implementation plan established the correct high-level boundary: hosts own every semantic writing judgment while Inkspan owns deterministic validation, revision/selector integrity, presentation, accessibility, and ordinary editor transactions. + +Two lower-level clauses nevertheless permitted incompatible first-release interpretations: + +1. some prose allowed a diagnostic to survive a local transaction when ProseMirror mapping appeared valid, while the implementation plan's global contract required every local or collaborative document change to invalidate every active diagnostic; and +2. the decoration plan proposed `aria-invalid="spelling"` when a host category “maps to mechanics,” although `categoryCode` is deliberately opaque and the v1 contract contains no explicit semantic accessibility field. + +Leaving those ambiguities unresolved would force implementers either to invent semantic mapping from an opaque string or to maintain two competing stale-diagnostic lifecycles. + +## Alternatives considered + +### Preserve diagnostics through transaction mapping + +Rejected for v1. ProseMirror can map structural positions, but position continuity does not prove that a host model's semantic judgment still applies to changed prose. This also conflicts with the already implemented controller/decorations direction and complicates collaborative edits, asynchronous digest races, application revalidation, testing, and host refresh ownership. + +### Derive ARIA validity from `categoryCode` + +Rejected. `categoryCode` is host-defined opaque metadata. Comparing it with strings such as `spelling`, `grammar`, `mechanics`, or language-specific equivalents would be the semantic keyword fallback that ADR 0027 explicitly prohibits. + +### Add an explicit semantic ARIA enum to v1 + +Deferred. A future version may add a bounded, explicitly declared accessibility semantic if evidence shows that decoration-level `aria-invalid` is interoperable and useful across supported browser/assistive-technology combinations. That addition requires its own versioned contract, tests, compatibility review, and ADR. + +### Strictly invalidate on every document change and keep decorations semantically neutral + +Selected for v1 because it is deterministic, explainable, provider-neutral, privacy-minimized, compatible with the existing implementation stack, and safe under both standalone and collaborative editing. + +## Decision + +For writing diagnostics v1: + +- every transaction with `docChanged === true`, whether local or collaborative, invalidates the complete active diagnostic generation before any further display or mutation authority can be used; +- no diagnostic range is preserved, remapped, repaired, or re-admitted through ProseMirror mapping, nearest-text search, quote search, keyword search, or semantic guessing; +- a host that wants current guidance after a document change must submit a new diagnostic set bound to a newly derived exact document revision; +- final application still performs exact current-state verification under the implementation plan, but mapping is not an alternate admission path; +- inline decorations contain static Inkspan classes, priority styling, and an opaque diagnostic identifier only; +- Inkspan does not derive `aria-invalid`, spelling/grammar state, or any other semantic accessibility assertion from `categoryCode`, title, explanation, replacement, confidence, provenance, or source text; +- category, priority, title, explanation, and actions remain available as plain text in the named diagnostics panel, while underlines are a visual supplement rather than the sole information channel. + +This ADR narrows and supersedes only the conflicting lifecycle and decoration-accessibility clauses in ADR 0027, the 2026-08-12 design specification, and the original implementation plan. Their product/host/model authority, security, privacy, revision, packaging, and release decisions remain in force. + +## Consequences + +### Positive + +- one lifecycle applies to local edits, remote edits, digest races, focus, action callbacks, and replacement application; +- no hidden semantic classifier is introduced into Inkspan; +- stale diagnostics fail closed without pretending position continuity proves meaning continuity; +- hosts receive a clear refresh responsibility; +- accessibility remains complete through explicit panel text and actions without unsupported semantic ARIA claims. + +### Trade-offs + +- even an unrelated document edit invalidates all active diagnostics in v1; +- hosts may perform more review refreshes; +- Inkspan does not preserve diagnostic continuity across edits until a future, separately governed evidence model exists. + +These costs are accepted because deterministic invalidation is safer and easier to validate than a partially semantic remapping policy. + +## Failure and recovery + +- A document-changing transaction clears active decorations and marks the generation stale. +- A digest or selector result completing for an invalidated generation is discarded. +- Apply/ignore/dismiss/explain operations against a missing or stale generation return typed non-mutating results. +- The host may recover only by supplying a newly admitted diagnostic set for the current exact revision. +- No offline or model-unavailable fallback fabricates a diagnostic. + +## Security and privacy impact + +The decision prevents opaque host category strings from gaining semantic execution or accessibility authority. It also prevents stale model output from being moved onto changed content. No additional authored text, replacement text, prompt, model output, tenant identifier, provider credential, or document envelope enters telemetry or DOM attributes. + +## Accessibility impact + +The panel must expose a named region, count, ordered list, category, priority, title, explanation, affected-range navigation, and explicit Apply/Ignore/Dismiss/Explain controls. Information must remain available without color, hover, pointer input, animation, or generated CSS content. Asynchronous arrival must not steal focus. An assertive alert is reserved for an actual application conflict; ordinary actions use polite status messaging. + +## Compatibility and migration + +The decision is additive to hosts that do not enable writing diagnostics. Diagnostics remain noncanonical review state, so no document-envelope, persistence, collaboration, or database migration is required. Existing Task 1–4 implementation work already follows strict invalidation and, after the corresponding runtime correction, semantic-neutral decoration behavior. Downstream tasks must consume the same exact contract; predecessor checks and reviews do not transfer after any stack refresh. + +A future public contract may add explicit accessibility semantics or evidence-backed diagnostic continuity only through a new versioned type and compatibility plan. Opaque v1 category strings do not acquire new meaning retrospectively. + +## Verification + +Acceptance requires deterministic tests proving: + +- local and Yjs remote `docChanged` transactions invalidate the complete generation; +- no transaction mapping preserves diagnostics in v1; +- stale asynchronous work cannot install decorations or emit actions; +- decoration attributes contain no title, explanation, replacement, category text, model output, HTML, or derived semantic ARIA state; +- diagnostics with category codes such as `spelling`, `grammar`, `mechanics`, multilingual equivalents, or attacker-controlled lookalikes receive identical semantic-neutral decoration handling; +- the accessible panel exposes host strings as text and remains usable without visual-only cues; +- Inkspan produces no diagnostic when the host supplies none. + +## Rollback or supersession + +Rollback removes the optional diagnostic surface without altering canonical document envelopes, revisions, persistence records, collaboration data, or host storage. The host simply stops supplying diagnostics and the editor retains its ordinary deterministic behavior. + +A future diagnostic-continuity design may supersede strict invalidation only with a versioned evidence model that proves target identity across changes, defines standalone/collaborative parity, contains privacy and accessibility semantics, and passes cross-engine and realistic semantic-integrity validation. A future semantic ARIA field likewise requires an explicit typed contract rather than inference from opaque strings. From ba37c4dfd20e0b63ba14759e8b58c7a242cc9daf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:59:01 +0900 Subject: [PATCH 18/21] docs(plan): inherit strict diagnostics v1 errata --- ...-writing-diagnostics-v1-contract-errata.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-12-writing-diagnostics-v1-contract-errata.md diff --git a/docs/superpowers/plans/2026-08-12-writing-diagnostics-v1-contract-errata.md b/docs/superpowers/plans/2026-08-12-writing-diagnostics-v1-contract-errata.md new file mode 100644 index 00000000..a377f872 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-writing-diagnostics-v1-contract-errata.md @@ -0,0 +1,71 @@ +# Writing Diagnostics v1 Implementation Plan Errata + +Status: Required companion to `2026-08-12-writing-diagnostics-implementation.md` + +ADR 0028 resolves two contradictions discovered after the original atomic implementation plan was written. Every remaining task and acceptance review must apply this errata. Task 10 must fold these corrections into the canonical ADR, design, plan, PRD, TRD, contracts, threat model, operability, traceability, and CHANGELOG so the final protected-main documentation has no parallel instruction set. + +## Global lifecycle correction + +The following rule replaces every original-plan or design clause that permits transaction-local preservation, mapping, remapping, repair, or re-admission of an existing diagnostic: + +> Any local or collaborative transaction with `docChanged === true` invalidates the complete active diagnostic generation. Version 1 never preserves or remaps a diagnostic across changed document content. A host must submit a new set bound to the new exact revision. + +ProseMirror mapping may be used internally for ordinary editor behavior, but it is not evidence that a model judgment still targets the same meaning and is not diagnostic admission authority. + +## Task 3 correction: decoration attributes + +Replace the proposed attribute set with: + +```text +class="cwl-writing-diagnostic cwl-writing-diagnostic--{priority}" +data-cwl-diagnostic-id="opaque-id" +``` + +Do not add `aria-invalid`, spelling/grammar state, category semantics, title, explanation, replacement, confidence, provenance, model output, or HTML to decoration attributes. `categoryCode` remains opaque; it cannot be matched against words such as `spelling`, `grammar`, or `mechanics` to derive behavior or ARIA state. + +Task 3 tests must include attacker-controlled, multilingual, and lookalike category codes and prove identical semantic-neutral decoration handling. + +## Task 4 correction: controller state + +The controller's transaction subscriber invalidates both `verifying` and `active` generations before stale asynchronous work can publish. It does not map verified ranges after any document change. All old digest/selector completions are generation-fenced and discarded. + +## Task 5 correction: accessible panel + +The named panel is the semantic accessibility surface. It exposes category, priority, title, explanation, count, ordered position, affected-range navigation, and explicit Apply/Ignore/Dismiss/Explain actions as React text and native controls. + +- Do not infer semantic ARIA state from `categoryCode` or any other host text. +- Do not place selected source text in action names or attributes. +- New asynchronous diagnostics do not move focus. +- Previous/next navigation is explicit and roving; no undocumented global shortcut is added. +- Ordinary action completion uses a polite status region. +- An assertive alert is reserved for an actual application conflict. +- Information remains available without color, hover, pointer input, animation, or generated CSS content. + +## Task 6 correction: application + +Apply rechecks the exact current document revision immediately before the ordinary ProseMirror transaction. A stale generation cannot be rescued through range mapping or text search. A successful application invalidates all remaining diagnostics and produces a newly derived resulting revision. + +## Task 7 correction: collaboration + +Every remote Yjs document change invalidates the complete local diagnostic generation. A relative position or mapped ProseMirror position is not proof that the host judgment remains semantically current. Awareness payloads never carry diagnostics or review state. + +## Task 9 correction: assurance + +Cross-engine and hostile-input evidence must prove: + +- strict invalidation after every local or remote document change; +- no nearest-text, quote, keyword, category, or transaction-mapping recovery; +- no semantic ARIA derivation from opaque host fields; +- accessible information remains present through the panel and native actions; +- no diagnostics are produced when the host supplies none. + +## Task 10 reconciliation requirement + +Before the feature stack can become Ready, the original ADR 0027 and design/plan prose must be edited so they directly express ADR 0028. This errata is temporary planning evidence, not the desired final duplicate source of truth. The final documentation contract tests must fail if either of the superseded claims reappears: + +1. a diagnostic can survive `docChanged` through transaction mapping; or +2. Inkspan derives `aria-invalid` or other semantic state from an opaque category string. + +## Acceptance impact + +Existing Task 1–4 runtime direction is compatible with this correction. Downstream Task 5–12 branches must be based on exact predecessor heads that contain or explicitly consume this errata, and all exact-head CI/review evidence must be regenerated after any affected branch is refreshed. From 525400d94fd4499d48e6f3425ec564fea10b8e7a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 22:35:27 +0900 Subject: [PATCH 19/21] docs(diagnostics): reconcile contract with current design --- ...0027-host-owned-llm-writing-diagnostics.md | 191 ++++--- docs/adr/README.md | 1 + ...8-12-writing-diagnostics-implementation.md | 532 ++++-------------- ...on-bound-llm-writing-diagnostics-design.md | 393 ++++++------- src/adrQualityContract.test.ts | 21 + ...ngDiagnosticsDocumentationContract.test.ts | 105 ++++ 6 files changed, 526 insertions(+), 717 deletions(-) create mode 100644 src/writingDiagnosticsDocumentationContract.test.ts diff --git a/docs/adr/0027-host-owned-llm-writing-diagnostics.md b/docs/adr/0027-host-owned-llm-writing-diagnostics.md index 05d9e0f4..25e98da8 100644 --- a/docs/adr/0027-host-owned-llm-writing-diagnostics.md +++ b/docs/adr/0027-host-owned-llm-writing-diagnostics.md @@ -1,152 +1,165 @@ -# ADR 0027: Host-owned, revision-bound LLM writing diagnostics +# ADR 0027: Host-owned, revision-bound writing diagnostics Status: Proposed ## Context -Inkspan already provides deterministic authoring, revision evidence, revision-scoped W3C `TextPositionSelector` evidence, guarded restore, and a host-owned model-assistance boundary. Host applications now need a Grammarly-like writing-guidance surface that can underline a passage, explain a problem, propose a replacement, let the author apply or ignore it, and keep every suggestion bound to the exact document revision from which it was produced. +Inkspan already provides deterministic authoring, revision evidence, revision-scoped W3C `TextPositionSelector` evidence, guarded restore, and a host-owned model-assistance boundary. Host applications need a Grammarly-like writing-guidance surface that can mark a passage, explain a concern, propose a replacement, let the author act explicitly, and keep every proposal bound to the exact document revision reviewed by the host. -The requested quality judgments include spelling, grammar, clarity, concision, discourse structure, workplace pragmatics, audience appropriateness, technical precision, actionability, and preservation of the author's intended request. These are contextual language judgments. Fixed keyword lists, regular-expression phrase detectors, domain-suffix lists, hand-written “aggressive phrase” tables, and positional repair rules cannot establish those meanings and create brittle false positives and false negatives across paraphrases, quotations, languages, and recipient contexts. +Spelling, grammar, clarity, concision, discourse structure, workplace pragmatics, audience appropriateness, technical precision, actionability, and preservation of author intent are contextual language judgments. Fixed keyword lists, regular-expression phrase detectors, domain-suffix lists, hand-written phrase tables, opaque category names, and positional repair rules cannot establish those meanings across paraphrases, quotations, languages, and recipient contexts. -Inkspan must remain a modular editor. It must not gain email semantics, tenant policy, model credentials, network transport, an LLM provider dependency, or authority to decide whether a sentence is appropriate. At the same time, hosts should not have to scrape ProseMirror DOM nodes or maintain a private editor fork to display revision-safe diagnostics. +Inkspan must remain a modular editor. It must not gain email semantics, tenant policy, model credentials, network transport, an LLM provider dependency, persistence authority, or authority to decide whether prose is appropriate. Hosts likewise should not have to scrape ProseMirror DOM nodes or maintain private editor forks to display revision-safe guidance. ## Alternatives considered -- **Host-specific DOM overlays over Inkspan.** Rejected because DOM offsets are not a supported document contract, become stale after ProseMirror transactions, fragment keyboard and screen-reader behavior, and force every host to rebuild decorations, navigation, application, conflict handling, and undo semantics. -- **A deterministic keyword or regex checker inside Inkspan.** Rejected because lexical triggers are not evidence of grammar, intent, tone, pragmatics, or technical correctness. Deterministic code may validate data shape and document coordinates, but it may not manufacture semantic judgments. -- **Inkspan invokes an LLM directly.** Rejected because the editor would acquire provider, credential, network, privacy, retention, availability, model-routing, and tenant-policy responsibilities that belong to the host. -- **The host sends whole-document rewrites and calls `setValue`.** Rejected because whole-document replacement obscures individual reasons, weakens author control, destroys revision-local review evidence, and makes accidental intent changes harder to detect. -- **Host-owned LLM judgments rendered through a generic Inkspan diagnostic contract.** Selected because it preserves Inkspan's provider-neutral deterministic core while giving every host one revision-safe, accessible writing-assistance surface. +- **Host-specific DOM overlays.** Rejected because DOM offsets are not a supported document contract, become stale after transactions, fragment keyboard/screen-reader behavior, and force each host to rebuild navigation, application, conflict handling, and undo semantics. +- **A deterministic keyword or regex checker inside Inkspan.** Rejected because lexical triggers are not evidence of grammar, intent, tone, pragmatics, or technical correctness. Deterministic code may validate shape and coordinates but may not manufacture semantic judgments. +- **Inkspan invokes an LLM directly.** Rejected because the editor would acquire provider, credential, network, privacy, retention, availability, routing, and tenant-policy responsibilities that belong to the host. +- **The host sends whole-document rewrites.** Rejected because replacement obscures individual reasons, weakens author control, destroys revision-local evidence, and makes unintended meaning changes harder to detect. +- **Preserve diagnostics by mapping ranges across edits.** Rejected for v1 because structural position continuity does not prove that a host semantic judgment remains current after content changes. +- **Host-owned judgments through a generic Inkspan diagnostic contract.** Selected because it preserves Inkspan's provider-neutral deterministic core while giving every host one revision-safe, accessible presentation and action surface. ## Decision -Inkspan will expose a generic, additive writing-diagnostic presentation and application contract. The host supplies already-produced diagnostic proposals. Inkspan validates, anchors, renders, navigates, applies, ignores, and reports actions on those proposals, but does not decide whether the prose is correct or appropriate. +Inkspan will expose an additive writing-diagnostic contract. The host supplies already-produced proposals. Inkspan validates, revision-binds, resolves, renders, navigates, applies one selected replacement, ignores, dismisses, requests an explanation, and reports privacy-minimized action evidence. Inkspan does not decide whether prose is correct or appropriate. -A diagnostic must include, at minimum: +A v1 diagnostic includes: -- a bounded opaque `diagnostic_id`; -- the exact Inkspan strong document revision used by the host's review operation; -- the declared text-projection identity and version; -- a revision-scoped W3C `TextPositionSelector` with inclusive `start` and exclusive `end` Unicode-code-point offsets; -- a bounded host-defined `category_code` and display-safe title; -- an explanation; -- an optional proposed replacement; -- an optional bounded confidence value; -- provider/workflow provenance identifiers that contain no source text or credential; -- a host policy or judge-policy version identifying how the proposal was admitted. +- a bounded opaque `diagnosticId`; +- an exact `CwlEditorDocumentRevision` for the reviewed immutable snapshot; +- an exact `CwlEditorTextProjectionIdentity`; +- a W3C `TextPositionSelector` using inclusive `start` and exclusive `end` Unicode-code-point offsets; +- an opaque bounded `categoryCode`; +- one of `advisory`, `important`, or `critical` presentation priority; +- a plain-text title and explanation; +- an optional plain-text replacement; +- optional finite confidence in `[0, 1]`; and +- privacy-minimized workflow/judge-policy provenance identifiers with no source text, credential, or raw model output. -Inkspan may perform only deterministic validation and document operations: +Selector values are non-negative safe integers with `start <= end`. Collapsed selectors are valid evidence and remain navigable, but create no inline range decoration. -- schema, type, enum, length, count, duplicate-id, and resource-bound validation; -- projection-version and strong-revision equality checks; +Inkspan performs only deterministic validation and editor operations: + +- exact schema/type/enum/length/count/duplicate/resource validation; +- rejection of accessors, prototypes, symbols, extra fields, sparse arrays, and hostile reflection; +- projection-version and strong-revision checks; - Unicode-code-point and grapheme-boundary checks; -- selector range checks against the exact projected text; -- safe text/markup handling under the existing editor security policy; -- decoration mapping through local ProseMirror transactions when that mapping remains valid; -- stale-result invalidation when the declared revision or projection no longer matches; -- overlap/conflict detection between proposed replacements; -- one-action-at-a-time replacement, ordinary editor undo, and action callbacks. +- exact selector resolution against one immutable projected snapshot; +- safe plain-text rendering and replacement handling; +- stale-generation rejection; +- one-action-at-a-time replacement, ordinary undo, and privacy-minimized action callbacks. + +Every local or collaborative transaction with docChanged === true invalidates the complete active diagnostic generation. -Inkspan must not infer a diagnostic from keywords, regexes, phrase lists, sender domains, recipient counts, language names, or word position. Such mechanisms may validate identifiers or transport contracts, but they may not be used as a semantic fallback. If the host's model path is unavailable or returns no admitted diagnostics, Inkspan displays no fabricated judgment. +No diagnostic is preserved, mapped, remapped, repaired, or re-admitted through ProseMirror mapping, Yjs relative positions, nearest-text search, quote search, keyword search, or semantic guessing. A host that wants current guidance after an edit supplies a new set bound to a newly derived exact revision. -Applying a diagnostic is an explicit author action. A replacement is applied only if the current document still matches the diagnostic's expected revision or if Inkspan can prove a valid transaction-local mapping under the published lifecycle contract. A stale or ambiguous diagnostic never mutates the document. It returns a typed conflict or invalidation result so the host can request a fresh review. +Version 1 applies exactly one explicitly selected diagnostic at a time. Immediately before mutation, Inkspan derives and compares the exact current revision again. A stale, missing, invalid, or ambiguous proposal never mutates the document. Successful application uses one ordinary ProseMirror transaction, normal undo history, a resulting revision derived from the post-transaction document, and complete invalidation of the remaining generation. -Diagnostics are advisory. Inkspan does not block form submission, email sending, persistence, or export merely because diagnostics remain. A host may implement a separate product policy, but that policy is outside the editor package and cannot be inferred from Inkspan diagnostic priority or confidence. +Inkspan does not derive aria-invalid or any other semantic accessibility state from opaque host strings. Inline decorations contain static Inkspan classes, priority styling, and an opaque identifier only. Category, priority, title, explanation, and actions are available as text and native controls in the named diagnostics panel. + +Diagnostics are advisory. Inkspan does not block form submission, sending, persistence, export, or collaboration merely because diagnostics remain. A host may implement separate product policy outside the editor package. ## Ownership boundary Inkspan owns: -- document state and serialization; -- revision and selector evidence; -- diagnostic schema validation; -- decorations and accessible suggestion navigation; -- explicit apply/ignore/dismiss actions; -- stale-result and overlap conflict handling; -- ordinary document undo and focus restoration; -- privacy-minimized action callbacks. +- deterministic document state and serialization; +- revision/projection/selector validation; +- strict diagnostic schema and resource validation; +- semantic-neutral decorations and accessible diagnostic navigation; +- explicit Focus, Apply, Ignore, Dismiss, and Explain actions; +- strict stale-generation invalidation; +- ordinary document transactions, undo, and predictable focus behavior; +- privacy-minimized action events and redacted errors. The host owns: -- model selection and orchestration; -- prompts, rubrics, examples, and language policy; -- source email, thread, recipient, role, and organization context; +- model selection, orchestration, prompts, rubrics, examples, and language policy; +- source email/thread/recipient/role/organization context; - semantic categories, confidence calibration, acceptance policy, and abstention; - provider credentials and data-processing approval; -- diagnostic persistence and retention; -- feedback collection, evaluation, monitoring, and human escalation; -- any send, save, or compliance gate. +- diagnostic persistence, retention, evaluation, monitoring, and escalation; +- any send, save, compliance, or submission gate. ## Consequences -A host can provide inline writing guidance without forking the editor. Inkspan remains usable offline and when every model provider is unavailable. The public contract becomes broader and therefore requires packed-package, standalone, collaborative, SSR, accessibility, and cross-engine evidence. Hosts must operate a real review service and cannot treat the editor as an evaluator. +A host can provide inline writing guidance without forking the editor. Inkspan remains usable offline and when every model provider is unavailable. The public contract becomes broader and requires packed-package, standalone, collaborative, SSR, accessibility, concurrency, hostile-input, and cross-engine evidence. + +The design separates semantic authority from deterministic integrity. A host model may be wrong about prose; Inkspan can still prove that the proposal was admitted for one exact snapshot, was not moved onto changed content, and was not applied after the document changed. -The decision intentionally separates semantic authority from deterministic integrity. A model or calibrated judge may be wrong about the prose; Inkspan can still guarantee that the proposal was not silently moved to an unrelated span or applied to a different revision. +Strict invalidation may require more host refreshes, including after unrelated edits. This cost is accepted because v1 cannot prove semantic continuity through structural mapping. ## Failure and recovery -- Missing, malformed, oversized, duplicate, unsupported-projection, or out-of-range diagnostics are rejected without document mutation. -- A provider timeout, quota error, malformed model result, or host policy abstention is represented by absence or a host-owned status outside the diagnostic list. Inkspan authoring remains available. -- A changed document invalidates stale diagnostics. Inkspan never “repairs” them by searching for keywords or selecting the nearest matching sentence. -- A safely mapped local transaction may preserve a diagnostic only when the mapping contract proves the selected range still denotes the intended content. Ambiguity invalidates it. -- Overlapping replacements are applied separately and revalidated after every mutation. “Apply all” is permitted only for an explicitly validated non-overlapping batch under one current revision. -- A collaborative remote edit follows the same invalidation rule; raw local positions are not durable Yjs anchors. +- Missing, malformed, oversized, duplicate, unsupported-projection, or invalid-selector input is rejected without document mutation. +- Provider timeout, quota failure, malformed model result, or policy abstention remains host-owned absence/status; Inkspan authoring stays available. +- Any local or remote document change invalidates the complete generation before further action authority exists. +- Async digest/selector work from an invalidated generation is discarded. +- Apply/Ignore/Dismiss/Explain against a missing or stale generation returns typed non-mutating evidence. +- Host callback exceptions are contained and cannot corrupt editor state. +- Recovery is a newly supplied diagnostic set for the current exact revision. Inkspan never fabricates a fallback judgment. ## Security and privacy impact -Diagnostics and replacements are untrusted host-controlled input. They pass through the same safe-link, inline-image, clipboard, schema, and transaction boundaries as other editor input. A diagnostic must not contain model credentials, raw provider request/response bodies, tenant identifiers not needed by the component, or hidden executable markup. +Diagnostics and replacements are untrusted host-controlled input. The exact data-property boundary rejects executable or ambiguous object shapes. Host strings render as text; v1 accepts no HTML, JavaScript, command, arbitrary TipTap JSON, arbitrary transaction, or embedded host callback. -The public diagnostic contract does not require copying the selected source text. Position selectors remain revision-scoped and privacy-minimized. Hosts that add quote selectors, store explanations, or transmit the document to a model own authorization, encryption, provider data-use terms, retention, audit, and regional-processing controls. +The contract does not require copying selected source text. Revision and selector evidence is privacy-minimized. Hosts transmitting documents to a model own authorization, encryption, provider data-use terms, retention, audit, regional processing, and consent. -Generic telemetry may record bounded category, action, conflict reason, latency bucket, and policy version. It must not record authored source text, suggested replacement text, full explanations, prompts, raw model output, or document envelopes by default. +Default action events may contain only opaque identifier, exact revision evidence, opaque category, generation, action, and bounded reason code. They do not contain authored source text, replacement text, explanation, prompt, raw model output, document envelope, credential, email recipient, or tenant identifier. + +Opaque values that happen to contain words such as `spelling`, `grammar`, `mechanics`, `rude`, `incorrect`, or multilingual equivalents gain no semantic behavior or ARIA authority. ## Accessibility -Diagnostics must be available without relying on color or hover. The eventual implementation must provide: +Diagnostics are available without relying on color, hover, pointer input, animation, or generated CSS content. The built-in panel provides: -- a keyboard-reachable diagnostics summary; -- previous/next diagnostic navigation; -- an accessible name for category and affected passage; -- predictable focus movement between editor range and suggestion card; -- explicit Apply, Ignore, Dismiss, and Explain actions; -- polite status announcements after application or invalidation; -- no focus theft while new asynchronous diagnostics arrive; -- equivalent behavior in standalone and collaborative editors. +- a named region, count, and ordered list; +- category, priority, title, and explanation as text; +- previous/next navigation and explicit affected-range focus; +- native Apply, Ignore, Dismiss, and Explain actions; +- disabled Apply when no replacement exists; +- polite status after ordinary actions; +- an assertive alert only for an actual application conflict; +- stable focus when async diagnostics arrive; and +- standalone/collaborative parity. -Underlines are a visual supplement, not the sole information channel. +Underlines are a visual supplement, not the semantic accessibility surface. ## Compatibility and migration -The feature is additive. Existing hosts that do not pass diagnostics retain identical editor, serialization, form, persistence, and collaboration behavior. The diagnostic contract must remain optional and provider-neutral. It may be released only in a version whose package declarations, ESM/CJS outputs, React peer ranges, styles, and consumer verification expose the same contract. +The feature is additive. Hosts that omit diagnostics retain identical editor, serialization, form, persistence, export, and collaboration behavior. The diagnostic contract remains optional and provider-neutral. + +No canonical document-envelope or database migration is required because diagnostics are noncanonical review state. A future explicit semantic accessibility field or cross-edit continuity model requires a new versioned type, compatibility plan, and ADR; v1 opaque fields gain no retrospective meaning. -No canonical document-envelope schema change is required. Diagnostics are review state, not canonical document content. Rollback removes the diagnostic props, decorations, and action surface without document migration. +The feature may be released only when root and framework-neutral subpaths, ESM/CommonJS/types/CSS, React peer compatibility, package consumers, SSR, browser evidence, and rollback contracts agree. ## Verification Acceptance requires tests proving: -- strict resource-bounded schema validation and duplicate rejection; -- Unicode astral characters, Korean/CJK text, combining marks, emoji, bidirectional text, and grapheme-boundary behavior; -- exact revision/projection binding; -- stale-result rejection without mutation; -- transaction mapping only when meaning-preserving range identity is provable; -- overlapping replacement conflict behavior; -- single and bounded batch application plus undo; -- safe rendering of hostile titles, explanations, and replacements; -- keyboard, focus, live-region, and screen-reader semantics; -- standalone/collaborative parity and remote-edit invalidation; -- SSR-safe initial shell and hydration; -- packed ESM/CommonJS/types/CSS consumer compatibility; -- no model SDK, credential, environment, database, or network dependency in the Inkspan package; -- no source text or replacement text in default telemetry; -- exact 100% production statement, branch, function, and line coverage and complete public API documentation. - -Contract tests must also prohibit semantic keyword fallback: adversarial fixtures with identical keywords but different meanings, and paraphrases with different words but the same issue, must prove that Inkspan itself produces no judgment. It renders only host-supplied diagnostics. +- strict exact-field/resource validation, duplicate rejection, and hostile-reflection containment; +- Unicode astral, Korean/CJK, combining, emoji, bidirectional, grapheme, empty, and collapsed selector behavior; +- exact revision/projection binding and one-snapshot selector resolution; +- local and Yjs remote `docChanged` invalidation; +- no transaction mapping, nearest-text, quote, keyword, or semantic repair; +- no semantic ARIA derivation from opaque host fields; +- one selected replacement only, exact pre-mutation recheck, ordinary undo, and resulting revision; +- safe text rendering of hostile titles, explanations, categories, identifiers, and replacements; +- keyboard/focus/status/alert/non-color accessibility; +- standalone/collaborative parity and no awareness publication; +- SSR/hydration and packed ESM/CommonJS/types/CSS compatibility; +- no model SDK, credential, environment, database, filesystem, or network dependency in the package; +- no authored/model text in default action evidence or telemetry; +- exact 100% owned production statement, branch, function, and line coverage plus complete public documentation. + +Contrast fixtures with identical keywords but different meanings, and paraphrases with different words but the same issue, must prove that Inkspan produces zero diagnostics unless the host supplies them. ## Research and standards traceability -This decision uses the W3C Web Annotation Data Model's Unicode-code-point `TextPositionSelector` semantics together with Inkspan's stronger revision binding. It treats LLM judgments as fallible measurement outputs rather than deterministic truth, consistent with published findings on position, verbosity, self-preference, artifact, multilingual, and consistency biases in LLM evaluators. The accompanying design and doctoring records contain APA 7th citations and the host-side calibration implications. +The decision uses W3C Web Annotation Data Model Unicode-code-point `TextPositionSelector` semantics together with stronger exact revision binding. It uses ProseMirror/TipTap immutable state and transaction contracts, RFC 9110 strong entity-tag semantics, and the existing Inkspan deterministic-versus-model-assisted authoring boundary. LLM judgments remain fallible host evidence rather than editor truth; the companion doctoring record contains APA 7 references and calibration implications. ## Rollback or supersession -Rollback removes the optional diagnostic surface while preserving canonical documents, revision evidence, selection evidence, and deterministic authoring. Supersession requires a new ADR if Inkspan is ever proposed to own model invocation, semantic classification, persistence authority, or submission policy. Such a change must provide explicit provider neutrality, privacy, offline/degraded operation, accessibility, compatibility, migration, and rollback evidence. \ No newline at end of file +Rollback removes optional diagnostic props, controller state, decorations, panel, styles, and action APIs while preserving canonical documents, revisions, persistence, and collaboration data. + +Supersession requires a new ADR if Inkspan is proposed to own model invocation, semantic classification, persistence authority, submission policy, semantic ARIA inference, cross-edit diagnostic continuity, or batch application. Such a change must provide provider neutrality, privacy, offline/degraded operation, accessibility, compatibility, migration, recovery, and rollback evidence. diff --git a/docs/adr/README.md b/docs/adr/README.md index 3b73b384..8c314016 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -33,6 +33,7 @@ This index records durable architectural decisions. An ADR on a branch or protec | [0025](0025-bounded-docx-heading-alignment.md) | Accepted | Bounded heading alignment in deterministic DOCX output | | [0026](0026-bounded-docx-external-hyperlinks.md) | Accepted | Bounded external hyperlinks in deterministic DOCX rich text | | [0027](0027-host-owned-llm-writing-diagnostics.md) | Proposed | Host-owned, revision-bound LLM writing diagnostics | +| [0028](0028-writing-diagnostics-v1-strict-invalidation.md) | Proposed | Strict writing-diagnostic invalidation and semantic-neutral accessibility | ## Decision discipline diff --git a/docs/superpowers/plans/2026-08-12-writing-diagnostics-implementation.md b/docs/superpowers/plans/2026-08-12-writing-diagnostics-implementation.md index 27517d31..15b686e8 100644 --- a/docs/superpowers/plans/2026-08-12-writing-diagnostics-implementation.md +++ b/docs/superpowers/plans/2026-08-12-writing-diagnostics-implementation.md @@ -1,458 +1,162 @@ # Revision-Bound Writing Diagnostics Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a generic, provider-neutral Inkspan surface that validates, displays, navigates, applies, ignores, dismisses, and invalidates host-supplied writing diagnostics without making any semantic judgment itself. - -**Architecture:** A React-free `writing-diagnostics` contract validates bounded host input and resolves revision-scoped W3C text selectors against Inkspan's canonical text projection. A ProseMirror extension renders verified ranges and clears them on every document-changing transaction. A shared React controller binds asynchronous revision verification to one editor generation, while a built-in accessible panel exposes explicit actions. Standalone and collaborative editors reuse the same contract, controller, extension, and action result types. Version 1 accepts plain-text replacements only and never searches for similar text after a revision mismatch. - -**Tech Stack:** TypeScript, React 18/19, TipTap/ProseMirror, Yjs collaboration, Web Crypto revision evidence, W3C `TextPositionSelector`, Vitest with jsdom, Playwright across Chromium/Firefox/WebKit, Vite package subpath builds, pnpm, and the existing exact-head coverage/package/release gates. - -## Global Constraints - -- Inkspan does not call an LLM, provider, network service, database, storage service, or host API. -- Inkspan never infers grammar, spelling, tone, clarity, pragmatics, technical quality, or actionability from text. -- Keywords, regular expressions, phrase dictionaries, sender domains, language names, recipient counts, nearest-text search, and word positions are prohibited as semantic fallback or stale-selector repair. -- Version 1 replacements are plain text. HTML, commands, editor JSON, JavaScript, and arbitrary ProseMirror transactions are not accepted from a diagnostic. -- The canonical selector projection remains `inkspan-prosemirror-text` version `1`; a new projection requires a separate ADR and compatibility plan. -- Any document-changing local or collaborative transaction invalidates every active diagnostic before it can be applied. Version 1 does not retain or remap a diagnostic across a changed document. -- A diagnostic is actionable only after its declared strong revision, projection identity, selector, and grapheme boundaries have been verified against one exact editor snapshot. -- Asynchronous revision checks use generation tokens and never publish results for a replaced editor, destroyed editor, newer diagnostic set, or changed document. -- Default hard limits: - - 256 diagnostics per editor snapshot; - - 256 characters per `diagnosticId`; - - 128 characters per `categoryCode` and provenance identifier; - - 256 characters per title; - - 4,000 characters per explanation; - - 20,000 characters per replacement; - - confidence in the closed interval `[0, 1]`. -- Action callbacks and default telemetry-safe result objects contain opaque identifiers, revisions, category, action, bounded reason codes, and timing state only. They do not contain selected source text, replacement text, explanation, prompt, model output, document envelope, credential, or tenant identifier. -- Diagnostics remain advisory. Inkspan does not block form submission, persistence, export, or sending. -- Existing editor behavior is byte-for-byte and interaction-compatible when `writingDiagnostics` is absent. -- Production statement, branch, function, and line coverage remains exactly 100%. -- Public APIs and every shipped module/class/function receive beginner-readable documentation. -- Feature work remains under `Unreleased`; a separate exact-head release-only PR publishes the next compatible minor version after all acceptance gates pass. - ---- - -## Task 1: Define the React-Free Diagnostic Contract - -**Files:** -- Create: `src/writingDiagnostics.ts` -- Create: `src/writingDiagnostics.test.ts` -- Create: `src/writing-diagnostics/index.ts` -- Modify: `src/index.ts` - -- [ ] Write failing tests for valid diagnostics, empty arrays, duplicate IDs, unexpected fields, inherited fields, accessors, symbols, proxy exceptions, oversized arrays/strings, invalid confidence, unsupported priority, malformed revision, unsupported projection, invalid selector order, and non-string replacement values. -- [ ] Define the public v1 types: - -```ts -export type CwlWritingDiagnosticPriority = - | 'advisory' - | 'important' - | 'critical'; - -export interface CwlWritingDiagnostic { - readonly diagnosticId: string; - readonly documentRevision: CwlEditorDocumentRevision; - readonly textProjection: CwlEditorTextProjectionIdentity; - readonly selector: CwlEditorTextPositionSelector; - readonly categoryCode: string; - readonly priority: CwlWritingDiagnosticPriority; - readonly title: string; - readonly explanation: string; - readonly suggestedReplacement?: string; - readonly confidence?: number; - readonly provenance: Readonly<{ - workflowId: string; - workflowVersion: string; - judgePolicyVersion: string; - orchestrationMode?: string; - }>; -} -``` +> **For agentic workers:** Use the repository's test-driven, exact-head, single-writer workflow. Every task begins with a realistic failing regression, implements the smallest bounded production change, removes temporary branch-only workflows before integration, and regenerates all current-head evidence after ancestry changes. -- [ ] Define stable, redacted error codes and `WritingDiagnosticError` for contract, limit, revision, projection, selector, conflict, and lifecycle failures. -- [ ] Define frozen default limits and a strict `validateWritingDiagnostics(input, limits?)` function that returns a deeply detached, deeply frozen tuple. -- [ ] Validate only own data properties. Catch hostile object/proxy behavior and return a stable error without reflecting source values. -- [ ] Reject duplicate diagnostic IDs and reject any object whose exact field set differs from the v1 schema. -- [ ] Preserve diagnostic order from the host; do not sort by confidence, category, wording, or source position inside the validator. -- [ ] Export only the React-free contract, validator, constants, limits, and error types from `src/writing-diagnostics/index.ts`. -- [ ] Re-export the same contract from the root package for interactive consumers. -- [ ] Run: - -```bash -pnpm exec vitest run src/writingDiagnostics.test.ts -pnpm typecheck -``` +**Goal:** Add a generic, provider-neutral Inkspan surface that validates, displays, navigates, applies, ignores, dismisses, explains, and invalidates host-supplied writing diagnostics without making semantic judgments itself. -- [ ] Commit: +**Architecture:** A React-free contract validates bounded hostile input. A deterministic inverse text-projection resolver maps revision-scoped W3C selectors to one immutable ProseMirror snapshot. A semantic-neutral extension renders verified ranges. One shared React controller fences async verification by generation and invalidates the complete set after every document change. A built-in accessible panel exposes explicit actions. Standalone and collaborative editors reuse the same types and lifecycle. Version 1 accepts plain-text replacements, applies one explicitly selected diagnostic at a time, and never repairs stale selectors. -```bash -git add src/writingDiagnostics.ts src/writingDiagnostics.test.ts src/writing-diagnostics/index.ts src/index.ts -git commit -m "feat(diagnostics): add strict writing diagnostic contract" -``` +**Technology:** TypeScript, React 18/19, TipTap/ProseMirror, Yjs, Web Crypto revision evidence, W3C `TextPositionSelector`, Vitest/jsdom, Playwright Chromium/Firefox/WebKit, Vite package subpaths, pnpm, and existing exact-head coverage/security/package/release gates. -## Task 2: Add an Inverse Canonical Text-Projection Resolver - -**Files:** -- Create: `src/writingDiagnosticProjection.ts` -- Create: `src/writingDiagnosticProjection.test.ts` -- Modify: `src/textPositionSelectorEvidence.ts` -- Modify: `src/text-position-selector/index.ts` - -- [ ] Write failing tests that resolve selectors over paragraphs, headings, lists, tables, hard breaks, inline text, non-text leaf nodes, astral characters, Korean/CJK text, combining marks, emoji sequences, bidirectional text, empty blocks, and document boundaries. -- [ ] Add negative tests for unsupported projection versions, negative/non-integral offsets, reversed ranges, out-of-range offsets, grapheme-splitting boundaries, ambiguous projection boundaries, and runtimes without `Intl.Segmenter`. -- [ ] Implement a single-pass `buildTextProjectionMap(documentNode)` that emits the exact same text as `documentNode.textBetween(0, documentNode.content.size, '\n', '\uFFFC')` plus a boundary map between Unicode-code-point offsets and ProseMirror positions. -- [ ] Assert in tests that the independently built projection is exactly equal to the existing canonical projection for every fixture and generated document. -- [ ] Add: - -```ts -export function resolveTextPositionSelector( - documentNode: ProseMirrorNode, - selector: CwlEditorTextPositionSelector, - textProjection: CwlEditorTextProjectionIdentity, -): Readonly<{ from: number; to: number }>; -``` +## Global constraints -- [ ] Fail closed when a code-point boundary cannot map to one unambiguous ProseMirror position; never pick the nearest sentence, matching word, or repeated substring. -- [ ] Reuse one shared grapheme-boundary implementation for selector creation and resolution so forward and inverse paths cannot diverge. -- [ ] Add property tests that create a valid structural selection, convert it with `createTextPositionSelector()`, resolve it back, and recover the exact original range. -- [ ] Run: +- Inkspan calls no model, provider, network service, database, storage service, credential broker, or host API. +- Inkspan never infers spelling, grammar, tone, clarity, pragmatics, technical quality, or actionability from text or opaque host fields. +- Keywords, regexes, phrase dictionaries, language names, domains, recipient counts, nearest-text search, quote search, and word positions are prohibited as semantic fallback or stale-selector repair. +- Host strings are untrusted plain text. Version 1 accepts no diagnostic HTML, command, JavaScript, arbitrary TipTap JSON, arbitrary transaction, or callback. +- `inkspan-prosemirror-text` version 1 is the sole v1 selector projection. +- Selector offsets are non-negative safe integers with `start <= end`; collapsed selectors remain navigable but create no inline range decoration. +- Every local or collaborative `docChanged` transaction invalidates the complete active generation. Version 1 never maps or preserves a diagnostic across changed content. +- One immutable editor snapshot supplies the current revision and every selector resolution for one generation. +- Generation, mounted-state, and editor-identity guards prevent stale promises from installing decorations, emitting actions, or moving focus. +- Diagnostics remain advisory and never block form submission, sending, persistence, export, or collaboration. +- Existing behavior remains compatible when diagnostics are absent. +- Owned production statement, branch, function, and line coverage remains exactly 100%, with beginner-readable public documentation. +- The feature stays under `Unreleased`; publication uses a separate release-only PR. -```bash -pnpm exec vitest run src/textPositionSelectorEvidence.test.ts src/writingDiagnosticProjection.test.ts -pnpm typecheck -``` +## Task 1: React-free diagnostic contract -- [ ] Commit: +**Files:** `src/writingDiagnostics.ts`, `src/writingDiagnostics.test.ts`, `src/writing-diagnostics/index.ts`, `src/index.ts`. -```bash -git add src/textPositionSelectorEvidence.ts src/text-position-selector/index.ts src/writingDiagnosticProjection.ts src/writingDiagnosticProjection.test.ts -git commit -m "feat(diagnostics): resolve revision-scoped text selectors" -``` +- [ ] Add RED tests for valid/empty sets, exact fields, duplicate IDs, hostile arrays/objects/proxies/accessors/prototypes/symbols, resource ceilings, priorities, confidence, revision, projection, selectors, provenance, and replacement types. +- [ ] Define `advisory | important | critical`, structured `CwlEditorDocumentRevision`, structured projection identity, selector, opaque category, bounded host prose, optional plain-text replacement/confidence, and privacy-minimized provenance. +- [ ] Define stable redacted error codes for contract, limit, revision, projection, selector, conflict, and lifecycle failures. +- [ ] Validate exact own enumerable data properties and return deeply detached frozen values. +- [ ] Preserve host order; do not sort or infer semantics. +- [ ] Export identical root and React-free subpath contracts. +- [ ] Run focused tests and typecheck to GREEN. -## Task 3: Build the ProseMirror Decoration and Invalidation Extension +## Task 2: inverse canonical text-projection resolver -**Files:** -- Create: `src/extensions/WritingDiagnostics.ts` -- Create: `src/extensions/WritingDiagnostics.test.ts` -- Modify: `src/extensions/kit.ts` +**Files:** `src/writingDiagnosticProjection.ts`, its tests, `src/textPositionSelectorEvidence.ts`, `src/text-position-selector/index.ts`. -- [ ] Write failing extension-state tests for installing a verified diagnostic set, inline decoration attributes, collapsed/empty ranges, duplicate install generations, clear commands, editor destruction, and document-changing transactions. -- [ ] Define one plugin key and typed transaction metadata for `install`, `focus`, and `clear` operations. -- [ ] Render verified non-empty ranges with safe static attributes only: +- [ ] Add RED round-trip tests for paragraphs, headings, lists, tables, hard breaks, inline/block atoms, empty blocks, document boundaries, repeated text, astral/Korean/CJK/combining/emoji/bidirectional text, and collapsed ranges. +- [ ] Add negative tests for unsupported projection, unsafe/reversed/out-of-range offsets, grapheme splits, ambiguous structural boundaries, hostile metadata, and missing `Intl.Segmenter`. +- [ ] Build the exact `textBetween(0, size, '\n', '\uFFFC')` projection and one code-point-boundary-to-ProseMirror-position map. +- [ ] Reuse one grapheme-boundary implementation for forward and inverse paths. +- [ ] Fail closed rather than selecting nearest or similar text. +- [ ] Publish the deterministic resolver through the framework-neutral selector surface. -```text -class="cwl-writing-diagnostic cwl-writing-diagnostic--{priority}" -data-cwl-diagnostic-id="opaque-id" -aria-invalid="spelling" only when the host category explicitly maps to mechanics -``` +## Task 3: semantic-neutral ProseMirror decorations -- [ ] Do not inject title, explanation, replacement, category text, model output, or HTML into decoration attributes. -- [ ] Clear all decorations and focused-diagnostic state on every `transaction.docChanged`, including Yjs remote transactions. -- [ ] Do not map a diagnostic through a changed document in version 1. -- [ ] Expose typed helper commands that accept already-validated, already-resolved ranges; the extension itself must not hash documents or call host code. -- [ ] Add the extension exactly once through `buildExtensions()` for standalone and collaborative editor graphs. -- [ ] Prove by source and runtime tests that an editor with no diagnostics has no added visual output, action surface, or document mutation. -- [ ] Run: - -```bash -pnpm exec vitest run src/extensions/WritingDiagnostics.test.ts src/extensions/kit.test.ts -pnpm typecheck -``` +**Files:** `src/extensions/WritingDiagnostics.ts`, its tests, `src/extensions/kit.ts`. -- [ ] Commit: +- [ ] Add RED tests for install/focus/clear metadata, monotonic generation, exact ranges, collapsed ranges, malformed metadata, duplicate IDs, resource ceilings, editor destruction, and local/remote document changes. +- [ ] Render non-empty ranges with only: -```bash -git add src/extensions/WritingDiagnostics.ts src/extensions/WritingDiagnostics.test.ts src/extensions/kit.ts -git commit -m "feat(diagnostics): add fail-closed editor decorations" +```text +class="cwl-writing-diagnostic cwl-writing-diagnostic--{priority}" +data-cwl-diagnostic-id="opaque-id" ``` -## Task 4: Implement the Revision-Bound Controller State Machine +- [ ] Reject all extra semantic fields and never derive spelling/grammar validity or other semantic ARIA state from host strings. +- [ ] Clear the complete generation before processing metadata on every `docChanged` transaction. +- [ ] Accept only already-validated structural ranges; perform no hashing or host callback. +- [ ] Install the extension exactly once for standalone and collaborative editor graphs. -**Files:** -- Create: `src/components/useWritingDiagnosticsController.ts` -- Create: `src/components/useWritingDiagnosticsController.test.tsx` -- Modify: `src/types.ts` +## Task 4: revision-bound controller -- [ ] Write failing hook tests for initial verification, replacement diagnostic props, same-array identity with mutated hostile members, editor replacement, editor destruction, revision mismatch, projection mismatch, verification rejection, document change during hashing, overlapping asynchronous requests, and callback replacement without editor recreation. -- [ ] Define controller states: - -```text -absent -> verifying -> active - -> invalid -active -> applying -> applied -active -> ignored | dismissed | explanation_requested -active -> stale on any document change -``` +**Files:** `src/components/useWritingDiagnosticsController.ts`, its tests, `src/types.ts`. -- [ ] Add public action/result types with stable reason codes and no authored text: +- [ ] Add RED tests for absent/invalid/verifying/active/stale states, editor and callback replacement, hostile prop mutation, revision/projection mismatch, selector rejection, doc changes during hashing, concurrent promises, unmount, and callback exceptions. +- [ ] Validate the complete set before reading editor state. +- [ ] Capture one immutable envelope, derive one revision, resolve all selectors against that same snapshot, and install atomically. +- [ ] Fence every async continuation with monotonic generation, mounted state, and editor identity. +- [ ] Invalidate both verifying and active generations immediately after any local/remote document change; do not map ranges. +- [ ] Expose privacy-minimized Focus/Ignore/Dismiss/Explain controller actions and typed reason codes. -```ts -export type CwlWritingDiagnosticAction = - | 'applied' - | 'ignored' - | 'dismissed' - | 'requested_explanation' - | 'stale' - | 'conflict'; -``` +## Task 5: accessible diagnostics panel -- [ ] Validate diagnostics before reading the editor. -- [ ] Capture one immutable document envelope and derive one strong revision from that same snapshot before resolving any selector. -- [ ] Compare the declared revision and projection exactly; no normalization or compatibility guess is permitted. -- [ ] Resolve all ranges against the same immutable snapshot and reject the complete set atomically if structural validation fails. -- [ ] Treat overlapping diagnostics as displayable but prevent overlapping replacements from being batch-applied. Version 1 exposes single application only. -- [ ] Subscribe to editor transactions and immediately invalidate the active generation before scheduling any new verification. -- [ ] Use monotonic generation IDs and mounted/editor identity guards so older promises cannot install decorations, emit actions, or change focus. -- [ ] Contain host callback exceptions and keep editor state deterministic. -- [ ] Run: - -```bash -pnpm exec vitest run src/components/useWritingDiagnosticsController.test.tsx -pnpm typecheck -``` +**Files:** `src/components/WritingDiagnosticsPanel.tsx`, its tests, `src/components/EditorFrame.tsx`, `src/styles.css`, print-style tests. -- [ ] Commit: +- [ ] Add RED accessibility tests for a named region, count, ordered items, category, priority, title, explanation, affected-range focus, previous/next navigation, Apply/Ignore/Dismiss/Explain, disabled Apply without replacement, polite status, and assertive conflict alert. +- [ ] Prove asynchronous arrival does not steal focus and explicit navigation uses roving focus. +- [ ] Render every host string as a React text node; never use raw HTML. +- [ ] Keep selected source text out of action names and attributes. +- [ ] Add forced-colors, high-contrast, reduced-motion, focus-visible, touch-target, zoom, and print behavior. Default print remains document-only; an explicit host option may include a bounded appendix. +- [ ] Add no undocumented global shortcut. -```bash -git add src/components/useWritingDiagnosticsController.ts src/components/useWritingDiagnosticsController.test.tsx src/types.ts -git commit -m "feat(diagnostics): bind diagnostics to exact editor revisions" -``` +## Task 6: standalone editor actions -## Task 5: Add Accessible Diagnostic Navigation and Action UI - -**Files:** -- Create: `src/components/WritingDiagnosticsPanel.tsx` -- Create: `src/components/WritingDiagnosticsPanel.test.tsx` -- Modify: `src/components/EditorFrame.tsx` -- Modify: `src/styles.css` - -- [ ] Write failing accessibility tests for a named region, count summary, ordered diagnostic list, category/priority/title/explanation, affected-range focus, previous/next navigation, Apply/Ignore/Dismiss/Explain actions, live status, and disabled application when no replacement exists. -- [ ] Add tests proving that asynchronous diagnostic arrival does not move focus and that explicit navigation returns focus predictably between the editor range and panel card. -- [ ] Add tests proving information remains available without color, hover, pointer input, animation, or generated CSS content. -- [ ] Render host strings as React text nodes only. Never use `dangerouslySetInnerHTML`. -- [ ] Give every action an explicit accessible name that includes the diagnostic title but does not copy the selected source passage into an attribute. -- [ ] Use buttons for previous/next navigation and roving focus within the list; do not add undocumented global shortcuts in version 1. -- [ ] Add one polite live region for completed actions and one assertive alert only for an application conflict. -- [ ] Add priority-specific underline styles plus forced-colors, high-contrast, reduced-motion, print, touch-target, and focus-visible rules. -- [ ] In print, omit action buttons and include a compact diagnostic appendix only when the host explicitly enables `printWritingDiagnostics`; default print output remains document-only. -- [ ] Add `writingDiagnosticsPanel?: ReactNode` support to `EditorFrame` only as an internally constructed trusted component slot; hosts do not inject raw diagnostic markup. -- [ ] Run: - -```bash -pnpm exec vitest run src/components/WritingDiagnosticsPanel.test.tsx src/components/EditorFrame.test.tsx src/printStyles.test.ts -pnpm typecheck -``` +**Files:** `src/types.ts`, `src/components/CwlEditor.tsx`, `src/components/useEditorHandle.ts`, integration/accessibility/handle tests. -- [ ] Commit: +- [ ] Add RED tests for optional props, hostile/valid/stale sets, explicit actions, clear, read-only/disabled modes, form submission, undo, callback replacement, and unmount. +- [ ] Preserve the raw diagnostics prop by identity until bounded controller validation. +- [ ] Add additive props for diagnostics, action/error callbacks, label, and optional print appendix. +- [ ] Add imperative Focus/Ignore/Dismiss/Explain/Apply methods through the same controller used by the panel. +- [ ] Immediately before Apply, derive and compare the exact current revision again. +- [ ] Apply one plain-text replacement through one ordinary ProseMirror transaction, derive the resulting revision, emit the complete event, and invalidate the remaining generation. +- [ ] Return typed non-mutating outcomes for stale/conflict/lifecycle cases. +- [ ] Prove source words alone generate no diagnostics. -```bash -git add src/components/WritingDiagnosticsPanel.tsx src/components/WritingDiagnosticsPanel.test.tsx src/components/EditorFrame.tsx src/styles.css -git commit -m "feat(diagnostics): add accessible writing guidance UI" -``` +## Task 7: collaborative parity -## Task 6: Integrate Standalone Editor Props and Imperative Actions - -**Files:** -- Modify: `src/types.ts` -- Modify: `src/components/CwlEditor.tsx` -- Modify: `src/components/useEditorHandle.ts` -- Modify: `src/components/CwlEditor.test.tsx` -- Modify: `src/components/CwlEditor.accessibility.test.tsx` -- Modify: `src/components/useEditorHandle.test.tsx` -- Create: `src/components/CwlEditor.writingDiagnostics.test.tsx` - -- [ ] Write failing integration tests for omitted props, valid diagnostics, invalid diagnostics, stale revisions, editor updates, Apply/Ignore/Dismiss/Explain, clear, undo, host callback replacement, read-only mode, disabled editor, form submission, and unmount. -- [ ] Add optional props: - -```ts -writingDiagnostics?: readonly CwlWritingDiagnostic[]; -onWritingDiagnosticAction?: (event: CwlWritingDiagnosticActionEvent) => void; -onWritingDiagnosticsError?: (error: WritingDiagnosticError) => void; -writingDiagnosticsLabel?: string; -printWritingDiagnostics?: boolean; -``` +**Files:** collaborative editor/index/tests and a two-client diagnostics suite. -- [ ] Preserve the original diagnostics array by identity until the controller performs bounded validation; editor construction must not deeply inspect hostile values. -- [ ] Add imperative methods for focus, ignore, dismiss, explanation request, and asynchronous apply. Every method returns a typed result instead of throwing for ordinary stale/conflict outcomes. -- [ ] Apply one plain-text replacement through an ordinary ProseMirror transaction only after a second exact-current-revision check immediately before mutation. -- [ ] Compute and return the resulting strong revision from the applied post-transaction document, and emit the host callback only after the result is complete. -- [ ] Ensure the transaction enters the normal undo history and does not bypass clipboard, link, image, schema, or document-envelope policy. -- [ ] Immediately invalidate every remaining diagnostic after a successful apply. -- [ ] Keep editing, form submission, conversion, save, and export available when diagnostics are invalid, unavailable, stale, ignored, or unhandled. -- [ ] Prove a document containing words such as `rude`, `incorrect`, `urgent`, or their multilingual equivalents produces zero diagnostics unless the host supplies them. -- [ ] Run: - -```bash -pnpm exec vitest run src/components/CwlEditor.writingDiagnostics.test.tsx src/components/CwlEditor.accessibility.test.tsx src/components/useEditorHandle.test.tsx -pnpm typecheck -``` +- [ ] Add the same props/actions and accessible panel behavior through the shared controller and extension. +- [ ] Prove a remote Yjs insertion invalidates the complete local set before application. +- [ ] Prove remote change during pending hashing discards the stale continuation. +- [ ] Keep diagnostics, explanations, replacements, selected text, model provenance, and review state out of awareness payloads. +- [ ] Emit an action only from the client whose user invoked it. +- [ ] Preserve editor/provider/Yjs identity when diagnostics/callbacks change. -- [ ] Commit: +## Task 8: framework-neutral package subpath -```bash -git add src/types.ts src/components/CwlEditor.tsx src/components/useEditorHandle.ts src/components/CwlEditor.test.tsx src/components/CwlEditor.accessibility.test.tsx src/components/useEditorHandle.test.tsx src/components/CwlEditor.writingDiagnostics.test.tsx -git commit -m "feat(diagnostics): integrate revision-safe editor actions" -``` +**Files:** package manifest/lock, Vite subpath config, package/export/consumer/boundary tests. -## Task 7: Establish Collaborative Editor Parity - -**Files:** -- Modify: `src/collaboration/CollaborativeCwlEditor.tsx` -- Modify: `src/collaboration/index.ts` -- Modify: `src/collaboration/index.test.ts` -- Modify: `src/collaboration/CollaborativeCwlEditor.test.tsx` -- Modify: `src/collaboration/CollaborativeCwlEditor.accessibility.test.tsx` -- Create: `src/collaboration/CollaborativeCwlEditor.writingDiagnostics.test.tsx` - -- [ ] Write failing tests for the same public props/actions as standalone Inkspan. -- [ ] Add a two-client Yjs test proving a remote insertion invalidates the local client's complete diagnostic set before any application can occur. -- [ ] Add a race test in which remote content changes while the local revision digest is pending; the older digest must not install decorations. -- [ ] Reuse `useWritingDiagnosticsController`; do not create a second collaborative-specific semantic or lifecycle implementation. -- [ ] Ensure awareness payloads never contain diagnostics, explanations, replacements, selected text, model provenance, or review state. -- [ ] Ensure remote action callbacks are not fabricated: only the client whose user explicitly invoked an action emits that action. -- [ ] Prove editor/provider/Yjs identity remains stable when diagnostics or callbacks change. -- [ ] Run: - -```bash -pnpm exec vitest run src/collaboration/CollaborativeCwlEditor.writingDiagnostics.test.tsx src/collaboration/CollaborativeCwlEditor.accessibility.test.tsx src/collaboration/index.test.ts -pnpm typecheck -``` +- [ ] Publish `@contextualwisdomlab/cwl-editor/writing-diagnostics` as React-free ESM/CommonJS/types. +- [ ] Export contracts, limits, validation, errors, and deterministic selector-resolution primitives only. +- [ ] Prove no React, TipTap React, provider, model SDK, network, credential, filesystem, database, or CSS side-effect dependency leaks into the pure subpath. +- [ ] Install the packed tarball into isolated ESM/CommonJS/strict-TypeScript consumers. +- [ ] Keep UI and editor handles on interactive root/collaboration entrypoints. -- [ ] Commit: +## Task 9: browser, SSR, hostile-input, and no-fallback assurance -```bash -git add src/collaboration/CollaborativeCwlEditor.tsx src/collaboration/index.ts src/collaboration/index.test.ts src/collaboration/CollaborativeCwlEditor.test.tsx src/collaboration/CollaborativeCwlEditor.accessibility.test.tsx src/collaboration/CollaborativeCwlEditor.writingDiagnostics.test.tsx -git commit -m "feat(diagnostics): guarantee collaborative parity" -``` +**Files:** browser specs/fixture, security tests, server-rendering tests, `docs/TEST_STRATEGY.md`. -## Task 8: Publish a Framework-Neutral Package Subpath - -**Files:** -- Modify: `package.json` -- Modify: `pnpm-lock.yaml` -- Create: `vite.writing-diagnostics.config.ts` -- Modify: `scripts/verify-package.mjs` -- Modify: `src/packageExports.test.ts` -- Modify: `src/packageConsumer.test.ts` -- Create: `src/writing-diagnostics/packageBoundary.test.ts` - -- [ ] Add `@contextualwisdomlab/cwl-editor/writing-diagnostics` as a React-free ESM/CommonJS/type subpath. -- [ ] Keep UI components and editor handles on the root and collaboration entrypoints; the subpath exports only types, limits, validation, errors, and selector-resolution primitives that do not require React. -- [ ] Add a dedicated Vite build configuration and package export map entries. -- [ ] Add dependency-graph tests proving the subpath has no React, TipTap React, Yjs provider, model SDK, network, credential, filesystem, or database import. -- [ ] Extend package verification to install the packed tarball in isolated strict TypeScript ESM and CommonJS consumers and compile a complete diagnostic fixture. -- [ ] Verify CSS remains opt-in through the existing `styles.css` export and the pure subpath has no CSS side effect. -- [ ] Run: - -```bash -pnpm build -pnpm verify:package -pnpm exec vitest run src/packageExports.test.ts src/packageConsumer.test.ts src/writing-diagnostics/packageBoundary.test.ts -``` +- [ ] Test rendering, keyboard/touch navigation, focus, Apply, undo, strict invalidation, zoom, forced colors, and mobile targets on pinned Chromium/Firefox/WebKit. +- [ ] Prove SSR renders without browser globals, segmenter, model infrastructure, or semantic evaluation; hydration adds no duplicate IDs, focus theft, or mismatch. +- [ ] Test script/HTML strings, bidi controls, isolated surrogates, nulls, oversize, accessors, proxies, duplicate JSON members, and callback exceptions. +- [ ] Use multilingual semantic contrast fixtures; without host diagnostics the exact result is an empty diagnostic surface. +- [ ] Record browser lock, artifact identity, and the distinction between editor integrity evidence and model accuracy. -- [ ] Commit: +## Task 10: canonical documentation and traceability -```bash -git add package.json pnpm-lock.yaml vite.writing-diagnostics.config.ts scripts/verify-package.mjs src/packageExports.test.ts src/packageConsumer.test.ts src/writing-diagnostics/packageBoundary.test.ts -git commit -m "build(diagnostics): publish framework-neutral contracts" -``` +**Files:** README, Architecture, PRD, TRD, API contract, threat model, operability, traceability, ADR 0027/0028/index, design, this plan, CHANGELOG, documentation-contract tests. -## Task 9: Add Browser, SSR, Hostile-Input, and No-Fallback Assurance - -**Files:** -- Create: `tests/browser/specs/writing-diagnostics.browser.spec.ts` -- Modify: `tests/browser/fixture/index.html` -- Create: `src/components/writingDiagnosticsSecurity.test.tsx` -- Modify: `src/components/editorServerRendering.test.tsx` -- Modify: `docs/TEST_STRATEGY.md` - -- [ ] Add Playwright scenarios in Chromium, Firefox, and WebKit for rendering, keyboard navigation, range focus, apply, undo, stale invalidation, zoom, forced-colors, and mobile/touch action targets. -- [ ] Add SSR tests proving a deterministic initial shell renders without `window`, `document`, `Intl.Segmenter`, model infrastructure, or diagnostics evaluation. -- [ ] Add hydration tests proving diagnostics verify after the client editor is ready without duplicate IDs, focus theft, or markup mismatch. -- [ ] Add hostile-input tests for HTML/script strings, bidi controls, isolated surrogates, nulls, oversized values, accessors, proxies, duplicate keys after JSON parsing, and callback exceptions. -- [ ] Add semantic contrast fixtures proving Inkspan itself has no keyword behavior: - - identical words in a quotation and direct statement; - - the same issue expressed through unrelated paraphrases; - - product names, code, URLs, and paths resembling spelling errors; - - Korean, English, mixed-language, and CJK documents. -- [ ] The expected Inkspan result for every contrast fixture without host diagnostics is an empty diagnostic surface. -- [ ] Document that these tests establish editor integrity and rendering parity, not the accuracy of any LLM or host rubric. -- [ ] Run the repository-pinned browser workflow command used by CI and record the exact browser package lock and artifact receipt. -- [ ] Commit: - -```bash -git add tests/browser/specs/writing-diagnostics.browser.spec.ts tests/browser/fixture/index.html src/components/writingDiagnosticsSecurity.test.tsx src/components/editorServerRendering.test.tsx docs/TEST_STRATEGY.md -git commit -m "test(diagnostics): prove browser and no-fallback assurance" -``` +- [ ] Document host semantic authority versus Inkspan deterministic integrity. +- [ ] Synchronize public type examples, strict invalidation, semantic-neutral decorations, collapsed selectors, single-action application, standalone/collaboration parity, SSR, degraded operation, privacy, and rollback. +- [ ] Remove temporary errata after folding every rule into the canonical ADR/design/plan; leave no parallel instruction set. +- [ ] Document that confidence/priority/category are host labels, not editor truth or submission policy. +- [ ] Add threats for hostile diagnostics, stale async work, replacement injection, telemetry leakage, focus attacks, collaboration races, and authority confusion. +- [ ] Map requirements to modules, tests, browser/package evidence, and release gates. +- [ ] Keep ADRs Proposed until protected implementation and acceptance exist. +- [ ] Add machine contracts that reject semantic keyword fallback, category-derived semantic ARIA, cross-edit mapping, provider ownership, stale repair, batch mutation authority, and send gating. -## Task 10: Reconcile Canonical Documentation and Traceability - -**Files:** -- Modify: `README.md` -- Modify: `ARCHITECTURE.md` -- Modify: `docs/PRD.md` -- Modify: `docs/TRD.md` -- Modify: `docs/API_CONTRACT.md` -- Modify: `docs/THREAT_MODEL.md` -- Modify: `docs/OPERABILITY.md` -- Modify: `docs/TRACEABILITY.md` -- Modify: `docs/adr/0027-host-owned-llm-writing-diagnostics.md` -- Modify: `docs/adr/README.md` -- Modify: `CHANGELOG.md` -- Modify: `src/documentationContracts.test.ts` - -- [ ] Add public examples for host-supplied diagnostics, exact revision capture, action callbacks, stale refresh, standalone and collaborative editors, SSR, and no-model degraded operation. -- [ ] Document the distinction between semantic authority and deterministic integrity. -- [ ] Document that `confidence` and `priority` are host evidence labels, not editor truth or submission policy. -- [ ] Add a threat-model section for prompt/model output as untrusted data, hostile diagnostic objects, stale selectors, replacement injection, overlap conflicts, telemetry leakage, focus attacks, and collaboration races. -- [ ] Add an operability section for review-unavailable state, invalid diagnostics, refresh ownership, callback failure, feature rollback, and no-network/offline behavior. -- [ ] Update traceability from ADR requirement to source module, test, browser evidence, package evidence, and release gate. -- [ ] Keep ADR 0027 `Proposed` until protected `main` contains the implementation and exact-head acceptance evidence; promote it in the release reconciliation PR, not prematurely. -- [ ] Record the feature under `Unreleased` without claiming Naruon integration, LLM quality, language validation, or publication. -- [ ] Add documentation contract tests that fail if keyword fallback, provider ownership, stale-repair, or send-gating claims reappear. -- [ ] Run: - -```bash -pnpm exec vitest run src/documentationContracts.test.ts -pnpm typecheck -``` +## Task 11: exact-head integration acceptance -- [ ] Commit: +- [ ] Reconcile the complete stack onto the latest protected main without destructive history or lost concurrent changes. +- [ ] Remove every temporary branch-specific workflow and regenerate evidence on the resulting exact head. +- [ ] Run full tests, typecheck, exact 100% owned coverage, library/demo builds, packed consumers, cross-engine browsers, Office Python 3.11/3.14 package gates, security/SAST/dependency/SBOM/provenance/secret checks, and documentation contracts. +- [ ] Inspect all current human/CodeRabbit/GHAS/Dependabot/OpenCode/Noema/Strix feedback and resolve only addressed threads. +- [ ] Require zero valid unresolved findings and qualifying independent non-author approval on the unchanged head. +- [ ] Mark Ready and merge only through protected policy; then verify protected main contains expected files and no temporary artifacts. -```bash -git add README.md ARCHITECTURE.md docs/PRD.md docs/TRD.md docs/API_CONTRACT.md docs/THREAT_MODEL.md docs/OPERABILITY.md docs/TRACEABILITY.md docs/adr/0027-host-owned-llm-writing-diagnostics.md docs/adr/README.md CHANGELOG.md src/documentationContracts.test.ts -git commit -m "docs(diagnostics): reconcile product and assurance contracts" -``` +## Task 12: release-only publication and host handoff -## Task 11: Exact-Head Acceptance and Merge - -- [ ] Rebase or merge the latest protected `main` without discarding valid concurrent changes. -- [ ] Run the complete repository test suite. -- [ ] Run `pnpm typecheck`. -- [ ] Run `pnpm coverage` and prove 100% production statement, branch, function, and line coverage. -- [ ] Run deterministic demo/library builds. -- [ ] Run `pnpm verify:package` against the packed tarball outside the source tree. -- [ ] Run cross-engine browser evidence through the repository-pinned Playwright lane. -- [ ] Run Office Python 3.11 and 3.14 test, docstring, branch coverage, wheel, schema, and license gates. -- [ ] Run SAST, dependency, supply-chain, SBOM, provenance, and secret checks on the exact final head. -- [ ] Review every current-head CodeRabbit, GitHub Advanced Security, Dependabot, OpenCode, Noema, Strix, human, and other applicable finding. -- [ ] Resolve every valid review thread and rerun affected tests. -- [ ] Confirm zero unresolved valid review threads. -- [ ] Obtain a qualifying non-author current-head approval. -- [ ] Move the PR from Draft to Ready only after implementation, direct validation, and documentation gates are complete. -- [ ] Merge without bypass only after all protected exact-head checks and approval rules pass. -- [ ] Refetch protected `main` and verify the merge commit contains the expected files and no unrelated branch artifacts. - -## Task 12: Release-Only Publication and Naruon Handoff - -- [ ] Open a separate release-only PR for the next compatible minor version after the feature merge. -- [ ] Promote ADR 0027 to `Accepted` only with protected-main implementation and exact-head evidence. -- [ ] Update version metadata, final CHANGELOG release section, package declarations, license inventory, SBOM, provenance, and rollback evidence. -- [ ] Publish immutable npm artifacts only from the exact reviewed release head. -- [ ] Verify ESM, CommonJS, types, CSS, root, collaboration, and `writing-diagnostics` subpaths from the published package. -- [ ] Record the immutable version, tarball integrity, source commit, package manifest, and compatibility matrix in the Naruon companion PR. -- [ ] Do not merge Naruon's runtime integration against a mutable Inkspan branch, source archive, local path, or unreviewed package. -- [ ] Retain a documented rollback path that removes diagnostic props and UI without document migration or canonical-envelope changes. +- [ ] Open a separate release-only PR after protected feature integration and current release-train closure. +- [ ] Promote ADR status only with protected implementation and current acceptance evidence. +- [ ] Update version, final CHANGELOG, package declarations, licenses, SBOM/provenance, compatibility, and rollback. +- [ ] Publish immutable npm artifacts from the exact reviewed release head and verify all subpaths from the registry artifact. +- [ ] Record immutable version, tarball digest, source commit, manifest, browser evidence, and rollback in the Naruon companion integration. +- [ ] Never integrate a mutable branch, source archive, copied fork, local path, or unreviewed package into a host runtime. diff --git a/docs/superpowers/specs/2026-08-12-revision-bound-llm-writing-diagnostics-design.md b/docs/superpowers/specs/2026-08-12-revision-bound-llm-writing-diagnostics-design.md index 8e753eb2..75e26e15 100644 --- a/docs/superpowers/specs/2026-08-12-revision-bound-llm-writing-diagnostics-design.md +++ b/docs/superpowers/specs/2026-08-12-revision-bound-llm-writing-diagnostics-design.md @@ -1,4 +1,4 @@ -# Revision-Bound LLM Writing Diagnostics Design +# Revision-Bound Writing Diagnostics Design **Date:** 2026-08-12 **Status:** Proposed design; not shipped behavior @@ -6,99 +6,108 @@ ## Objective -Add a provider-neutral, Grammarly-like writing-diagnostic surface to Inkspan without turning Inkspan into a language model, email product, policy engine, or persistence service. +Add a provider-neutral, Grammarly-like writing-diagnostic surface without turning Inkspan into a language model, email product, policy engine, persistence service, or hidden semantic classifier. -A host application will generate contextual writing proposals using an LLM and its own review policy. Inkspan will display those proposals against the exact document revision from which they were generated, let the author inspect and apply or ignore each one, and prevent stale asynchronous output from mutating newer content. +A host application generates contextual writing proposals using its own model, rubric, authorization, privacy, retention, and review policy. Inkspan admits only structurally valid host proposals, binds them to one exact document revision and text projection, renders them accessibly, and permits explicit user actions without allowing stale asynchronous output to mutate changed content. -The feature must support spelling, grammar, spacing, punctuation, clarity, concision, structure, tone, pragmatics, technical precision, and actionability as host-defined categories. Inkspan does not determine any of those categories. It exposes a generic review contract and deterministic document integrity. +Host-defined categories may describe spelling, grammar, spacing, punctuation, clarity, concision, structure, tone, pragmatics, technical precision, or actionability. Inkspan treats those fields as opaque proposal data. It does not determine whether a category, explanation, confidence, priority, or replacement is semantically correct. ## Product behavior -An author sees normal Inkspan editing first. When the host supplies diagnostics: +An author sees normal Inkspan editing first. When the host supplies an admitted diagnostic set: -1. affected ranges receive non-color-only decorations; -2. the diagnostics summary reports the number and categories of suggestions; -3. keyboard and pointer users can move to the previous or next suggestion; -4. a suggestion card explains the issue and shows an optional replacement; -5. Apply changes only the selected range; -6. Ignore reports a host-visible feedback action without changing the document; -7. Dismiss removes the local presentation until the host changes the diagnostic set; -8. Explain requests no model call from Inkspan; it reveals the explanation already supplied by the host or invokes a host callback; -9. any document change revalidates or invalidates affected diagnostics; -10. stale diagnostics never apply by nearest-text search, keyword search, or silent position repair. +1. non-empty affected ranges receive non-color-only visual decorations; +2. a named diagnostics region reports the count and exposes an ordered list; +3. each item exposes category, priority, title, explanation, and optional replacement as plain text; +4. keyboard, pointer, touch, and assistive-technology users can navigate the previous or next diagnostic; +5. Focus moves to an affected structural range only after explicit user navigation; +6. Apply rechecks the exact current revision and applies one selected plain-text replacement through one ordinary ProseMirror transaction; +7. Ignore and Dismiss emit privacy-minimized host-visible actions without changing canonical content; +8. Explain reveals the supplied explanation or invokes an explicit host callback; Inkspan performs no model call; +9. asynchronous arrival never steals focus; and +10. stale diagnostics never apply through nearest-text search, keyword search, quote search, remapping, or silent position repair. -Diagnostics remain advisory. Their presence does not block form submission, email sending, export, or persistence in the editor package. +Diagnostics remain advisory. Their presence, absence, invalidity, or staleness does not block form submission, sending, export, persistence, or collaboration in the editor package. ## Selected architecture ```mermaid flowchart LR H[Host review service] -->|revision-bound diagnostics| P[Inkspan public props] - P --> V[Deterministic diagnostic validator] - V --> D[ProseMirror decorations] - D --> U[Accessible diagnostics UI] - U --> A{Author action} - A -->|Apply| R[Revision/selector revalidation] - A -->|Ignore or Dismiss| C[Privacy-minimized callback] - R -->|match| T[Normal ProseMirror transaction] - R -->|stale or ambiguous| X[Typed conflict/invalidation] - T --> E[Normal onChange/onDocumentChange/undo] + P --> V[Deterministic contract validator] + V --> R[Exact revision and selector resolver] + R --> D[Semantic-neutral ProseMirror decorations] + D --> U[Accessible diagnostics panel] + U --> A{Explicit author action} + A -->|Apply| C[Exact current-revision check] + A -->|Ignore Dismiss Explain| E[Privacy-minimized action event] + C -->|match| T[Ordinary ProseMirror transaction] + C -->|stale conflict| X[Typed non-mutating outcome] + T --> O[Normal change revision and undo behavior] ``` -The host may be Naruon, another CWL product, or an unrelated consumer. No host name appears in the runtime API. +The host may be Naruon, another CWL product, or an unrelated consumer. No host, provider, model, email, or tenant name appears in the generic runtime contract. ## Public contract -The exact implementation names may be refined during planning, but the semantic contract is fixed. +The v1 design mirrors the implementation types rather than maintaining a second approximate schema. ```ts -export type CwlWritingDiagnosticPriority = 'suggestion' | 'important'; - -export interface CwlWritingDiagnosticSelector { - readonly type: 'TextPositionSelector'; - readonly start: number; - readonly end: number; -} +export type CwlWritingDiagnosticPriority = + | 'advisory' + | 'important' + | 'critical'; export interface CwlWritingDiagnosticProvenance { readonly workflowId: string; readonly workflowVersion: string; - readonly policyVersion: string; - readonly providerName?: string; - readonly modelName?: string; + readonly judgePolicyVersion: string; + readonly orchestrationMode?: string; } export interface CwlWritingDiagnostic { readonly diagnosticId: string; - readonly documentRevision: string; - readonly projectionName: 'inkspan-prosemirror-text'; - readonly projectionVersion: 1; - readonly selector: CwlWritingDiagnosticSelector; + readonly documentRevision: CwlEditorDocumentRevision; + readonly textProjection: CwlEditorTextProjectionIdentity; + readonly selector: CwlEditorTextPositionSelector; readonly categoryCode: string; readonly priority: CwlWritingDiagnosticPriority; readonly title: string; readonly explanation: string; readonly suggestedReplacement?: string; readonly confidence?: number; - readonly provenance: CwlWritingDiagnosticProvenance; + readonly provenance: Readonly; } export type CwlWritingDiagnosticAction = - | 'apply' - | 'ignore' - | 'dismiss' - | 'explain'; + | 'applied' + | 'ignored' + | 'dismissed' + | 'requested_explanation' + | 'stale' + | 'conflict'; + +export type CwlWritingDiagnosticActionReasonCode = + | 'explicit' + | 'document_changed' + | 'revision_mismatch' + | 'projection_mismatch' + | 'selector_invalid' + | 'verification_failed' + | 'lifecycle_ended' + | 'diagnostic_missing'; export interface CwlWritingDiagnosticActionEvent { - readonly diagnosticId: string; readonly action: CwlWritingDiagnosticAction; - readonly status: 'completed' | 'stale' | 'conflict' | 'rejected'; - readonly currentRevision?: string; - readonly reasonCode?: string; + readonly reasonCode: CwlWritingDiagnosticActionReasonCode; + readonly diagnosticId: string; + readonly documentRevision: CwlEditorDocumentRevision; + readonly categoryCode: string; + readonly generation: number; } ``` -Candidate props: +Candidate additive editor props are: ```ts interface CwlEditorProps { @@ -106,235 +115,191 @@ interface CwlEditorProps { onWritingDiagnosticAction?: ( event: CwlWritingDiagnosticActionEvent, ) => void; + onWritingDiagnosticsError?: (error: WritingDiagnosticError) => void; + writingDiagnosticsLabel?: string; + printWritingDiagnostics?: boolean; } ``` -Candidate imperative method for hosts that render their own panel: +Candidate imperative methods use the same controller and validation path as the built-in panel. There is no trusted imperative bypass. -```ts -interface CwlEditorHandle { - applyWritingDiagnosticIfMatch( - diagnosticId: string, - ): Promise; -} -``` +## Validation boundary -The component and imperative paths must call the same implementation. There cannot be a “trusted imperative” bypass. +The deterministic validator fails closed and verifies: -## Validation boundary +- exact own enumerable data properties and no unsupported fields, symbols, accessors, sparse arrays, inherited fields, or hostile reflection; +- bounded collection size, identifier length, category length, title, explanation, replacement, and provenance identifiers; +- unique diagnostic identifiers within one submitted generation; +- supported finite priority and confidence values; +- an exact lowercase SHA-256 revision object and matching strong entity tag; +- the exact `inkspan-prosemirror-text` version 1 projection identity; +- selector values are non-negative safe integers with start <= end; +- Unicode-code-point and grapheme-cluster boundaries; +- one unambiguous structural range in the exact projected snapshot; and +- plain-text replacement values only. -The diagnostic validator is deterministic and fail-closed. It verifies: - -- the collection is an array within a documented maximum count; -- every object contains exactly the supported fields; -- identifiers and category codes satisfy bounded syntax contracts; -- identifiers are unique within the supplied collection; -- text fields are non-empty where required and within documented limits; -- confidence, if present, is finite and in `[0, 1]`; -- the projection name and version are supported; -- the declared revision has valid Inkspan strong-entity-tag syntax; -- selector values are non-negative integers with `start < end`; -- selector boundaries are valid Unicode-code-point and grapheme-cluster boundaries; -- the range exists in the declared projection; -- replacement content passes the existing editor input, link, image, and schema policies; -- diagnostics do not contain executable markup or hidden event handlers; -- a bounded batch application contains no overlapping edits. - -The validator does not decide whether an explanation is true, whether a replacement is grammatically better, or whether a message is polite. Regexes may validate identifiers and revision syntax but cannot create or admit a semantic diagnostic based on source wording. +Collapsed selectors are valid evidence and remain navigable, but they create no inline range decoration. Empty and non-empty selectors use the same exact revision/projection admission path. + +The validator does not decide whether an explanation is true, a replacement is better, a message is polite, or a category label is accurate. Regexes may validate bounded identifiers and revision syntax, but they cannot create, prioritize, admit, or semantically classify a diagnostic. ## Revision and position lifecycle ### Initial admission -The host captures one document revision and text projection, sends that material through its review system, and returns diagnostics carrying the same revision and projection identity. Inkspan compares those fields with the editor state before rendering the proposals as current. +The host reviews one immutable document snapshot and returns diagnostics carrying that snapshot's exact `CwlEditorDocumentRevision`, projection identity, and W3C text-position selector. Inkspan validates the complete untrusted set before reading editor state, derives one current revision from one immutable editor snapshot, resolves all selectors against that same snapshot, and publishes only a complete verified generation. -### Local edits +### Strict invalidation -ProseMirror can map a range through transactions. Inkspan may keep a diagnostic current only when all of the following hold: +Every local or collaborative transaction with docChanged === true invalidates the complete active diagnostic generation. -- the original revision was admitted; -- every intervening transaction exposes a valid mapping; -- the mapped range is not deleted, split ambiguously, or replaced by unrelated content; -- the host's declared policy allows mapped presentation; -- application still performs a fresh current-state check. +Version 1 does not preserve, map, remap, repair, or re-admit a diagnostic after changed document content. ProseMirror mapping and Yjs relative positions are useful editor mechanisms, but neither proves that a host model judgment remains semantically current. A stale async digest or selector result is discarded through the generation fence. The host must submit a new set bound to the new exact revision. -A mapped decoration is presentation convenience, not permission to apply stale model output. The final replacement action verifies the active state under the implementation plan's exact conflict contract. +### Explicit action -### Remote collaborative edits +Version 1 applies exactly one explicitly selected diagnostic at a time. -Yjs collaboration can remap local ProseMirror positions, but a model proposal remains bound to the original strong revision. A remote edit that changes the reviewed content invalidates the proposal for application. Inkspan must not treat a Yjs relative position as proof that the semantic target remained unchanged. +Immediately before application, Inkspan derives and compares the current exact revision again. A matching diagnostic with a valid plain-text replacement produces one ordinary ProseMirror transaction and normal undo history. A mismatch, missing diagnostic, invalid selector, ended lifecycle, or changed document returns a typed non-mutating event. Successful application invalidates every remaining diagnostic and derives the resulting revision from the post-transaction document. -### Re-review +There is no Apply All or batch mutation authority in v1. Overlapping diagnostics may be displayed independently, but each action is revalidated after every document change. -The host receives stale/conflict callbacks and may request a new review. Inkspan itself performs no network call and has no retry loop. +### Re-review -## Decoration and interaction model +The host may use a stale/conflict action event to request a new review. Inkspan performs no network call, retry, provider fallback, or diagnostic regeneration. -- Different categories may use distinct underline patterns, but color alone is insufficient. -- Hover may show a preview, but every operation must be keyboard reachable. -- The editor toolbar remains one composite tab stop; diagnostic navigation may be a separate named toolbar or panel with a documented roving-tabindex pattern. -- Opening a diagnostic card does not move the caret unless the author explicitly chooses to navigate to the affected range. -- Applying a replacement creates one normal ProseMirror transaction and one normal undo step. -- After Apply, focus returns predictably to the editor at the end of the inserted replacement unless the host chooses a documented alternative. -- New asynchronous diagnostics must not steal focus or close a card the author is actively reading. -- Screen-reader output identifies category, ordinal position, affected range context, and available actions without reading the entire document. +## Decoration and accessibility model -## Host feedback surface +Inline decorations contain only: -Inkspan reports action metadata only. The default event contains no selected source text, replacement text, explanation, prompt, raw model output, email recipient, or tenant identifier. +```text +class="cwl-writing-diagnostic cwl-writing-diagnostic--{priority}" +data-cwl-diagnostic-id="opaque-id" +``` -A host that needs richer audit evidence must deliberately read it from its own authorized review-session store. This prevents generic analytics from becoming a shadow copy of authored documents. +Inkspan does not derive aria-invalid or any other semantic accessibility state from opaque host strings. -Recommended action reason codes include: +The editor does not infer spelling, grammar, mechanics, tone, or correctness from `categoryCode`, title, explanation, replacement, confidence, provenance, or source text. It does not place those strings in decoration attributes. The named diagnostics panel is the semantic accessibility surface. -```text -revision_mismatch -projection_mismatch -range_deleted -range_ambiguous -replacement_rejected -batch_overlap -unsupported_diagnostic -editor_destroyed -``` +The panel must provide: -Reason codes are stable machine data. Human-readable failure messages remain localized host/editor UI text. +- a named region and count summary; +- an ordered list with category, priority, title, and explanation; +- explicit previous/next navigation with roving focus; +- an affected-range Focus action; +- Apply, Ignore, Dismiss, and Explain native buttons; +- a disabled Apply action when no replacement exists; +- a polite status region for ordinary completed actions; +- an assertive alert only for an actual application conflict; +- no focus theft when diagnostics arrive asynchronously; and +- equivalent information without color, hover, pointer input, animation, or generated CSS content. -## Security and privacy +Host strings render as React text nodes only. Action names may use the diagnostic title but never copy selected source text into DOM attributes. Underlines are a visual supplement, not the sole information channel. -- Treat every diagnostic field as attacker-controlled input. -- Render title and explanation as text, not trusted HTML. -- Route replacements through existing safe-link, safe-image, clipboard, and schema policy. -- Do not allow a diagnostic to carry commands, JavaScript, arbitrary TipTap JSON, or host callbacks. -- Do not place source or replacement text in logs, exceptions, analytics, or performance marks. -- Do not expose provider credentials or full provider traces through provenance. -- Bound diagnostic count, text lengths, selector sizes, and decoration work to prevent rendering denial of service. -- Reject duplicate identifiers and unsupported fields rather than accepting ambiguous objects. -- Preserve Inkspan's no-runtime-environment-read and no-network-call contracts. +## Host feedback surface -## Keyword-judgment prohibition +Default action events contain only opaque identifiers, exact revision evidence, opaque category code, generation, action, and bounded reason code. They contain no selected source text, replacement text, explanation, prompt, raw model output, email recipient, credential, document envelope, or tenant identifier. -Inkspan must contain no semantic rule such as: +A host requiring richer audit evidence reads it from its own authorized review-session store. Generic analytics must not become a shadow copy of authored content. -```text -if text includes "무슨 말씀이신가요" then category = "tone" -if text includes "당황스럽습니다" then priority = "important" -if sender domain ends with X then apply business-language rule Y -``` +## Security and privacy -Test fixtures will include: +- Treat every diagnostic object and host string as untrusted input. +- Reject accessors, prototypes, symbols, extra fields, sparse arrays, proxies, duplicate identifiers, and resource-limit violations. +- Render title, explanation, category, and replacement previews as text, never trusted HTML. +- Accept only plain-text replacement values in v1. +- Do not allow commands, JavaScript, arbitrary TipTap JSON, host callbacks, or executable markup inside a diagnostic. +- Do not place authored or model-produced text in logs, exceptions, analytics, performance marks, awareness payloads, or decoration attributes. +- Do not expose provider credentials, raw provider traces, or tenant data through provenance. +- Preserve Inkspan's no-runtime-environment-read and no-network-call contracts. -- the same phrase quoted neutrally and used as a direct rebuke; -- the same pragmatic problem expressed with unrelated vocabulary; -- intentionally misspelled words inside code, quotations, and proper names; -- recipient metadata that changes the host's interpretation while the draft text remains identical. +## Semantic keyword prohibition -Inkspan must produce zero diagnostics in every fixture unless the host explicitly supplies them. This proves the package is a renderer and integrity boundary, not a hidden classifier. +Inkspan must produce zero diagnostics unless the host explicitly supplies them. It contains no semantic rule based on keywords, regexes, phrase dictionaries, sender domains, recipient counts, language names, positions, or nearest-text similarity. Opaque values that happen to contain words such as `spelling`, `grammar`, `mechanics`, `rude`, `incorrect`, or multilingual equivalents do not gain behavior or semantic ARIA authority. ## Failure behavior | Condition | Inkspan behavior | |---|---| | No diagnostics supplied | Normal editor behavior | -| Host review pending | Normal editor; optional host-owned loading UI | -| Host review failed | Normal editor; no fabricated fallback | -| Malformed diagnostic | Reject diagnostic collection or invalid entry according to the typed contract; no mutation | -| Stale revision | Mark invalid/stale; Apply unavailable; emit callback | -| Unsupported projection | Reject; no nearest-text recovery | -| Hostile explanation/replacement | Render safely or reject under existing policy | -| Overlapping batch | Reject batch; allow individually revalidated actions | -| Editor destroyed | Return typed non-mutating result | +| Host review pending or unavailable | Normal editor; optional host-owned status UI | +| Malformed or oversized input | Reject complete set through a redacted typed error; no editor mutation | +| Revision mismatch | Do not install or apply; return bounded stale/conflict evidence | +| Unsupported projection | Reject; no nearest-text or compatibility recovery | +| Invalid or ambiguous selector | Reject complete set; no guessed position | +| Local or remote document change | Invalidate complete active generation immediately | +| Host callback throws | Contain callback failure; preserve deterministic editor state | +| Editor destroyed | Return typed non-mutating lifecycle evidence | ## Testing strategy -### Pure contract tests +### Contract and hostile-input tests -- exact field, type, length, and count validation; -- duplicate IDs and unexpected fields; -- finite confidence and revision syntax; -- Unicode code-point ranges and grapheme boundaries; -- immutable/frozen public event snapshots where applicable; -- overlap detection and deterministic ordering. +- exact fields, types, limits, priorities, confidence, revision, projection, and selector validation; +- duplicate identifiers, sparse arrays, accessors, inherited fields, symbols, proxies, and hostile reflection; +- Unicode astral characters, Korean/CJK, combining marks, emoji, bidirectional text, empty/collapsed selectors, and grapheme boundaries; +- immutable detached public values and redacted errors. -### Editor tests +### Editor and concurrency tests -- decorations on exact ranges; -- local transaction mapping and invalidation; -- stale application rejection; -- safe replacement and one-step undo; -- no mutation on rejected input; -- action callback content minimization; -- no diagnostic generation from source text. +- exact decoration attributes and no semantic ARIA derivation; +- local and remote `docChanged` invalidation; +- generation fencing for overlapping async verification; +- exact application recheck, ordinary transaction, resulting revision, and one-step undo; +- no mutation on rejected or stale input; +- privacy-minimized action events and callback-failure containment; +- zero diagnostics without host input. ### Accessibility tests -- keyboard navigation and all actions; -- named regions and controls; -- focus restoration; -- polite live status; -- non-color-only rendering; -- arrival of new diagnostics while focus remains stable. +- named region, ordered list, count, category, priority, title, and explanation; +- keyboard navigation, roving focus, and explicit actions; +- no focus theft, polite status, conflict alert, forced colors, high contrast, reduced motion, print, zoom, and touch targets; +- no visual-only or hover-only information. -### Collaborative tests +### Collaboration, package, and browser tests -- local and remote edits; -- Yjs remapping followed by revision rejection; -- no awareness publication caused by diagnostics; -- standalone/collaborative public API parity. +- standalone/collaborative API parity and remote invalidation; +- no diagnostics in Yjs awareness payloads; +- SSR-safe shell and deterministic hydration; +- packed ESM/CommonJS/strict-TypeScript consumers; +- React-free `writing-diagnostics` subpath; +- Chromium, Firefox, and WebKit evidence; and +- exact 100% owned production statement, branch, function, and line coverage plus complete public JSDoc. -### Package and browser tests +## Performance constraints -- packed ESM/CommonJS/type consumers; -- React 18 and 19 host builds; -- SSR/hydration; -- Chromium, Firefox, and WebKit behavior; -- production statement, branch, function, and line coverage at exactly 100%; -- public declarations and JSDoc completeness. +- Validation is linear in bounded diagnostic count and bounded projection size. +- At most 256 active diagnostics are admitted by default. +- One immutable snapshot and one revision derivation are shared across one verification generation. +- No document clone or digest is repeated merely to render an already admitted set. +- A document change invalidates rather than remaps the set, keeping v1 lifecycle cost deterministic. +- Telemetry records counts and timing buckets, not authored text. -## Performance constraints +## Documentation and release requirements + +Implementation must synchronize root README, public API/JSDoc, PRD, TRD, API contract, architecture, threat model, operability, selector/revision guides, collaboration guide, test strategy, ADR index, traceability, CHANGELOG, package consumers, SBOM/provenance, rollback, and release evidence. -- Validation is linear in diagnostic count plus bounded text projection work. -- Decoration updates are incremental where ProseMirror supports it. -- A configurable hard maximum prevents unbounded diagnostic decorations. -- No source document clone or SHA-256 digest is repeated merely to render an already-admitted set. -- Applying one proposal does not serialize the full document more times than required by the existing revision guard. -- Performance telemetry records counts and timing buckets, not authored text. - -## Documentation updates required with implementation - -- root README and React editor examples; -- public API declarations and JSDoc; -- selection lifecycle and revision evidence guides; -- accessibility guide; -- collaboration guide; -- security/privacy guidance; -- package distribution and packed-consumer verification; -- ADR index and documentation-fitness traceability; -- CHANGELOG and release evidence. +The feature remains `Unreleased`. It may ship only after the complete stack is reconciled onto protected main, temporary branch-specific workflows are removed, exact-head CI/security/coverage/package/browser evidence succeeds, zero valid findings remain, qualifying independent review exists, and a separate release-only PR publishes immutable artifacts. ## Out of scope -- model invocation or model selection; -- spelling dictionaries or grammar models; +- model invocation, model selection, prompt construction, rubric ownership, judge calibration, or provider failover; +- spelling dictionaries or deterministic grammar/tone classifiers; - email/thread/recipient semantics; -- host policy or submission blocking; -- persistent review sessions; -- diagnostic aggregation across users; -- human-review assignment; -- provider billing and retention; -- training or calibrating an LLM judge. - -Those responsibilities belong to the host or separate CWL services. +- host submission, send, persistence, or compliance gates; +- persistent review sessions and cross-user aggregation; +- human-review assignment; and +- provider billing, retention, or training. ## Primary references -- W3C Web Annotation Data Model Recommendation for Unicode-code-point `TextPositionSelector` semantics and its warning that positions are brittle across resource changes. -- TipTap v2 and ProseMirror documentation for immutable editor state, transactions, selections, decorations, and mapping. -- RFC 9110 for strong entity-tag and conditional-write semantics used by Inkspan's revision boundary. -- Inkspan ADR 0011 for the deterministic versus model-assisted authoring boundary. +- W3C Web Annotation Data Model for Unicode-code-point `TextPositionSelector` semantics and its warning that positions are brittle across resource changes. +- TipTap v2 and ProseMirror documentation for immutable editor state, transactions, selections, and decorations. +- RFC 9110 for strong entity-tag semantics used by Inkspan revision evidence. +- Inkspan ADR 0011 for deterministic versus model-assisted authoring. - Inkspan ADR 0018 for revision-scoped W3C selector authority. +- Inkspan ADR 0027 and ADR 0028 for host semantic authority, strict invalidation, and semantic-neutral accessibility. - The accompanying doctoring record for LLM-judge bias and host calibration implications. ## Approval boundary -Approval of this design authorizes an implementation plan, not production claims. The feature remains unshipped until protected `main` contains the implementation, documentation, exact 100% coverage evidence, packed-package verification, cross-engine evidence, security checks, review approval, and release reconciliation. \ No newline at end of file +Approval of this design authorizes implementation work, not production claims. The feature remains unshipped until protected main contains the reconciled implementation, canonical documentation, exact acceptance evidence, independent approval, and verified release artifacts. diff --git a/src/adrQualityContract.test.ts b/src/adrQualityContract.test.ts index 04dcef71..32664c2e 100644 --- a/src/adrQualityContract.test.ts +++ b/src/adrQualityContract.test.ts @@ -23,6 +23,8 @@ const requiredAdrHeadings = [ /^## Rollback or supersession(?:\b|\s|$)/mu, ] as const; +const ADR_STATUS = /^Status: (Proposed|Accepted|Superseded)$/mu; + describe('ADR quality documentation contract', () => { it('preserves the canonical ADR quality requirements on every reconciliation branch', () => { const adrIndex = repositoryFile('docs/adr/README.md'); @@ -37,6 +39,25 @@ describe('ADR quality documentation contract', () => { expect(adrIndex).toContain('rollback or explicit supersession conditions'); }); + it('indexes every detailed ADR with its exact file and status', () => { + const adrIndex = repositoryFile('docs/adr/README.md'); + const adrFiles = detailedAdrFiles(); + + expect(adrFiles.length).toBeGreaterThan(0); + + for (const adrFile of adrFiles) { + const adr = repositoryFile(`docs/adr/${adrFile}`); + const status = adr.match(ADR_STATUS)?.[1]; + const adrNumber = adrFile.slice(0, 4); + + expect(status, `${adrFile} has no canonical status`).toBeDefined(); + expect( + adrIndex, + `${adrFile} is missing from the ADR index or has a stale status`, + ).toContain(`| [${adrNumber}](${adrFile}) | ${status} |`); + } + }); + it('applies the canonical quality sections to every detailed ADR', () => { const adrFiles = detailedAdrFiles(); diff --git a/src/writingDiagnosticsDocumentationContract.test.ts b/src/writingDiagnosticsDocumentationContract.test.ts new file mode 100644 index 00000000..9de70450 --- /dev/null +++ b/src/writingDiagnosticsDocumentationContract.test.ts @@ -0,0 +1,105 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +/** Read one authoritative repository text artifact for deterministic assertions. */ +function repositoryFile(path: string): string { + return readFileSync(resolve(process.cwd(), path), 'utf8'); +} + +/** + * Collapse prose whitespace and code-span punctuation without weakening words. + * + * Markdown authors may correctly wrap identifiers such as `docChanged` or + * `aria-invalid` in code spans. The documentation contract checks the semantic + * prose, not that presentational punctuation choice. + */ +function normalizeProse(value: string): string { + return value.replace(/`/gu, '').replace(/\s+/gu, ' ').trim(); +} + +const designPath = + 'docs/superpowers/specs/2026-08-12-revision-bound-llm-writing-diagnostics-design.md'; +const planPath = + 'docs/superpowers/plans/2026-08-12-writing-diagnostics-implementation.md'; +const adrPath = 'docs/adr/0027-host-owned-llm-writing-diagnostics.md'; +const supersedingAdrPath = + 'docs/adr/0028-writing-diagnostics-v1-strict-invalidation.md'; + +describe('writing diagnostics documentation contract', () => { + it('keeps the public design examples synchronized with the implemented v1 types', () => { + const design = repositoryFile(designPath); + + expect(design).toContain( + "export type CwlWritingDiagnosticPriority =\n | 'advisory'\n | 'important'\n | 'critical';", + ); + expect(design).toContain( + 'readonly documentRevision: CwlEditorDocumentRevision;', + ); + expect(design).toContain( + 'readonly textProjection: CwlEditorTextProjectionIdentity;', + ); + expect(design).toContain('readonly judgePolicyVersion: string;'); + expect(design).toContain( + 'readonly reasonCode: CwlWritingDiagnosticActionReasonCode;', + ); + expect(design).toContain('readonly generation: number;'); + expect(design).not.toContain( + "export type CwlWritingDiagnosticPriority = 'suggestion' | 'important';", + ); + expect(design).not.toContain('readonly documentRevision: string;'); + expect(design).not.toContain( + "readonly projectionName: 'inkspan-prosemirror-text';", + ); + expect(design).not.toContain("readonly status: 'completed'"); + }); + + it('requires strict invalidation and semantic-neutral decoration guidance everywhere', () => { + const design = normalizeProse(repositoryFile(designPath)); + const plan = repositoryFile(planPath); + const adr = normalizeProse(repositoryFile(adrPath)); + const supersedingAdr = normalizeProse(repositoryFile(supersedingAdrPath)); + + const strictInvalidation = + 'Every local or collaborative transaction with docChanged === true invalidates the complete active diagnostic generation.'; + const semanticNeutrality = + 'Inkspan does not derive aria-invalid or any other semantic accessibility state from opaque host strings.'; + + expect(design).toContain(strictInvalidation); + expect(adr).toContain(strictInvalidation); + expect(supersedingAdr).toContain( + 'every transaction with docChanged === true, whether local or collaborative, invalidates the complete active diagnostic generation', + ); + expect(design).toContain(semanticNeutrality); + expect(adr).toContain(semanticNeutrality); + + expect(design).not.toContain('Inkspan may keep a diagnostic current'); + expect(design).not.toContain('local transaction mapping and invalidation'); + expect(design).not.toContain('Yjs remapping followed by revision rejection'); + expect(adr).not.toContain( + 'decoration mapping through local ProseMirror transactions', + ); + expect(adr).not.toContain('A safely mapped local transaction may preserve'); + expect(adr).not.toContain('valid transaction-local mapping'); + expect(plan).not.toContain('aria-invalid="spelling"'); + }); + + it('documents collapsed selectors and one-action-at-a-time application without batch authority', () => { + const design = normalizeProse(repositoryFile(designPath)); + const adr = normalizeProse(repositoryFile(adrPath)); + + expect(design).toContain( + 'selector values are non-negative safe integers with start <= end', + ); + expect(design).toContain( + 'Version 1 applies exactly one explicitly selected diagnostic at a time.', + ); + expect(adr).toContain( + 'Version 1 applies exactly one explicitly selected diagnostic at a time.', + ); + expect(design).not.toContain('a bounded batch application'); + expect(design).not.toContain('batch_overlap'); + expect(adr).not.toContain('Apply all'); + }); +}); From 81fbd55c637592d339411cc66e33a8aae609064f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 07:50:17 -0700 Subject: [PATCH 20/21] test(package): require writing diagnostics subpath publication --- ...riting-diagnostics-package-config.test.mjs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 scripts/writing-diagnostics-package-config.test.mjs diff --git a/scripts/writing-diagnostics-package-config.test.mjs b/scripts/writing-diagnostics-package-config.test.mjs new file mode 100644 index 00000000..9e69d7c7 --- /dev/null +++ b/scripts/writing-diagnostics-package-config.test.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageJson = JSON.parse( + readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), +); + +test('writing diagnostics is published as an independently built package subpath', () => { + assert.deepEqual(packageJson.exports['./writing-diagnostics'], { + types: './dist/writing-diagnostics/index.d.ts', + import: './dist/cwl-writing-diagnostics.js', + require: './dist/cwl-writing-diagnostics.cjs', + }); + assert.match( + packageJson.scripts.build, + /vite build --config vite\.writing-diagnostics\.config\.ts/u, + ); + assert.match( + packageJson.scripts['verify:package'], + /verify-writing-diagnostics-subpath-package\.mjs/u, + ); +}); From b878721bf1c085612f8aea889081f0322e62a2ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 07:53:07 -0700 Subject: [PATCH 21/21] test(diagnostics): keep package publication on package owner --- ...riting-diagnostics-package-config.test.mjs | 26 ------------------- 1 file changed, 26 deletions(-) delete mode 100644 scripts/writing-diagnostics-package-config.test.mjs diff --git a/scripts/writing-diagnostics-package-config.test.mjs b/scripts/writing-diagnostics-package-config.test.mjs deleted file mode 100644 index 9e69d7c7..00000000 --- a/scripts/writing-diagnostics-package-config.test.mjs +++ /dev/null @@ -1,26 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import test from 'node:test'; - -const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const packageJson = JSON.parse( - readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), -); - -test('writing diagnostics is published as an independently built package subpath', () => { - assert.deepEqual(packageJson.exports['./writing-diagnostics'], { - types: './dist/writing-diagnostics/index.d.ts', - import: './dist/cwl-writing-diagnostics.js', - require: './dist/cwl-writing-diagnostics.cjs', - }); - assert.match( - packageJson.scripts.build, - /vite build --config vite\.writing-diagnostics\.config\.ts/u, - ); - assert.match( - packageJson.scripts['verify:package'], - /verify-writing-diagnostics-subpath-package\.mjs/u, - ); -});