diff --git a/CHANGELOG.md b/CHANGELOG.md index 08df103c..acd44a9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,37 @@ Historical release entries from **0.1.0 through 0.5.27** are preserved verbatim ## [Unreleased] +### Added +- Added `createDocumentAutosaveSession()` to bind every single-flight save to the exact server-issued strong entity tag loaded or last committed by the host, without adding transport, persistence, credentials, tenancy, or provider coupling +- Added `isStrongHttpEntityTag()` for fail-closed RFC 9110 quoted opaque-tag validation without trimming or repair +- Added immutable durable save request, result, session, recovery, and document-free snapshot contracts to the framework-independent `autosave` package surface + +### Reliability +- Successful durable writes advance the next `If-Match` base only from the host callback's validated server-selected replacement tag +- Conflict, malformed result, hostile reflection, promise-assimilation failure, and transport failure preserve the previous durable validator until explicit authenticated recovery supplies a new strong tag +- Retained work resumes only after `resume(nextStrongEntityTag)` installs the recovered durable base before the next callback begins +- Recovery validators are validated consistently before lifecycle inspection; a valid no-op resume cannot replace the current durable base, and an unexpectedly declined blocked transition restores the previous validator +- Malformed recovery validators now raise the dedicated public `invalid_recovery_validator` code and redacted recovery message instead of being misclassified as malformed session-construction options +- `flush()` reacquires the current terminal queue state after asynchronous wrapper boundaries, following recovery or shutdown races until lifecycle fields and `durableStrongEntityTag` describe one coherent idle, blocked, or closed logical moment + +### Security +- Initial and replacement durable validators reject weak, unquoted, whitespace-containing, list, wildcard, control-character, and out-of-range values before they can enter host transport +- Callback results are exact-shape validated through property descriptors; document bodies, validators, callback values, and private exceptions never enter public error messages +- Session snapshots remain frozen and document-free; entity tags are still tenant-confidential equality metadata rather than authorization, signatures, tenant membership, or durable audit evidence + +### Tests +- Added deterministic sequential and concurrently queued validator handoff, conflict recovery, lifecycle-independent recovery validation, malformed option, missing and symbol-keyed option, malformed callback result, hostile reflection, promise assimilation, transport failure, frozen request, shutdown, and snapshot tests under repository-wide 100% production statement and branch coverage gates +- Added explicit regression cases for control characters, out-of-range Unicode, list-form values, wildcards, no-op recovery, and recovered-validator installation before retained work starts +- Added deterministic recovery-before-wrapper and close-before-wrapper concurrency regressions that prevent temporally mixed public flush snapshots +- Extended isolated packed-artifact ESM, CommonJS, and strict TypeScript consumers to prove the durable session and strong-tag validator work without React, React DOM, TipTap, ProseMirror, or Yjs installed + ### Documentation -- Added buyer-visible autosave onboarding, explicit `autosave` and `revision-evidence` distribution surfaces, and npm persistence discovery metadata without changing runtime behavior or package version +- Added buyer-visible autosave onboarding, explicit `autosave` and `revision-evidence` distribution surfaces, and npm persistence discovery metadata without changing the released package version +- Updated package discovery metadata to identify the server-validator-bound durable autosave session alongside the lower-level single-flight coordinator - Corrected the autosave onboarding so initial and replacement validators are checked before use and come from the durable host's server-issued strong `ETag` rather than local revision evidence; missing, weak, or malformed validators now fail closed in the example +- Documented that host-owned save callbacks must apply their own timeout or abort signal because an unresolved callback intentionally retains the active single-flight operation; retry policy remains host-owned - Added a deterministic repository contract test and APA 7th-style doctoring for README, npm-search, Node.js package-export discoverability, RFC 9110 validator ownership, and quoted opaque-tag syntax - +- Added operator and doctoring evidence for the durable autosave session, coherent recovery-time flush snapshots, host ownership boundaries, exact-head verification, and acquisition-review scope ## [0.5.28] — 2026-08-05 diff --git a/docs/doctoring/durable-autosave-session.md b/docs/doctoring/durable-autosave-session.md new file mode 100644 index 00000000..eedf1918 --- /dev/null +++ b/docs/doctoring/durable-autosave-session.md @@ -0,0 +1,91 @@ +# Doctoring record: Durable autosave validator session + +**Date:** 2026-08-05 +**Target release:** Unreleased after Inkspan 0.5.28 +**Decision owner:** ContextualWisdomLab +**Scope:** Provider-neutral handoff of server-issued strong entity tags across the framework-independent single-flight autosave queue. + +## Buyer-visible gap + +Inkspan 0.5.28 supplies deterministic revision evidence and a bounded single-flight autosave queue. A host nevertheless has to retain a mutable durable `ETag` beside the queue, put the current value into every `If-Match` request, validate the server's replacement value, advance it only after a committed write, and replace it correctly after conflict recovery. That repeated closure is security-sensitive integration code. A missing, weak, malformed, stale, or locally derived validator can defeat lost-update protection even when the queue itself remains correctly ordered. + +The selected bounded slice adds a durable autosave session to the existing framework-independent package. It makes the required concurrency base explicit in every host callback and owns only the local validator handoff. This reduces buyer integration risk without moving transport, authorization, tenant isolation, persistence, credentials, migration, retention, audit, retry, idempotency, conflict UX, or model-use policy into Inkspan. + +## Decision + +`createDocumentAutosaveSession()` combines one existing single-flight queue with one server-issued strong entity tag. The initial tag must come from the durable representation loaded by the host. For each active revision the session invokes the host callback with a frozen request containing: + +- detached, deeply frozen Inkspan document revision evidence; and +- the exact current `ifMatchStrongEntityTag` that the host must compare atomically in its authorized durable write transaction. + +A committed callback result must contain `status: "saved"` and the server-selected `nextStrongEntityTag`. The session advances its durable base only after that exact result passes fail-closed validation. A conflict result does not alter the base. A malformed result, inaccessible reflection surface, promise-assimilation failure, rejected callback, or thrown callback blocks progression through the existing redacted queue failure path and leaves the previous durable tag intact. + +`resume(nextStrongEntityTag)` is intentionally explicit. It succeeds only while the session is blocked and only after validating one replacement strong entity tag obtained through a host-owned authenticated reload, compare/merge/fork decision, or idempotency confirmation. The replacement is installed before retained work resumes, so the next callback cannot observe the stale pre-recovery base. + +## HTTP validator boundary + +RFC 9110 defines an entity tag as an opaque validator selected by the origin server. `If-Match` uses the strong comparison function and is commonly used with state-changing methods to prevent the lost-update problem. Inkspan's SHA-256 revision evidence is deterministic local equality evidence for its canonical envelope; it is not automatically the durable service's selected representation validator. + +`isStrongHttpEntityTag()` therefore accepts exactly one quoted opaque tag matching the RFC 9110 character grammar and rejects weak `W/` tags, unquoted values, whitespace, controls, lists, wildcards, Unicode beyond the HTTP `obs-text` range, and any input requiring trimming or repair. Syntax validation does not establish authorization, freshness, tenant membership, or correspondence to a durable representation; the host remains responsible for all of those properties. + +## Concurrency and failure invariants + +1. Exactly one durable save callback can be active because the session delegates scheduling to the existing single-flight queue. +2. Every callback receives the durable tag current at callback start, not the local content revision and not a later mutable value. +3. The durable tag advances only after an exact valid `saved` result supplies a valid server-selected replacement tag. +4. Conflict, invalid result, reflection failure, promise-assimilation failure, and callback failure never advance the durable tag. +5. Blocked retained work cannot restart until the host supplies a valid recovered durable tag. +6. The recovered tag is installed before queue progression resumes. +7. Session snapshots are frozen and contain no document body, callback result, credential, tenant identifier, or original exception. +8. Public errors remain redacted and machine-readable through the existing `DocumentAutosaveQueueError` categories. +9. The runtime and declaration graph contain no React, React DOM, TipTap, ProseMirror, Yjs, DOM, provider SDK, storage driver, credential, or network client dependency. +10. No database object, migration, scheduler, model call, reviewer identity, or credential-chain change is introduced. +11. `flush()` re-reads the current queue state after each awaited terminal notification. If recovery or shutdown starts before the public wrapper continuation executes, it follows the new transition until the session is currently idle, blocked, or closed, then combines that lifecycle state and durable validator without another asynchronous boundary. + +These invariants create a linearizable process-local handoff around host callbacks and public session snapshots. They do not create a distributed transaction. Durable correctness still requires the host to compare the supplied base and commit the new representation atomically inside the same authorized storage transaction. + +## Security and privacy analysis + +The session validates options and callback outcomes fail-closed. Callback result inspection uses exact own-property descriptors rather than ordinary status or tag getters. Promise assimilation is part of callback execution: a returned thenable whose `then` access throws is treated as an ambiguous host callback failure rather than as trustworthy result data. Reflection failures after successful assimilation are treated as invalid results. Neither path copies private values or exceptions into the public error. + +Entity tags may correlate representations and must be treated as tenant-confidential metadata. They must not become public URLs, metric-label values, analytics dimensions, unauthenticated log fields, authorization tokens, or durable audit substitutes. The host applies its existing access control, encryption, retention, residency, redaction, and audit policy. + +No database object is added. A host that persists session or document state must use database object names containing at least two descriptive words, with `snake_case` preferred unless the target ecosystem requires valid CamelCase or PascalCase. + +## Modular ownership matrix + +| Concern | Inkspan durable session | Host or CWL service | +| --- | --- | --- | +| Strong entity-tag syntax validation | Owns | Supplies server-selected values | +| One-active/one-pending local ordering | Owns | Chooses debounce and enqueue timing | +| Durable validator handoff between callbacks | Owns | Returns committed replacement tag | +| Transport and credentials | Does not own | Owns | +| Authentication and authorization | Does not own | Owns | +| Tenant isolation | Does not own | Owns | +| Atomic durable `If-Match` comparison and commit | Does not own | Owns | +| Persistence, migration, backup, and rollback | Does not own | Owns | +| Retention, residency, redaction, and audit storage | Does not own | Owns | +| Retry, offline, idempotency, and conflict UX | Does not own | Owns | +| Model provider and model-use policy | Does not own | Owns | + +This boundary preserves standalone operation and allows naruon compose surfaces, `ui.panel`, contextual-orchestrator workflows, and other CWL hosts to supply their own authorized service adapters without importing host policy into the editor package. + +## Verification evidence + +Deterministic unit and integration tests cover strong-tag grammar, malformed options, hostile option getters, sequential server-validator handoff, frozen callback requests, conflict retention, explicit recovered-tag installation, invalid exact callback shapes, weak replacement validators, inaccessible reflection, promise-assimilation failure, thrown transport failure, flush, shutdown, and document-free snapshots. + +Test-first microtask-ordering regressions prove that an internal blocked or idle notification cannot escape as a stale lifecycle snapshot after a request continuation has already installed a recovered validator, restarted retained work, or begun shutdown. The red recovery commit preserves the inconsistent `blocked` plus recovered-validator result; the implementation re-reads current queue state and waits for the resumed or closing transition before returning one coherent terminal snapshot. + +The exact packed npm artifact is executed through ESM and CommonJS and compiled as a strict TypeScript consumer in an isolated temporary package tree that contains no React, React DOM, TipTap, ProseMirror, or Yjs installation. The packed tests prove that the exported session supplies the initial server tag, adopts only the returned replacement tag, preserves framework independence, and exposes complete declarations. + +Repository acceptance remains 100% production statement, branch, function, and line coverage; TypeScript type checking; deterministic library and demo builds; isolated package-consumer verification; security and supply-chain scans; exact-current-head automated review; independent approval; and branch protection. A version bump, tag, registry publication, provenance, or immutable release publication is prohibited until all integrated release-acceptance gates pass on the exact merge candidate. + +## References (APA 7th edition) + +Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). RFC Editor. https://doi.org/10.17487/RFC9110 + +Herlihy, M. P., & Wing, J. M. (1990). Linearizability: A correctness condition for concurrent objects. *ACM Transactions on Programming Languages and Systems, 12*(3), 463–492. https://doi.org/10.1145/78969.78972 + +International Organization for Standardization. (2023). *Systems and software engineering—Systems and software quality requirements and evaluation (SQuaRE)—Product quality model* (ISO/IEC 25010:2023). https://www.iso.org/standard/78176.html + +Rundgren, A., Jordan, B., & Erdtman, S. (2020). *JSON Canonicalization Scheme (JCS)* (RFC 8785). RFC Editor. https://doi.org/10.17487/RFC8785 diff --git a/docs/document-autosave.md b/docs/document-autosave.md index c14deba2..7f0004c3 100644 --- a/docs/document-autosave.md +++ b/docs/document-autosave.md @@ -1,15 +1,22 @@ # Provider-neutral document autosave -Inkspan 0.5.28 adds a framework-independent autosave coordination surface at -`@contextualwisdomlab/cwl-editor/autosave`. It accepts immutable document +Inkspan exposes framework-independent autosave coordination at +`@contextualwisdomlab/cwl-editor/autosave`. The package accepts immutable document revision evidence, ensures that only one host save callback runs at a time, retains at most one not-yet-started revision, and shares one internal pending flush promise across concurrent quiescence checks. -Inkspan coordinates local ordering only. The host application continues to own -transport, authentication, authorization, tenant isolation, durable storage, -credentials, schema migration, retention, audit storage, retry budgets, and -conflict-resolution user experience. +Use `createDocumentAutosaveSession()` for ordinary durable HTTP persistence. It +binds every callback to the exact server-issued strong entity tag loaded or last +committed by the host. Use the lower-level `createDocumentAutosaveQueue()` only +when the host deliberately owns a different version-token protocol and can prove +its validator handoff separately. + +Inkspan owns deterministic local ordering, immutable evidence validation, and +server-validator handoff only. The host continues to own transport, +authentication, authorization, tenant isolation, durable storage, credentials, +schema migration, backup, rollback, retention, audit storage, retry budgets, +offline and idempotency policy, and conflict-resolution user experience. ## Install and import @@ -18,22 +25,23 @@ server, worker, queue, or test code: ```ts import { - createDocumentAutosaveQueue, - type DocumentAutosaveSaveResult, + createDocumentAutosaveSession, + type DocumentAutosaveDurableSaveResult, } from '@contextualwisdomlab/cwl-editor/autosave'; import { createDocumentEnvelopeRevisionEvidence, } from '@contextualwisdomlab/cwl-editor/revision-evidence'; ``` -The autosave subpath has no React, React DOM, TipTap, ProseMirror, or Yjs runtime -dependency. It does not create timers, perform network requests, read environment -variables, or access storage. +The autosave subpath has no React, React DOM, TipTap, ProseMirror, Yjs, DOM, +provider SDK, storage driver, or network runtime dependency. It does not create +timers, perform requests, read environment variables, access storage, or choose +a tenant. ## Capture immutable revision evidence Create evidence through Inkspan rather than constructing a look-alike object. -The envelope and SHA-256 strong entity tag are derived from the same normalized, +The envelope and local SHA-256 revision are derived from the same normalized, frozen document capture: ```ts @@ -48,36 +56,31 @@ An editor host may instead use `CwlEditorHandle.getDocumentEnvelopeRevisionEvidence()` so the document and its revision are captured atomically from the active editor. -The queue validates the public evidence shape and consistency of the declared -SHA-256 digest and strong tag. It does not recompute the digest. Use an -Inkspan-created evidence value, or apply an equivalent private validation and -canonicalization boundary before enqueueing it. +The autosave boundary validates the public evidence shape, deep immutability, +active schema, SHA-256 metadata, and resource ceilings. It does not recompute the +digest. Use an Inkspan-created evidence value, or apply an equivalent trusted +private canonicalization and hashing boundary before enqueueing it. -## Create the queue +The evidence revision is local equality evidence. It is not automatically the +origin server's durable HTTP `ETag`, authorization decision, tenant identifier, +signature, or proof that a durable transaction committed. -The save callback receives one immutable evidence value. The host must separately -retain the strong entity tag of the durable revision that it loaded or last -committed. Use that durable base tag as `If-Match`; the submitted evidence tag -identifies the proposed new revision and becomes the next base only after the -write commits. +## Load the durable base validator -Return `saved` only after the authorized durable transaction has committed. -Return `conflict` when the server rejects the durable base revision, normally as -HTTP `412 Precondition Failed` after an atomic `If-Match` comparison. +The host must load the document and the durable service's server-selected strong +`ETag` under the same authenticated document context. Missing, weak, malformed, +or inaccessible validators fail closed before the session is created. ```ts -let durableStrongEntityTag = loadedRevision.strongEntityTag; - -const autosaveQueue = createDocumentAutosaveQueue({ - async save(evidence): Promise { - const response = await fetch('/documents/current', { - method: 'PUT', - credentials: 'include', - headers: { - 'content-type': 'application/json', - 'if-match': durableStrongEntityTag, - }, - body: JSON.stringify(evidence.envelope), +const loadedResponse = await loadDocument(); +const loadedStrongEntityTag = loadedResponse.headers.get('ETag'); + +const autosaveSession = createDocumentAutosaveSession({ + initialStrongEntityTag: loadedStrongEntityTag ?? '', + async save(request): Promise { + const response = await saveDocument({ + envelope: request.evidence.envelope, + ifMatch: request.ifMatchStrongEntityTag, }); if (response.status === 412) { @@ -87,16 +90,39 @@ const autosaveQueue = createDocumentAutosaveQueue({ throw new Error('Private transport failure'); } - durableStrongEntityTag = evidence.revision.strongEntityTag; - return { status: 'saved' }; + return { + status: 'saved', + nextStrongEntityTag: response.headers.get('ETag') ?? '', + }; }, }); ``` +`createDocumentAutosaveSession()` validates the initial value immediately. It +also validates every replacement before advancing the next callback's +`If-Match` base. Empty, missing, weak, unquoted, whitespace-containing, list, +wildcard, control-character, and out-of-range values are rejected through a +redacted `DocumentAutosaveQueueError`; they never become a later transport +validator. + +The host callback must return `saved` only after the authorized durable +transaction commits. The returned `nextStrongEntityTag` must be the strong +validator selected by the durable service for the committed representation. +Return `conflict` when the service rejects the supplied base, normally as HTTP +`412 Precondition Failed` after an atomic `If-Match` comparison. + Do not copy private transport exceptions into public UI, logs, or telemetry. The -queue converts callback failures into a redacted `DocumentAutosaveQueueError`. -The original exception remains available only to the host's private transport -observability boundary. +session converts callback failures into a redacted queue error. The original +exception remains available only to the host's private transport observability +boundary. + +Every host save callback must apply a host-owned timeout or abort signal around +its transport and durable transaction. Inkspan intentionally cannot cancel +host-owned I/O. A callback that never settles retains the one active +single-flight request, prevents pending work from starting, and keeps `flush()` +and `close()` unresolved. Timeout handling, retry budgets, backoff, offline +policy, idempotency confirmation, and user notification remain host-owned; a +failed attempt must reject or throw without falsely returning `saved`. ## Enqueue editor changes @@ -104,7 +130,7 @@ Change detection and debounce timing remain host-owned. When the host decides a revision is ready, enqueue its immutable evidence: ```ts -const outcome = await autosaveQueue.enqueue(evidence); +const outcome = await autosaveSession.enqueue(evidence); switch (outcome.status) { case 'saved': @@ -123,17 +149,17 @@ switch (outcome.status) { } ``` -Requests for the same active or pending strong entity tag share one callback and -one promise. A newer different revision may replace only pending work. It never +Requests for the same active or pending local revision share one callback and one +promise. A newer different revision may replace only pending work. It never cancels or overlaps a callback that has already started. Inkspan retains at most one active document and one pending document regardless of edit frequency. -An `unchanged` outcome is emitted only when the requested revision is still -known to be durably current and no active or pending write can replace it. A -different active or pending write, a server conflict, an invalid callback result, -or an ambiguous callback failure invalidates that shortcut. Calling `resume()` -does not make the earlier durable assumption valid again; a later successful -save must establish a new known-durable revision. +An `unchanged` outcome is emitted only when the requested local revision is still +known to be the session's last successful revision and no active or pending write +can replace it. A different active or pending write, server conflict, invalid +callback result, promise-assimilation failure, or ambiguous transport failure +invalidates that shortcut. Recovery does not restore the shortcut; a later +successful save must establish it again. ## Recover from conflict or failure @@ -141,108 +167,144 @@ A conflict or callback failure blocks automatic progression. This is deliberate: Inkspan does not guess whether a retry is authorized, safe, or useful. ```ts -const snapshot = await autosaveQueue.flush(); +const snapshot = await autosaveSession.flush(); if (snapshot.state === 'blocked') { const recovery = await reloadCompareMergeOrForkUnderHostAuthorization(); - durableStrongEntityTag = recovery.currentDurableStrongEntityTag; - autosaveQueue.resume(); + + autosaveSession.resume(recovery.currentDurableStrongEntityTag); } ``` -Call `resume()` only after the host has completed its authenticated recovery -workflow. A conflict usually requires fetching the current durable revision, -showing or applying an accessible compare/merge/fork decision, updating the -host-owned durable base tag, and creating new revision evidence. A transport -failure usually requires host-specific retry budget, backoff, offline, and +Call `resume(nextStrongEntityTag)` only after the host completes its authenticated +recovery workflow. A conflict normally requires fetching the current durable +revision, showing or applying an accessible compare/merge/fork decision, and +creating new revision evidence. A transport failure normally requires +host-specific retry budget, backoff, offline, idempotency confirmation, and user-notification policy. -Resuming permits retained or newly enqueued work to run, but it deliberately does -not restore the previous duplicate-save shortcut. The first subsequent -successful callback establishes the next revision that the queue may safely -report as `unchanged` while quiescent. +The replacement durable tag is validated and installed before retained work +resumes. An invalid tag throws the machine-readable +`invalid_recovery_validator` error without clearing the blocked state. Calling +`resume()` while the session is not blocked returns `false` and does not change +the current durable base. -`flush()` resolves when the queue is idle, blocked, or closed. Concurrent calls +`flush()` resolves when the session is idle, blocked, or closed. Concurrent calls while work is active return the same pending promise, so repeated component, worker, or operator checks do not append unbounded internal waiters. It does not wait forever for an external conflict decision. +A recovery or shutdown decision can race with the asynchronous wrapper that +turns an internal queue snapshot into a public session snapshot. When recovery +or closing starts before that wrapper continuation runs, `flush()` follows the +new work until the session is currently idle, blocked, or closed. The returned +lifecycle fields and `durableStrongEntityTag` therefore describe one coherent +logical moment rather than combining a stale blocked snapshot with a newer +validator. + ## Shutdown Use `close()` during page, worker, or host lifecycle shutdown: ```ts -const finalSnapshot = await autosaveQueue.close(); +const finalSnapshot = await autosaveSession.close(); ``` Closing rejects new work, resolves not-yet-started work as `closed`, and allows -an active host callback to finish. Inkspan never aborts host transport. +an active host callback to finish. Inkspan never aborts host transport. The final +session snapshot includes the last accepted durable validator but no document +body or private callback value. + +## Lower-level queue + +`createDocumentAutosaveQueue()` remains available for hosts whose durable version +protocol is not an RFC 9110 entity tag or whose validator state is intentionally +owned elsewhere. Its callback receives only immutable revision evidence and +returns `saved` or `conflict`. + +A host choosing this primitive must independently prove that it: + +- loads the correct durable base under authorization; +- supplies that exact base to every compare-and-swap transaction; +- advances the base only from the committed service response; +- preserves the base across conflict and ambiguous failure; +- installs the recovered base before calling `resume()`; and +- rejects malformed or weak validators before transport. + +The durable session exists so ordinary hosts do not have to reimplement that +security-sensitive mutable closure. ## SSR, worker, and modular host integration The autosave subpath is safe to import in SSR, Node.js, web workers, service -workers, and provider-neutral queues because it has no DOM or framework runtime -requirement. Hosts should instantiate one queue per authorized document editing -context rather than placing tenant or user identifiers inside Inkspan. +workers, and provider-neutral queues because it has no DOM or editor-framework +runtime requirement. Instantiate one session per authorized document editing +context rather than placing tenant, user, room, or credential identifiers inside +Inkspan. For CWL and naruon integrations: - a compose service or `ui.panel` captures Inkspan revision evidence and owns accessible dirty, saving, blocked, retry, and conflict UI; -- a host service supplies authenticated transport and the durable base revision; +- a host service supplies authenticated transport and the initially loaded and + subsequently returned durable strong entity tags; - the persistence service performs tenant-scoped atomic `If-Match` comparison - and writes the envelope in one transaction; + and envelope commit in one transaction; +- contextual-orchestrator may coordinate host policy but does not become part of + the Inkspan runtime dependency graph; - Inkspan never receives provider credentials, room identifiers, database - connections, retention policy, or model-use policy; -- queue snapshots may drive local presentation but are not a durable audit log. + connections, retention policy, or model-use policy; and +- session snapshots may drive local presentation but are not a durable audit log. ## Snapshot and observability rules -`getSnapshot()` returns frozen lifecycle metadata containing active, pending, and -last-saved strong entity tags. It never contains document bodies, callback -results, credentials, tenant identifiers, or original exceptions. +`getSnapshot()` returns frozen lifecycle metadata containing active, pending, +last-saved local revision tags, and the current durable server tag. It never +contains document bodies, callback results, credentials, tenant identifiers, or +original exceptions. -`lastSavedStrongEntityTag` records the most recent revision for which the host -reported `saved`; after conflict or callback uncertainty it is historical -metadata, not proof that the same revision remains current and not permission to -skip a future host write. +`lastSavedStrongEntityTag` is the local evidence revision most recently reported +as saved. `durableStrongEntityTag` is the current server-selected base for the +next compare-and-swap. They may differ and must not be substituted for each +other. -Strong entity tags can correlate identical canonical documents. Treat them as -tenant-confidential metadata: +Both values can correlate representations. Treat them as tenant-confidential +metadata: - do not use document bodies or revision tags as metric-label values; - do not place them in public URLs, unauthenticated logs, analytics events, or exception messages; -- use host-generated descriptive trace identifiers for correlation; +- use host-generated descriptive trace identifiers for correlation; and - apply the same authorization, retention, residency, encryption, and audit controls used for the durable document. ## Ownership matrix -| Concern | Inkspan | Host application | +| Concern | Inkspan session | Host application or service | | --- | --- | --- | -| Immutable evidence shape validation | Owns | Uses Inkspan evidence APIs | -| Single-flight local ordering | Owns | Enqueues approved revisions | -| Active/pending/flush-waiter bounds | Owns | Bounds external callers and transport | -| Pending revision coalescing | Owns | Chooses change/debounce timing | -| Durable base revision tracking | Does not own | Owns | +| Immutable evidence validation | Owns | Uses Inkspan evidence APIs | +| Single-flight ordering and bounded pending work | Owns | Chooses change/debounce timing | +| Strong entity-tag syntax validation | Owns | Supplies server-selected values | +| Durable validator handoff between callbacks | Owns | Returns committed replacement value | | Transport and credentials | Does not own | Owns | | Authentication and authorization | Does not own | Owns | | Tenant isolation | Does not own | Owns | -| Durable atomic `If-Match` | Does not own | Owns | -| Storage and migration | Does not own | Owns | -| Retention and audit storage | Does not own | Owns | -| Retry and offline policy | Does not own | Owns | -| Conflict comparison and UX | Does not own | Owns | +| Atomic durable `If-Match` comparison and commit | Does not own | Owns | +| Storage, migration, backup, and rollback | Does not own | Owns | +| Retention, residency, redaction, and audit storage | Does not own | Owns | +| Retry, offline, idempotency, and conflict UX | Does not own | Owns | +| Model provider and model-use policy | Does not own | Owns | ## Standards boundary -The queue provides a linearizable local coordination surface for callback -invocations. It does not create a distributed transaction. Durable lost-update -prevention requires the host to compare the previously loaded or committed base -validator and write the proposed new document atomically in the same authorized -storage transaction, consistent with RFC 9110 `If-Match` semantics. +The session provides a linearizable local coordination and validator-handoff +surface for callback invocations. It does not create a distributed transaction. +Durable lost-update prevention requires the host to compare the supplied +previously loaded or committed server validator and write the proposed document +atomically in the same authorized storage transaction, consistent with RFC 9110 +`If-Match` semantics. -See `docs/doctoring/document-autosave-queue.md` for the architectural decision, -security analysis, verification plan, and APA 7th references to RFC 9110, RFC +See `docs/doctoring/document-autosave-queue.md` and +`docs/doctoring/durable-autosave-session.md` for the architectural decisions, +security analyses, verification plans, and APA 7th references to RFC 9110, RFC 8785, Herlihy and Wing (1990), and ISO/IEC 25010:2023. diff --git a/package.json b/package.json index 1c54f2ca..f5d3398c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@contextualwisdomlab/cwl-editor", "version": "0.5.28", - "description": "Inkspan — commercial-grade Markdown + HTML WYSIWYG editor module (TipTap/ProseMirror, MIT) with SSR-safe client hydration, native form integration, lossless document snapshots, preparse-resource-bounded duplicate-name-safe versioned persistence envelopes, canonical JSON plus strict UTF-8 byte round trips, SHA-256 strong revision validators, framework-independent pure and imperative atomic revision-envelope evidence, local If-Match restore guards, atomic before/after revision-envelope transition evidence for optimistic concurrency, and a provider-neutral bounded single-flight autosave coordinator, one-call imperative envelope export and atomic active-schema restore, host-owned lifecycle callbacks, strict link and image policies, accessible editing controls, provider-neutral Yjs collaboration, a standalone base64 converter, and bundled offline multilingual Noto Sans fonts.", + "description": "Inkspan — commercial-grade Markdown + HTML WYSIWYG editor module (TipTap/ProseMirror, MIT) with SSR-safe client hydration, native form integration, lossless document snapshots, preparse-resource-bounded duplicate-name-safe versioned persistence envelopes, canonical JSON plus strict UTF-8 byte round trips, SHA-256 strong revision validators, framework-independent pure and imperative atomic revision-envelope evidence, local If-Match restore guards, atomic before/after revision-envelope transition evidence for optimistic concurrency, a provider-neutral bounded single-flight autosave coordinator, and a server-validator-bound durable autosave session, one-call imperative envelope export and atomic active-schema restore, host-owned lifecycle callbacks, strict link and image policies, accessible editing controls, provider-neutral Yjs collaboration, a standalone base64 converter, and bundled offline multilingual Noto Sans fonts.", "license": "MIT", "author": "ContextualWisdomLab", "type": "module", diff --git a/src/autosave/index.ts b/src/autosave/index.ts index d0c3908c..276aa42a 100644 --- a/src/autosave/index.ts +++ b/src/autosave/index.ts @@ -15,6 +15,7 @@ export type DocumentAutosaveBlockedReason = 'conflict' | 'failure'; export type DocumentAutosaveQueueErrorCode = | 'invalid_options' | 'invalid_revision_evidence' + | 'invalid_recovery_validator' | 'host_save_failed' | 'invalid_save_result'; diff --git a/src/autosave/package.test.ts b/src/autosave/package.test.ts index abb93264..4ea42a7e 100644 --- a/src/autosave/package.test.ts +++ b/src/autosave/package.test.ts @@ -1,13 +1,16 @@ import { describe, expect, it } from 'vitest'; import { createDocumentAutosaveQueue, + createDocumentAutosaveSession, DocumentAutosaveQueueError, + isStrongHttpEntityTag, + type DocumentAutosaveDurableSaveRequest, type DocumentAutosaveRevisionEvidence, } from './package.js'; /** Create one exact frozen framework-free autosave evidence fixture. */ -function createEvidence(): DocumentAutosaveRevisionEvidence { - const digestHex = '41'.repeat(32); +function createEvidence(byte = '41'): DocumentAutosaveRevisionEvidence { + const digestHex = byte.repeat(32); return Object.freeze({ envelope: Object.freeze({ schemaId: 'https://inkspan.io/schemas/document-envelope/v1', @@ -96,3 +99,278 @@ describe('framework-free autosave package boundary', () => { expect(Object.isFrozen(error)).toBe(true); }); }); + +describe('durable autosave session', () => { + it.each([ + ['""', true], + ['"durable-revision"', true], + [`"opaque-${String.fromCharCode(0x80)}"`, true], + ['W/"weak"', false], + ['durable-revision', false], + ['"contains space"', false], + ['"contains\\quote"', true], + ['"contains"quote"', false], + [null, false], + ])('classifies RFC 9110 strong entity tags without repair', (candidate, expected) => { + expect(isStrongHttpEntityTag(candidate)).toBe(expected); + }); + + it('rejects malformed initial options without exposing source values', () => { + const save = () => ({ status: 'conflict' as const }); + + for (const malformedOptions of [null, 1]) { + expect(() => + createDocumentAutosaveSession(malformedOptions as never), + ).toThrowError(expect.objectContaining({ code: 'invalid_options' })); + } + for (const initialStrongEntityTag of ['W/"weak"', 'unquoted', '"space tag"']) { + expect(() => + createDocumentAutosaveSession({ initialStrongEntityTag, save }), + ).toThrowError( + expect.objectContaining({ code: 'invalid_options' }), + ); + } + expect(() => + createDocumentAutosaveSession({ + initialStrongEntityTag: '"valid"', + save: 1, + } as never), + ).toThrowError(expect.objectContaining({ code: 'invalid_options' })); + expect(() => + createDocumentAutosaveSession({ + initialStrongEntityTag: '"valid"', + save, + unexpectedOption: true, + } as never), + ).toThrowError(expect.objectContaining({ code: 'invalid_options' })); + + let optionGetterCalls = 0; + const accessorOptions = Object.defineProperties({}, { + initialStrongEntityTag: { + enumerable: true, + get() { + optionGetterCalls += 1; + return '"valid"'; + }, + }, + save: { + enumerable: true, + get() { + optionGetterCalls += 1; + return save; + }, + }, + }); + expect(() => + createDocumentAutosaveSession(accessorOptions as never), + ).toThrowError(expect.objectContaining({ code: 'invalid_options' })); + expect(optionGetterCalls).toBe(0); + + const proxiedOptions = new Proxy( + { initialStrongEntityTag: '"valid"', save }, + { + get() { + optionGetterCalls += 1; + throw new Error('private option getter'); + }, + }, + ); + expect(createDocumentAutosaveSession(proxiedOptions).getSnapshot()).toMatchObject({ + state: 'idle', + durableStrongEntityTag: '"valid"', + }); + expect(optionGetterCalls).toBe(0); + }); + + it('rejects malformed revision evidence before host save begins', () => { + let saveCalls = 0; + const session = createDocumentAutosaveSession({ + initialStrongEntityTag: '"durable-one"', + save() { + saveCalls += 1; + return { status: 'conflict' }; + }, + }); + + expect(() => session.enqueue(Object.freeze({}) as never)).toThrowError( + expect.objectContaining({ code: 'invalid_revision_evidence' }), + ); + expect(saveCalls).toBe(0); + }); + + it('threads only server-issued validators through sequential durable writes', async () => { + const requests: DocumentAutosaveDurableSaveRequest[] = []; + const nextTags = ['"durable-two"', '"durable-three"']; + const session = createDocumentAutosaveSession({ + initialStrongEntityTag: '"durable-one"', + save(request) { + requests.push(request); + return { + status: 'saved', + nextStrongEntityTag: nextTags[requests.length - 1]!, + }; + }, + }); + + expect(Object.isFrozen(session)).toBe(true); + await expect(session.enqueue(createEvidence('41'))).resolves.toMatchObject({ + status: 'saved', + }); + await expect(session.enqueue(createEvidence('42'))).resolves.toMatchObject({ + status: 'saved', + }); + + expect(requests.map((request) => request.ifMatchStrongEntityTag)).toEqual([ + '"durable-one"', + '"durable-two"', + ]); + expect(requests.every(Object.isFrozen)).toBe(true); + expect(requests.every((request) => Object.isFrozen(request.evidence))).toBe(true); + expect(session.getSnapshot()).toMatchObject({ + state: 'idle', + durableStrongEntityTag: '"durable-three"', + }); + await expect(session.flush()).resolves.toMatchObject({ + state: 'idle', + durableStrongEntityTag: '"durable-three"', + }); + await expect(session.close()).resolves.toMatchObject({ + state: 'closed', + durableStrongEntityTag: '"durable-three"', + }); + }); + + it('retains the durable validator across conflict until explicit recovery', async () => { + const requests: DocumentAutosaveDurableSaveRequest[] = []; + const session = createDocumentAutosaveSession({ + initialStrongEntityTag: '"durable-one"', + save(request) { + requests.push(request); + return requests.length === 1 + ? { status: 'conflict' } + : { status: 'saved', nextStrongEntityTag: '"durable-three"' }; + }, + }); + + await expect(session.enqueue(createEvidence('41'))).resolves.toMatchObject({ + status: 'conflict', + }); + const pending = session.enqueue(createEvidence('42')); + expect(session.getSnapshot()).toMatchObject({ + state: 'blocked', + blockedReason: 'conflict', + durableStrongEntityTag: '"durable-one"', + }); + expect(() => session.resume('W/"weak"')).toThrowError( + expect.objectContaining({ + code: 'invalid_recovery_validator', + message: 'The recovered durable strong entity tag is invalid.', + }), + ); + expect(session.resume('"durable-two"')).toBe(true); + await expect(pending).resolves.toMatchObject({ status: 'saved' }); + expect(requests[1]?.ifMatchStrongEntityTag).toBe('"durable-two"'); + expect(session.getSnapshot().durableStrongEntityTag).toBe('"durable-three"'); + expect(session.resume('"unused"')).toBe(false); + expect(session.getSnapshot().durableStrongEntityTag).toBe('"durable-three"'); + }); + + it.each([ + null, + { status: 'saved' }, + { status: 'saved', nextStrongEntityTag: 'W/"weak"' }, + { status: 'conflict', unexpected: true }, + { status: 'unknown' }, + ])('fails closed for an invalid durable save result %#', async (result) => { + const session = createDocumentAutosaveSession({ + initialStrongEntityTag: '"durable-one"', + save: () => result as never, + }); + + await expect(session.enqueue(createEvidence())).rejects.toMatchObject({ + code: 'invalid_save_result', + }); + expect(session.getSnapshot()).toMatchObject({ + state: 'blocked', + blockedReason: 'failure', + durableStrongEntityTag: '"durable-one"', + }); + }); + + it('rejects missing or accessor result status without evaluating accessors', async () => { + let statusGetterCalls = 0; + const accessorResult = Object.defineProperty({}, 'status', { + enumerable: true, + get() { + statusGetterCalls += 1; + throw new Error('private status getter'); + }, + }); + + for (const result of [{}, accessorResult]) { + const session = createDocumentAutosaveSession({ + initialStrongEntityTag: '"durable-one"', + save: () => result as never, + }); + + await expect(session.enqueue(createEvidence())).rejects.toMatchObject({ + code: 'invalid_save_result', + message: 'The host save operation returned an invalid result.', + }); + } + expect(statusGetterCalls).toBe(0); + }); + + it('fails closed when durable result reflection is unavailable', async () => { + const inaccessibleResult = new Proxy( + { status: 'saved' as const, nextStrongEntityTag: '"durable-two"' }, + { + get(target, property, receiver) { + if (property === 'then') return undefined; + return Reflect.get(target, property, receiver); + }, + ownKeys() { + throw new Error('private result keys'); + }, + }, + ); + const session = createDocumentAutosaveSession({ + initialStrongEntityTag: '"durable-one"', + save: () => inaccessibleResult, + }); + + await expect(session.enqueue(createEvidence())).rejects.toMatchObject({ + code: 'invalid_save_result', + message: 'The host save operation returned an invalid result.', + }); + }); + + it('converts callback assimilation and execution failures into redacted errors', async () => { + const hostileThenable = new Proxy({ status: 'saved' }, { + get() { + throw new Error('private then getter'); + }, + }); + const assimilationFailureSession = createDocumentAutosaveSession({ + initialStrongEntityTag: '"durable-one"', + save: () => hostileThenable as never, + }); + await expect( + assimilationFailureSession.enqueue(createEvidence()), + ).rejects.toMatchObject({ + code: 'host_save_failed', + message: 'The host save operation failed.', + }); + + const failedSession = createDocumentAutosaveSession({ + initialStrongEntityTag: '"durable-one"', + save() { + throw new Error('private transport failure'); + }, + }); + await expect(failedSession.enqueue(createEvidence())).rejects.toMatchObject({ + code: 'host_save_failed', + message: 'The host save operation failed.', + }); + }); +}); diff --git a/src/autosave/package.ts b/src/autosave/package.ts index 61436d88..91a1ce6f 100644 --- a/src/autosave/package.ts +++ b/src/autosave/package.ts @@ -50,14 +50,15 @@ export type DocumentAutosaveQueueState = /** Reasons that require an explicit host recovery decision. */ export type DocumentAutosaveBlockedReason = 'conflict' | 'failure'; -/** Stable machine-readable categories for autosave queue errors. */ +/** Stable machine-readable categories for redacted autosave errors. */ export type DocumentAutosaveQueueErrorCode = | 'invalid_options' | 'invalid_revision_evidence' + | 'invalid_recovery_validator' | 'host_save_failed' | 'invalid_save_result'; -/** Public structural contract implemented by redacted autosave queue errors. */ +/** Public structural contract implemented by redacted autosave errors. */ export interface DocumentAutosaveQueueError extends Error { /** Stable category that callers can branch on without parsing messages. */ readonly code: DocumentAutosaveQueueErrorCode; @@ -253,3 +254,16 @@ export function createDocumentAutosaveQueue( getSnapshot: internalQueue.getSnapshot, }); } + +export { + createDocumentAutosaveSession, + isStrongHttpEntityTag, +} from './session.js'; +export type { + DocumentAutosaveDurableSaveFunction, + DocumentAutosaveDurableSaveRequest, + DocumentAutosaveDurableSaveResult, + DocumentAutosaveSession, + DocumentAutosaveSessionOptions, + DocumentAutosaveSessionSnapshot, +} from './session.js'; \ No newline at end of file diff --git a/src/autosave/session.resume.test.ts b/src/autosave/session.resume.test.ts new file mode 100644 index 00000000..c83e4d9d --- /dev/null +++ b/src/autosave/session.resume.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from 'vitest'; +import type { + DocumentAutosaveQueueErrorCode as InternalDocumentAutosaveQueueErrorCode, +} from './index.js'; +import { + createDocumentAutosaveSession, + isStrongHttpEntityTag, + type DocumentAutosaveDurableSaveResult, + type DocumentAutosaveQueueErrorCode, + type DocumentAutosaveRevisionEvidence, +} from './package.js'; + +/** Create one exact frozen framework-free revision fixture for recovery tests. */ +function createRecoveryEvidence(byte: string): DocumentAutosaveRevisionEvidence { + const digestHex = byte.repeat(32); + return Object.freeze({ + envelope: Object.freeze({ + schemaId: 'https://inkspan.io/schemas/document-envelope/v1', + schemaVersion: 1, + documentJson: Object.freeze({ type: 'doc' }), + }), + revision: Object.freeze({ + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }), + }); +} + +describe('durable autosave recovery validator contract', () => { + it.each([ + ['"control\u0001"'], + ['"beyond\u0100"'], + ['"first", "second"'], + ['*'], + ])('rejects the documented non-entity-tag form %s', (candidate) => { + expect(isStrongHttpEntityTag(candidate)).toBe(false); + }); + + it('rejects missing and symbol-keyed session options', () => { + const save = () => ({ status: 'conflict' as const }); + const unexpectedOption = Symbol('unexpected option'); + const symbolKeyedOptions = Object.assign( + { initialStrongEntityTag: '"server-one"', save }, + { [unexpectedOption]: true }, + ); + + expect(() => + createDocumentAutosaveSession({ + initialStrongEntityTag: '"server-one"', + } as never), + ).toThrowError(expect.objectContaining({ code: 'invalid_options' })); + expect(() => + createDocumentAutosaveSession(symbolKeyedOptions as never), + ).toThrowError(expect.objectContaining({ code: 'invalid_options' })); + }); + + it('keeps root and framework-free recovery error types aligned', () => { + const publicRecoveryErrorCode: DocumentAutosaveQueueErrorCode = + 'invalid_recovery_validator'; + const internalRecoveryErrorCode: InternalDocumentAutosaveQueueErrorCode = + publicRecoveryErrorCode; + + expect(internalRecoveryErrorCode).toBe('invalid_recovery_validator'); + }); + + it('rejects a malformed recovered validator in every lifecycle state', () => { + const recoveryErrorCode: DocumentAutosaveQueueErrorCode = + 'invalid_recovery_validator'; + const session = createDocumentAutosaveSession({ + initialStrongEntityTag: '"server-one"', + save: () => ({ status: 'conflict' }), + }); + + expect(() => session.resume('W/"weak"')).toThrowError( + expect.objectContaining({ + code: recoveryErrorCode, + message: 'The recovered durable strong entity tag is invalid.', + }), + ); + expect(session.getSnapshot()).toMatchObject({ + state: 'idle', + durableStrongEntityTag: '"server-one"', + }); + }); + + it('does not replace the durable validator when no blocked state is resumed', () => { + const session = createDocumentAutosaveSession({ + initialStrongEntityTag: '"server-one"', + save: () => ({ status: 'conflict' }), + }); + + expect(session.resume('"server-unused"')).toBe(false); + expect(session.getSnapshot().durableStrongEntityTag).toBe('"server-one"'); + }); + + it('hands the first committed server validator to concurrently queued work', async () => { + const observedValidators: string[] = []; + let completeFirstSave!: (result: DocumentAutosaveDurableSaveResult) => void; + const firstSave = new Promise((resolve) => { + completeFirstSave = resolve; + }); + const session = createDocumentAutosaveSession({ + initialStrongEntityTag: '"server-one"', + save(request) { + observedValidators.push(request.ifMatchStrongEntityTag); + return observedValidators.length === 1 + ? firstSave + : { status: 'saved', nextStrongEntityTag: '"server-three"' }; + }, + }); + + const firstRequest = session.enqueue(createRecoveryEvidence('41')); + const secondRequest = session.enqueue(createRecoveryEvidence('42')); + expect(observedValidators).toEqual(['"server-one"']); + + completeFirstSave({ + status: 'saved', + nextStrongEntityTag: '"server-two"', + }); + await expect(firstRequest).resolves.toMatchObject({ status: 'saved' }); + await expect(secondRequest).resolves.toMatchObject({ status: 'saved' }); + expect(observedValidators).toEqual(['"server-one"', '"server-two"']); + }); + + it('keeps flush snapshots current when recovery resumes before the wrapper continuation', async () => { + const observedValidators: string[] = []; + let completeFirstSave!: (result: DocumentAutosaveDurableSaveResult) => void; + const firstSave = new Promise((resolve) => { + completeFirstSave = resolve; + }); + const session = createDocumentAutosaveSession({ + initialStrongEntityTag: '"server-one"', + save(request) { + observedValidators.push(request.ifMatchStrongEntityTag); + return observedValidators.length === 1 + ? firstSave + : { status: 'saved', nextStrongEntityTag: '"server-three"' }; + }, + }); + + const firstRequest = session.enqueue(createRecoveryEvidence('41')); + const retainedRequest = session.enqueue(createRecoveryEvidence('42')); + const flushRequest = session.flush(); + const recoveryRequest = firstRequest.then((outcome) => { + expect(outcome.status).toBe('conflict'); + expect(session.resume('"server-two"')).toBe(true); + }); + + completeFirstSave({ status: 'conflict' }); + const flushSnapshot = await flushRequest; + await recoveryRequest; + await expect(retainedRequest).resolves.toMatchObject({ status: 'saved' }); + + expect(flushSnapshot).toMatchObject({ + state: 'idle', + blockedReason: null, + durableStrongEntityTag: '"server-three"', + }); + expect(observedValidators).toEqual(['"server-one"', '"server-two"']); + }); + + it('keeps flush snapshots current when close begins before the wrapper continuation', async () => { + const observedValidators: string[] = []; + let completeFirstSave!: (result: DocumentAutosaveDurableSaveResult) => void; + let completeSecondSave!: (result: DocumentAutosaveDurableSaveResult) => void; + const firstSave = new Promise((resolve) => { + completeFirstSave = resolve; + }); + const secondSave = new Promise((resolve) => { + completeSecondSave = resolve; + }); + const session = createDocumentAutosaveSession({ + initialStrongEntityTag: '"server-one"', + save(request) { + observedValidators.push(request.ifMatchStrongEntityTag); + return observedValidators.length === 1 ? firstSave : secondSave; + }, + }); + + const firstRequest = session.enqueue(createRecoveryEvidence('41')); + const flushRequest = session.flush(); + const closeRequest = firstRequest.then(async (outcome) => { + expect(outcome.status).toBe('saved'); + const secondRequest = session.enqueue(createRecoveryEvidence('42')); + const closedSnapshotRequest = session.close(); + expect(session.getSnapshot().state).toBe('closing'); + completeSecondSave({ + status: 'saved', + nextStrongEntityTag: '"server-three"', + }); + await expect(secondRequest).resolves.toMatchObject({ status: 'saved' }); + await expect(closedSnapshotRequest).resolves.toMatchObject({ + state: 'closed', + durableStrongEntityTag: '"server-three"', + }); + }); + + completeFirstSave({ + status: 'saved', + nextStrongEntityTag: '"server-two"', + }); + const flushSnapshot = await flushRequest; + await closeRequest; + + expect(flushSnapshot).toMatchObject({ + state: 'closed', + blockedReason: null, + durableStrongEntityTag: '"server-three"', + }); + expect(observedValidators).toEqual(['"server-one"', '"server-two"']); + }); + + it('installs the recovered validator before retained work starts', async () => { + const observedValidators: string[] = []; + const session = createDocumentAutosaveSession({ + initialStrongEntityTag: '"server-one"', + save(request) { + observedValidators.push(request.ifMatchStrongEntityTag); + return observedValidators.length === 1 + ? { status: 'conflict' } + : { status: 'saved', nextStrongEntityTag: '"server-three"' }; + }, + }); + + await expect(session.enqueue(createRecoveryEvidence('41'))).resolves.toMatchObject({ + status: 'conflict', + }); + const retainedRequest = session.enqueue(createRecoveryEvidence('42')); + + expect(session.resume('"server-two"')).toBe(true); + await expect(retainedRequest).resolves.toMatchObject({ status: 'saved' }); + expect(observedValidators).toEqual(['"server-one"', '"server-two"']); + expect(session.getSnapshot().durableStrongEntityTag).toBe('"server-three"'); + }); +}); diff --git a/src/autosave/session.rollback.test.ts b/src/autosave/session.rollback.test.ts new file mode 100644 index 00000000..af2ec1ac --- /dev/null +++ b/src/autosave/session.rollback.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const blockedSnapshot = Object.freeze({ + state: 'blocked' as const, + blockedReason: 'conflict' as const, + activeStrongEntityTag: null, + pendingStrongEntityTag: null, + lastSavedStrongEntityTag: null, +}); + +afterEach(() => { + vi.doUnmock('./index.js'); + vi.resetModules(); +}); + +describe('durable autosave recovery transition fault handling', () => { + it('restores the previous validator when the private queue declines a blocked transition', async () => { + vi.resetModules(); + const resumeQueue = vi.fn(() => false); + vi.doMock('./index.js', async () => { + const actual = await vi.importActual( + './index.js', + ); + return { + ...actual, + createDocumentAutosaveQueue: () => + Object.freeze({ + enqueue: vi.fn(), + resume: resumeQueue, + flush: vi.fn(async () => blockedSnapshot), + close: vi.fn(async () => blockedSnapshot), + getSnapshot: vi.fn(() => blockedSnapshot), + }), + }; + }); + + const { createDocumentAutosaveSession } = await import('./session.js'); + const session = createDocumentAutosaveSession({ + initialStrongEntityTag: '"server-one"', + save: () => ({ status: 'conflict' }), + }); + + expect(session.resume('"server-two"')).toBe(false); + expect(resumeQueue).toHaveBeenCalledOnce(); + expect(session.getSnapshot()).toMatchObject({ + state: 'blocked', + durableStrongEntityTag: '"server-one"', + }); + }); +}); diff --git a/src/autosave/session.ts b/src/autosave/session.ts new file mode 100644 index 00000000..a36b31ce --- /dev/null +++ b/src/autosave/session.ts @@ -0,0 +1,405 @@ +import { + DocumentAutosaveQueueError, + createDocumentAutosaveQueue as createInternalDocumentAutosaveQueue, + type DocumentAutosaveQueueSnapshot as InternalQueueSnapshot, +} from './index.js'; +import { createDetachedAutosaveRevisionEvidence } from './evidenceValidation.js'; +import type { + DocumentAutosaveQueueSnapshot, + DocumentAutosaveRequestOutcome, + DocumentAutosaveRevisionEvidence, + DocumentAutosaveSaveResult, +} from './package.js'; + +/** Immutable input supplied to one host-owned durable save operation. */ +export interface DocumentAutosaveDurableSaveRequest { + /** Detached, deeply frozen document revision proposed for persistence. */ + readonly evidence: DocumentAutosaveRevisionEvidence; + /** Server-issued strong entity tag that the host must send as `If-Match`. */ + readonly ifMatchStrongEntityTag: string; +} + +/** Result returned after one host-owned durable compare-and-swap attempt. */ +export type DocumentAutosaveDurableSaveResult = + | Readonly<{ + /** Indicates that the authorized durable write committed. */ + status: 'saved'; + /** Strong entity tag selected by the server for the committed representation. */ + nextStrongEntityTag: string; + }> + | Readonly<{ + /** Indicates that the durable base validator no longer matched. */ + status: 'conflict'; + }>; + +/** + * Host-owned durable save operation with an explicit optimistic-concurrency base. + * + * The host must enforce `ifMatchStrongEntityTag` atomically inside its authorized + * storage transaction and return the server-selected strong entity tag for the + * resulting representation. Inkspan never performs transport or persistence. + * The host must also bound its own callback with a timeout or abort signal: + * an unresolved callback retains the single-flight request and prevents later + * enqueue, flush, and close operations from completing. Retry policy remains + * host-owned. + */ +export type DocumentAutosaveDurableSaveFunction = ( + request: Readonly, +) => + | DocumentAutosaveDurableSaveResult + | PromiseLike; + +/** Options required to create a durable-validator autosave session. */ +export interface DocumentAutosaveSessionOptions { + /** Strong entity tag returned with the durable representation initially loaded. */ + readonly initialStrongEntityTag: string; + /** Host-owned authorized compare-and-swap operation. */ + readonly save: DocumentAutosaveDurableSaveFunction; +} + +/** Frozen document-free lifecycle metadata for one durable autosave session. */ +export interface DocumentAutosaveSessionSnapshot + extends DocumentAutosaveQueueSnapshot { + /** Server-issued strong entity tag used by the next durable save request. */ + readonly durableStrongEntityTag: string; +} + +/** + * Provider-neutral autosave coordinator that owns durable validator handoff. + * + * The session combines the bounded single-flight queue with one server-issued + * strong entity tag. It never substitutes Inkspan's local content revision for + * the durable HTTP validator and never advances the durable validator after a + * conflict, malformed callback result, or ambiguous transport failure. + */ +export interface DocumentAutosaveSession { + /** + * Queue one immutable revision for the host-owned durable save operation. + * + * @param evidence - Frozen evidence returned by Inkspan revision APIs. + * @returns A promise for the deterministic local request outcome. + */ + enqueue( + evidence: DocumentAutosaveRevisionEvidence, + ): Promise; + /** + * Resume a blocked session with the strong entity tag obtained by recovery. + * + * The replacement validator must come from an authenticated durable reload, + * compare/merge/fork decision, or idempotency confirmation. It is installed + * immediately before retained work resumes, so the next callback observes the + * exact recovered base. + * + * @param nextStrongEntityTag - Server-issued strong validator after recovery. + * @returns `true` when one blocked state was cleared; otherwise `false`. + * @throws {DocumentAutosaveQueueError} With code + * `invalid_recovery_validator` when the recovered value is not one + * syntactically valid strong entity tag. + */ + resume(nextStrongEntityTag: string): boolean; + /** + * Wait until the session becomes idle, blocked, or closed. + * + * @returns A frozen document-free session snapshot. + */ + flush(): Promise; + /** + * Reject new work, close pending work, and let active transport finish. + * + * @returns The final frozen document-free session snapshot. + */ + close(): Promise; + /** + * Read current document-free lifecycle and durable-validator metadata. + * + * @returns A newly created immutable session snapshot. + */ + getSnapshot(): DocumentAutosaveSessionSnapshot; +} + +interface InternalQueueAdapter { + readonly enqueue: ( + evidence: DocumentAutosaveRevisionEvidence, + ) => Promise; + readonly resume: () => boolean; + readonly flush: () => Promise; + readonly close: () => Promise; + readonly getSnapshot: () => InternalQueueSnapshot; +} + +const STRONG_HTTP_ENTITY_TAG = + /^"[\u0021\u0023-\u007e\u0080-\u00ff]*"$/u; +const DOCUMENT_AUTOSAVE_SESSION_OPTION_KEYS = [ + 'initialStrongEntityTag', + 'save', +] as const; + +/** + * Determine whether a value is one RFC 9110 strong entity tag. + * + * The check accepts exactly one quoted opaque tag, rejects the `W/` weak prefix, + * whitespace, control characters, Unicode outside the HTTP `obs-text` range, + * lists, wildcards, and unquoted values, and never trims or repairs input. + * + * @param candidate - Unknown value obtained from a durable service boundary. + * @returns `true` only for one syntactically strong entity tag. + */ +export function isStrongHttpEntityTag(candidate: unknown): candidate is string { + if (typeof candidate !== 'string') return false; + return STRONG_HTTP_ENTITY_TAG.test(candidate); +} + +/** Create one redacted invalid-session-options error. */ +function createInvalidSessionOptionsError(): DocumentAutosaveQueueError { + return new DocumentAutosaveQueueError( + 'invalid_options', + 'Document autosave session options are invalid.', + ); +} + +/** Create one redacted invalid-recovery-validator error. */ +function createInvalidRecoveryValidatorError(): DocumentAutosaveQueueError { + return new DocumentAutosaveQueueError( + 'invalid_recovery_validator', + 'The recovered durable strong entity tag is invalid.', + ); +} + +/** + * Read exact enumerable session option values without evaluating accessors. + * + * The descriptor-only boundary rejects unknown fields, symbols, accessors, + * non-enumerable fields, and hostile reflection while accepting transparent + * proxies whose target owns the two documented data properties. + */ +function readDocumentAutosaveSessionOptions( + options: DocumentAutosaveSessionOptions, +): Readonly<{ + initialStrongEntityTag: string; + save: DocumentAutosaveDurableSaveFunction; +}> { + try { + if (typeof options !== 'object' || options === null) { + throw createInvalidSessionOptionsError(); + } + const optionKeys = Reflect.ownKeys(options); + if ( + optionKeys.length !== DOCUMENT_AUTOSAVE_SESSION_OPTION_KEYS.length || + optionKeys.some( + (optionKey) => + typeof optionKey !== 'string' || + !DOCUMENT_AUTOSAVE_SESSION_OPTION_KEYS.includes( + optionKey as (typeof DOCUMENT_AUTOSAVE_SESSION_OPTION_KEYS)[number], + ), + ) + ) { + throw createInvalidSessionOptionsError(); + } + const initialStrongEntityTagDescriptor = Object.getOwnPropertyDescriptor( + options, + 'initialStrongEntityTag', + ); + const saveDescriptor = Object.getOwnPropertyDescriptor(options, 'save'); + if ( + initialStrongEntityTagDescriptor === undefined || + saveDescriptor === undefined || + !initialStrongEntityTagDescriptor.enumerable || + !saveDescriptor.enumerable || + !Object.prototype.hasOwnProperty.call( + initialStrongEntityTagDescriptor, + 'value', + ) || + !Object.prototype.hasOwnProperty.call(saveDescriptor, 'value') + ) { + throw createInvalidSessionOptionsError(); + } + const initialStrongEntityTag = initialStrongEntityTagDescriptor.value; + const save = saveDescriptor.value; + if (!isStrongHttpEntityTag(initialStrongEntityTag) || typeof save !== 'function') { + throw createInvalidSessionOptionsError(); + } + return Object.freeze({ initialStrongEntityTag, save }); + } catch { + throw createInvalidSessionOptionsError(); + } +} + +/** Read one exact durable callback result without evaluating accessors. */ +function readDurableSaveResult( + value: unknown, +): DocumentAutosaveDurableSaveResult | null { + try { + if (typeof value !== 'object' || value === null) return null; + const keys = Reflect.ownKeys(value); + const statusDescriptor = Object.getOwnPropertyDescriptor(value, 'status'); + if ( + statusDescriptor === undefined || + !Object.prototype.hasOwnProperty.call(statusDescriptor, 'value') + ) { + return null; + } + if (statusDescriptor.value === 'conflict') { + return keys.length === 1 && keys[0] === 'status' + ? Object.freeze({ status: 'conflict' }) + : null; + } + if ( + statusDescriptor.value !== 'saved' || + keys.length !== 2 || + !keys.includes('status') || + !keys.includes('nextStrongEntityTag') + ) { + return null; + } + const nextDescriptor = Object.getOwnPropertyDescriptor( + value, + 'nextStrongEntityTag', + ); + if ( + nextDescriptor === undefined || + !Object.prototype.hasOwnProperty.call(nextDescriptor, 'value') || + !isStrongHttpEntityTag(nextDescriptor.value) + ) { + return null; + } + return Object.freeze({ + status: 'saved', + nextStrongEntityTag: nextDescriptor.value, + }); + } catch { + return null; + } +} + +/** Create one invalid internal callback sentinel without private source values. */ +function createInvalidDurableSaveSentinel(): DocumentAutosaveSaveResult { + return Object.freeze({ status: 'invalid' }) as never; +} + +/** Combine queue lifecycle metadata with the current durable validator. */ +function createDocumentAutosaveSessionSnapshot( + queueSnapshot: DocumentAutosaveQueueSnapshot, + durableStrongEntityTag: string, +): DocumentAutosaveSessionSnapshot { + return Object.freeze({ + ...queueSnapshot, + durableStrongEntityTag, + }); +} + +/** Report whether one current queue snapshot is terminal for session flushing. */ +function isSessionFlushTerminal(snapshot: InternalQueueSnapshot): boolean { + return ( + snapshot.state === 'idle' || + snapshot.state === 'blocked' || + snapshot.state === 'closed' + ); +} + +/** + * Create a provider-neutral durable-validator autosave session. + * + * The session validates the initially loaded server entity tag, supplies that + * validator to exactly one host callback at a time, and advances it only after a + * syntactically valid `saved` result supplies the server's replacement tag. + * Conflict and failure recovery remain host-owned and explicit through + * `resume(nextStrongEntityTag)`. + * + * @param options - Initial server validator and host-owned durable save callback. + * @returns A frozen standalone autosave session with no framework dependency. + * @throws {DocumentAutosaveQueueError} When options are malformed. + */ +export function createDocumentAutosaveSession( + options: DocumentAutosaveSessionOptions, +): DocumentAutosaveSession { + const validatedOptions = readDocumentAutosaveSessionOptions(options); + let durableStrongEntityTag = validatedOptions.initialStrongEntityTag; + const internalQueue = createInternalDocumentAutosaveQueue({ + async save(internalEvidence) { + const evidence = + internalEvidence as unknown as DocumentAutosaveRevisionEvidence; + const result = await validatedOptions.save( + Object.freeze({ + evidence, + ifMatchStrongEntityTag: durableStrongEntityTag, + }), + ); + const validatedResult = readDurableSaveResult(result); + if (validatedResult === null) return createInvalidDurableSaveSentinel(); + if (validatedResult.status === 'conflict') return validatedResult; + durableStrongEntityTag = validatedResult.nextStrongEntityTag; + return Object.freeze({ status: 'saved' }); + }, + }) as unknown as InternalQueueAdapter; + + /** Queue one detached immutable revision. */ + function enqueue( + evidence: DocumentAutosaveRevisionEvidence, + ): Promise { + const detachedEvidence = createDetachedAutosaveRevisionEvidence(evidence); + if (detachedEvidence === null) { + throw new DocumentAutosaveQueueError( + 'invalid_revision_evidence', + 'Document revision evidence is invalid.', + ); + } + return internalQueue.enqueue(detachedEvidence); + } + + /** Create a current document-free snapshot. */ + function getSnapshot(): DocumentAutosaveSessionSnapshot { + return createDocumentAutosaveSessionSnapshot( + internalQueue.getSnapshot() as DocumentAutosaveQueueSnapshot, + durableStrongEntityTag, + ); + } + + /** Resume blocked work only after installing a valid recovered validator. */ + function resume(nextStrongEntityTag: string): boolean { + if (!isStrongHttpEntityTag(nextStrongEntityTag)) { + throw createInvalidRecoveryValidatorError(); + } + if (internalQueue.getSnapshot().state !== 'blocked') return false; + + // `internalQueue.resume()` starts retained work synchronously until the first + // host await. Install the recovered validator before that call, but restore + // the previous value if the queue unexpectedly declines the transition. + const previousStrongEntityTag = durableStrongEntityTag; + durableStrongEntityTag = nextStrongEntityTag; + const resumed = internalQueue.resume(); + if (!resumed) durableStrongEntityTag = previousStrongEntityTag; + return resumed; + } + + /** Wait for the current terminal state and attach its durable validator. */ + async function flush(): Promise { + await internalQueue.flush(); + while (true) { + const snapshot = internalQueue.getSnapshot(); + if (isSessionFlushTerminal(snapshot)) { + return createDocumentAutosaveSessionSnapshot( + snapshot as DocumentAutosaveQueueSnapshot, + durableStrongEntityTag, + ); + } + await internalQueue.flush(); + } + } + + /** Close queue progression and attach the final durable validator. */ + async function close(): Promise { + const snapshot = await internalQueue.close(); + return createDocumentAutosaveSessionSnapshot( + snapshot as DocumentAutosaveQueueSnapshot, + durableStrongEntityTag, + ); + } + + return Object.freeze({ + enqueue, + resume, + flush, + close, + getSnapshot, + }); +} diff --git a/tests/package/verify-framework-free-autosave-package.mjs b/tests/package/verify-framework-free-autosave-package.mjs index 27c5ffdd..59e427e1 100644 --- a/tests/package/verify-framework-free-autosave-package.mjs +++ b/tests/package/verify-framework-free-autosave-package.mjs @@ -100,7 +100,9 @@ function verifyRuntimeConsumers() { import { fileURLToPath } from 'node:url'; import { createDocumentAutosaveQueue, + createDocumentAutosaveSession, DocumentAutosaveQueueError, + isStrongHttpEntityTag, } from '${packageJson.name}/autosave'; const resolvedEntry = fileURLToPath( @@ -108,7 +110,11 @@ const resolvedEntry = fileURLToPath( ); assert.ok(resolvedEntry.endsWith('/dist/cwl-autosave.js')); assert.equal(typeof createDocumentAutosaveQueue, 'function'); +assert.equal(typeof createDocumentAutosaveSession, 'function'); assert.equal(typeof DocumentAutosaveQueueError, 'function'); +assert.equal(typeof isStrongHttpEntityTag, 'function'); +assert.equal(isStrongHttpEntityTag('"server-one"'), true); +assert.equal(isStrongHttpEntityTag('W/"weak"'), false); const digestHex = '42'.repeat(32); const evidence = Object.freeze({ envelope: Object.freeze({ @@ -161,9 +167,26 @@ assert.throws( ); mutableTextNode.text = 'mutated after rejection'; assert.equal(calls, 1); - assert.equal((await queue.flush()).state, 'idle'); assert.equal((await queue.close()).state, 'closed'); + +let durableRequest; +const session = createDocumentAutosaveSession({ + initialStrongEntityTag: '"server-one"', + save(request) { + durableRequest = request; + return { status: 'saved', nextStrongEntityTag: '"server-two"' }; + }, +}); +assert.equal(Object.isFrozen(session), true); +assert.equal((await session.enqueue(evidence)).status, 'saved'); +assert.equal(Object.isFrozen(durableRequest), true); +assert.equal(durableRequest.ifMatchStrongEntityTag, '"server-one"'); +assert.notEqual(durableRequest.evidence, evidence); +assert.equal(Object.isFrozen(durableRequest.evidence), true); +assert.deepEqual(durableRequest.evidence, evidence); +assert.equal(session.getSnapshot().durableStrongEntityTag, '"server-two"'); +assert.equal((await session.close()).durableStrongEntityTag, '"server-two"'); `, 'utf8', ); @@ -179,6 +202,8 @@ assert.ok( .endsWith('/dist/cwl-autosave.cjs'), ); assert.equal(typeof autosave.createDocumentAutosaveQueue, 'function'); +assert.equal(typeof autosave.createDocumentAutosaveSession, 'function'); +assert.equal(typeof autosave.isStrongHttpEntityTag, 'function'); const digestHex = '24'.repeat(32); const evidence = Object.freeze({ envelope: Object.freeze({ @@ -201,6 +226,19 @@ void queue.enqueue(evidence).then(async (outcome) => { assert.equal(outcome.status, 'conflict'); assert.equal((await queue.flush()).blockedReason, 'conflict'); assert.equal((await queue.close()).state, 'closed'); + + let durableRequest; + const session = autosave.createDocumentAutosaveSession({ + initialStrongEntityTag: '"server-one"', + save(request) { + durableRequest = request; + return { status: 'conflict' }; + }, + }); + assert.equal((await session.enqueue(evidence)).status, 'conflict'); + assert.equal(durableRequest.ifMatchStrongEntityTag, '"server-one"'); + assert.equal(session.getSnapshot().durableStrongEntityTag, '"server-one"'); + assert.equal((await session.close()).state, 'closed'); }).catch((error) => { console.error(error); process.exitCode = 1; @@ -221,8 +259,12 @@ function verifyDeclarationConsumer() { sourcePath, `import { createDocumentAutosaveQueue, + createDocumentAutosaveSession, + type DocumentAutosaveDurableSaveRequest, + type DocumentAutosaveQueueErrorCode, type DocumentAutosaveRequestOutcome, type DocumentAutosaveRevisionEvidence, + type DocumentAutosaveSessionSnapshot, } from '${packageJson.name}/autosave'; declare const evidence: DocumentAutosaveRevisionEvidence; @@ -230,7 +272,19 @@ const queue = createDocumentAutosaveQueue({ save: async () => ({ status: 'saved' }), }); const outcome: Promise = queue.enqueue(evidence); +const recoveryErrorCode: DocumentAutosaveQueueErrorCode = + 'invalid_recovery_validator'; +const session = createDocumentAutosaveSession({ + initialStrongEntityTag: '"server-one"', + save: async (request: DocumentAutosaveDurableSaveRequest) => ({ + status: 'saved' as const, + nextStrongEntityTag: request.ifMatchStrongEntityTag, + }), +}); +const snapshot: DocumentAutosaveSessionSnapshot = session.getSnapshot(); void outcome; +void recoveryErrorCode; +void snapshot; `, 'utf8', ); diff --git a/vite.autosave.config.ts b/vite.autosave.config.ts index e5959c25..81f7fe9d 100644 --- a/vite.autosave.config.ts +++ b/vite.autosave.config.ts @@ -9,7 +9,11 @@ import dts from 'vite-plugin-dts'; export default defineConfig({ plugins: [ dts({ - include: ['src/autosave/package.ts', 'src/autosave/index.ts'], + include: [ + 'src/autosave/package.ts', + 'src/autosave/session.ts', + 'src/autosave/index.ts', + ], exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts'], rollupTypes: false, entryRoot: 'src',