Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions docs/doctoring/w3c-text-position-selector-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# W3C text-position selector evidence

Status: Implemented on active PR

## Purpose

Inkspan already exposes revision-scoped ProseMirror selection evidence. That contract is intentionally local to one editor state: ProseMirror positions are structural positions, not portable W3C text offsets. This active change adds a second, privacy-minimized interoperability representation that binds a W3C `TextPositionSelector` to the exact same immutable document revision without copying selected text into ordinary evidence metadata.

## Standards authority

The W3C *Web Annotation Data Model* Recommendation defines `TextPositionSelector` using an inclusive `start` and exclusive `end` in a normalized text representation. Its text-position processing model counts Unicode code points rather than implementation code units and cautions that selection boundaries should not split grapheme clusters. Position-only selectors avoid copying quote text into the annotation graph, but they are sensitive to source changes; Inkspan therefore binds every selector to an exact revision rather than claiming durable cross-revision anchoring.

ProseMirror remains the editor-structure authority. Its document positions are tree-structural coordinates, and `Node.textBetween(from, to, blockSeparator, leafText)` is the primitive used by the versioned Inkspan projection. A ProseMirror position is never relabeled as a W3C position by identity.

ECMA-402 13th edition, June 2026 is the current published ECMAScript internationalization standard. Inkspan uses `Intl.Segmenter` with `granularity: 'grapheme'` to reject selection boundaries that do not coincide with grapheme-cluster boundaries. A runtime lacking that capability fails closed with the stable `segmenter_unavailable` classification instead of silently weakening the evidence contract.

## Projection version 1

`textProjection` is part of the public evidence because selector offsets are meaningless without a deterministic projection identity.

Projection v1 is:

- `id = "inkspan-prosemirror-text"`;
- `version = 1`;
- logical ProseMirror document order, independent of visual bidirectional rendering order;
- U+000A LINE FEED between block boundaries where ProseMirror `textBetween` inserts the configured block separator;
- U+FFFC OBJECT REPLACEMENT CHARACTER for supported non-text leaf nodes;
- Unicode-code-point counting for W3C `start` and `end`;
- inclusive `start` and exclusive `end`;
- grapheme-cluster boundary validation before evidence is returned.

Array/tree order and actual text content remain authoritative. The projection does not normalize Unicode text, reorder bidirectional text visually, or invent source quote text.

## Atomicity

`getTextPositionSelectorEvidence()` captures one `editor.state` before asynchronous digest work begins. The projection and selector are derived from that captured `state.doc` and `state.selection`; the document envelope used for SHA-256 revision derivation is produced from the same captured `state.doc`. A live editor mutation after digest work starts cannot change the pending evidence object.

The returned top-level evidence, `selector`, and `textProjection` are frozen. Ordinary evidence contains no selected text, surrounding quote, complete document envelope, actor, tenant, timestamp, model identity, authorization decision, transport result, signature, or durable-write claim.

## Failure semantics

- Before editor creation, the handle resolves to `null`, matching the existing revision-scoped selection fallback.
- A selection boundary inside a grapheme cluster fails with `TextPositionSelectorEvidenceError.code = "grapheme_boundary"`.
- Absence of supported `Intl.Segmenter` grapheme segmentation fails with `code = "segmenter_unavailable"`.
- Existing document-envelope and digest validation failures retain their own fail-closed behavior.
- The API never silently adjusts an invalid boundary to a nearby grapheme boundary because doing so would change the user's selected range without explicit authority.

## Privacy and ownership

Inkspan owns only the deterministic projection and exact-revision selector evidence. Hosts own annotation identifiers and bodies, source-resource IRI policy, authentication, authorization, tenant isolation, durable persistence, retention, audit, collaborative anchors, re-anchoring after revisions, publication, and any W3C Annotation graph stored or transmitted outside the editor.

A revision digest plus text-position selector proves neither who selected the range, when it was selected, whether it was authorized, nor whether an annotation was durably accepted. Hosts must compare the bound revision before reusing the positions. If the document changed, the host chooses compare, merge, fork, reload, or a separately designed collaborative re-anchoring strategy.

## Compatibility and rollback

Projection semantics are versioned. A future change to block separators, leaf representations, normalization, code-point interpretation, or grapheme policy must publish a new projection version rather than silently reinterpret stored v1 offsets. Unknown projection versions must fail closed in any future parser/consumer.

Rollback removes the new selector API while leaving the pre-existing ProseMirror revision-scoped selection evidence intact. Rollback does not authorize a host to reinterpret existing v1 W3C selectors as ProseMirror coordinates.

## Verification

Permanent tests cover astral Unicode code points, bidirectional multi-block logical order, U+FFFC leaf-node projection, combining-mark grapheme rejection, unavailable-segmenter failure, same-state atomicity under delayed hashing, frozen evidence, pre-editor null behavior, and absence of source text in ordinary evidence. The repository's exact 100% owned production coverage gate applies to the implementation.

## References — APA 7th

Ecma International. (2026). *ECMA-402: ECMAScript 2026 internationalization API specification* (13th ed.). https://ecma-international.org/publications-and-standards/standards/ecma-402/

ProseMirror. (n.d.). *ProseMirror reference manual*. Retrieved August 10, 2026, from https://prosemirror.net/docs/ref/
Comment thread
seonghobae marked this conversation as resolved.

World Wide Web Consortium. (2017, February 23). *Web Annotation Data Model*. https://www.w3.org/TR/annotation-model/
96 changes: 74 additions & 22 deletions docs/selection-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,26 +73,69 @@ any explicit re-anchor, compare, merge, fork, durable comment, or collaborative
relative-position workflow and must revalidate authorization and the target
revision before mutation.

This API is deliberately revision-scoped rather than a generic web-annotation
selector. ProseMirror positions are structural positions inside one ProseMirror
document tree. They are not W3C `TextPositionSelector` values, which identify
Unicode code-point offsets in a normalized textual representation, and Inkspan
does not silently convert between those coordinate systems. Hosts that need W3C
Web Annotation interoperability must define and test a separate text projection,
selector conversion, state/provenance model, and re-anchoring policy.

The local SHA-256 revision is equality evidence only. It is not a signature,
authorization grant, user or tenant identifier, server-selected durable ETag,
proof that a review was accepted, or proof that a persistence transaction
committed. Durable services remain responsible for authenticated atomic
concurrency and audit semantics.

## W3C text-position selector evidence

The active W3C interoperability line adds a separate imperative capture rather
than relabeling ProseMirror structural positions:

```tsx
const evidence =
await editorRef.current?.getTextPositionSelectorEvidence();

if (evidence) {
publishAnnotationProposal({
expectedDocumentRevision: evidence.revision.strongEntityTag,
selector: evidence.selector,
textProjection: evidence.textProjection,
});
}
```

`getTextPositionSelectorEvidence()` captures one immutable editor state, derives
the selector projection and document envelope from that same state, and only
then performs asynchronous SHA-256 revision derivation. The returned evidence is
frozen and contains no selected quote text.

Projection version 1 is explicitly identified as
`inkspan-prosemirror-text` version `1`. It uses logical ProseMirror document
order, U+000A LINE FEED as the configured block separator, and U+FFFC OBJECT
REPLACEMENT CHARACTER for supported non-text leaf nodes. `selector.start` is an
inclusive Unicode-code-point offset and `selector.end` is exclusive. Visual
bidirectional reordering does not alter the logical text stream.

Selection boundaries must coincide with grapheme-cluster boundaries. Inkspan
uses `Intl.Segmenter` grapheme segmentation for this check. A boundary inside a
grapheme cluster fails with `grapheme_boundary`; a runtime without the required
segmenter fails with `segmenter_unavailable`. Inkspan never silently moves an
invalid boundary to make an annotation appear valid.

This selector remains revision-scoped. It is not a durable cross-revision
anchor, `TextQuoteSelector`, actor identity, authorization record, timestamp,
signature, or persistence receipt. Hosts own source-resource identifiers,
annotation bodies and identifiers, publication, storage, authorization, tenant
policy, and any re-anchoring after the document revision changes.

The exact rationale, projection contract, privacy boundary, rollback policy, and
APA 7 references are recorded in
`docs/doctoring/w3c-text-position-selector-evidence.md`.

## Position semantics

Selection values are ProseMirror document positions, not DOM offsets, Markdown
character indexes, HTML byte offsets, or durable annotation identifiers. A
snapshot describes the editor state at the time of the callback. Any subsequent
transaction can remap or invalidate those coordinates.
`CwlEditorSelectionSnapshot` values are ProseMirror document positions, not DOM
offsets, Markdown character indexes, HTML byte offsets, W3C text positions, or
durable annotation identifiers. A snapshot describes the editor state at the
time of the callback. Any subsequent transaction can remap or invalidate those
coordinates.

W3C text-position evidence is a distinct coordinate system with an explicit
projection identity. Consumers must not mix the two systems even when numerical
values happen to be equal for a simple document.

Hosts that perform work synchronously can inspect or transform the current
selection through the supplied editor. Hosts that defer work across document
Expand Down Expand Up @@ -127,7 +170,7 @@ or trusted audit record. Hosts remain responsible for document authorization,
operation-level permission checks, content classification, telemetry minimization,
and validating any later mutation against the current authorized document.

The callback and revision-scoped capture perform no network request, read no
The callback and revision-scoped captures perform no network request, read no
environment variable, and introduce no transport, persistence, database, or
naruon-specific runtime dependency. They preserve Inkspan's modular MSA boundary
and require no database object or identifier.
Expand All @@ -150,11 +193,14 @@ accessible name, and return focus predictably when the control closes.
- [ProseMirror guide: document positions and selection](https://prosemirror.net/docs/guide/)
— immutable editor state, document-relative positions, and selection
coordinates.
- [ProseMirror reference: Selection](https://prosemirror.net/docs/ref/#state.Selection)
— `anchor`, `head`, `from`, `to`, and mapping behavior.
- [ProseMirror reference](https://prosemirror.net/docs/ref/) — `Selection`,
immutable document nodes, and `Node.textBetween` projection semantics.
- [W3C Web Annotation Data Model](https://www.w3.org/TR/annotation-model/)
— interoperable selector semantics, including the materially different
Unicode-code-point `TextPositionSelector` model and selector-state guidance.
— interoperable selector semantics including Unicode-code-point
`TextPositionSelector` positions and selector-state guidance.
- [ECMA-402](https://ecma-international.org/publications-and-standards/standards/ecma-402/)
— the current published ECMAScript internationalization specification that
defines `Intl.Segmenter`.
- [RFC 9110, HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110)
— strong and weak entity-tag semantics and conditional-request boundaries.

Expand All @@ -163,9 +209,15 @@ accessible name, and return focus predictably when the control closes.
The TypeScript suite verifies absent callbacks, callbacks attached after mount,
live callback replacement, stable editor identity, caret and range snapshots,
and parity between standalone and provider-neutral collaborative editors. It
also verifies that revision-scoped range and caret evidence is frozen, contains
no selected text or complete envelope, remains bound to the pre-hash document
state while later edits and selection moves occur, and returns `null` before an
editor exists. The public types are compiled through the packed-package consumer
gate under the repository-wide 100% statement, branch, function, and line
also verifies that revision-scoped range/caret evidence is frozen, contains no
selected text or complete envelope, remains bound to the pre-hash document state
while later edits and selection moves occur, and returns `null` before an editor
exists.

The W3C selector suite additionally verifies astral Unicode code points,
multi-block bidirectional logical order, supported leaf-node projection,
grapheme-boundary rejection, deterministic failure when grapheme segmentation is
unavailable, same-state revision atomicity, frozen evidence, and source-text
omission. Public declarations and packed-package consumers remain subject to the
repository-wide exact 100% production statement, branch, function, and line
coverage policy.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"test:package-config": "node --test ./scripts/revision-evidence-consumer-config.test.mjs ./scripts/release-metadata.test.mjs",
"verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs"
"verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
Expand Down
107 changes: 107 additions & 0 deletions scripts/verify-text-position-selector-package.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';

const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const packageJson = JSON.parse(
readFileSync(join(repositoryRoot, 'package.json'), 'utf8'),
);
const packageName = packageJson.name;
const verificationDirectory = mkdtempSync(
join(repositoryRoot, '.text-position-selector-verification-'),
);

/** Execute one strict package-consumer verification command. */
function run(command, argumentsList) {
return execFileSync(command, argumentsList, {
cwd: repositoryRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'inherit'],
});
}

try {
const esmPackage = await import(packageName);
assert.equal(
esmPackage.TEXT_POSITION_PROJECTION_ID,
'inkspan-prosemirror-text',
);
assert.equal(esmPackage.TEXT_POSITION_PROJECTION_VERSION, 1);
assert.equal(typeof esmPackage.TextPositionSelectorEvidenceError, 'function');
assert.equal(typeof esmPackage.createTextPositionSelector, 'function');

const require = createRequire(import.meta.url);
const commonJsPackage = require(packageName);
assert.equal(
commonJsPackage.TEXT_POSITION_PROJECTION_ID,
'inkspan-prosemirror-text',
);
assert.equal(commonJsPackage.TEXT_POSITION_PROJECTION_VERSION, 1);
assert.equal(
typeof commonJsPackage.TextPositionSelectorEvidenceError,
'function',
);
assert.equal(typeof commonJsPackage.createTextPositionSelector, 'function');

const consumerPath = join(verificationDirectory, 'consumer.ts');
writeFileSync(
consumerPath,
`import {
TEXT_POSITION_PROJECTION_ID,
TEXT_POSITION_PROJECTION_VERSION,
TextPositionSelectorEvidenceError,
type CwlEditorHandle,
type CwlEditorTextPositionSelectorEvidence,
type CwlEditorTextProjectionIdentity,
type TextPositionSelectorEvidenceErrorCode,
} from '${packageName}';

declare const handle: CwlEditorHandle;
const captured: Promise<CwlEditorTextPositionSelectorEvidence | null> =
handle.getTextPositionSelectorEvidence();
const projection: CwlEditorTextProjectionIdentity = {
id: TEXT_POSITION_PROJECTION_ID,
version: TEXT_POSITION_PROJECTION_VERSION,
};
const failureCode: TextPositionSelectorEvidenceErrorCode =
'segmenter_unavailable';
const failure = new TextPositionSelectorEvidenceError(failureCode);
const checked: Promise<void> = captured.then((evidence) => {
if (evidence === null) return;
const start: number = evidence.selector.start;
const end: number = evidence.selector.end;
const tag: string = evidence.revision.strongEntityTag;
void [start, end, tag, projection];
});
void [failure.code, checked];
`,
'utf8',
);

run('pnpm', [
'exec',
'tsc',
'--noEmit',
'--strict',
'--skipLibCheck',
'false',
'--module',
'NodeNext',
'--moduleResolution',
'NodeNext',
'--target',
'ES2022',
'--lib',
'ES2022,DOM,DOM.Iterable',
consumerPath,
]);

console.log(
`Verified ${packageName}: W3C text-position selector ESM, CommonJS, and strict TypeScript consumer contracts.`,
);
} finally {
rmSync(verificationDirectory, { recursive: true, force: true });
}
Loading
Loading