diff --git a/docs/README.md b/docs/README.md index d4d24d8da..c1c75372e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ This directory is the discoverable index for Inkspan's product, technical, secur | [`PRD.md`](PRD.md) | Product users, jobs, buyer outcomes, non-goals, acceptance and claim boundaries | | [`TRD.md`](TRD.md) | Technical invariants, runtime boundaries, failure semantics and release evidence | | [`CONTRACTS.md`](CONTRACTS.md) | Public package/API/event/schema/plugin/collaboration and host-integration contracts | +| [`document-autosave.md`](document-autosave.md) | Provider-neutral local autosave ordering, durable-validator handoff, recovery, lifecycle observation, and host-owned persistence boundary | | [`package-distribution.md`](package-distribution.md) | Buyer-facing public npm package entrypoints, packaged contents, runtime dependency boundaries, and consumer verification | | [`email-output.md`](email-output.md) | Deterministic email fragment/full-document authority, language/direction metadata, accessibility and host-owned transport boundary | | [`print-output.md`](print-output.md) | Browser print/paged-media presentation, accessibility/fidelity limits, host-owned governed-export boundary, and rollback | @@ -32,6 +33,10 @@ Root `SECURITY.md` is now implemented on protected `main` and remains the normat Dated reassessments capture a reviewed source generation and its active or operational deltas without placing mutable workflow-run identities into timeless architecture. They do not override protected `main`, accepted ADRs, or current exact-head evidence. +## Active implementation records + +Active-PR doctoring must remain explicitly non-shipped and must not replace the protected-main authority above. The current autosave reliability proposal is recorded in [`doctoring/durable-etag-resource-boundary.md`](doctoring/durable-etag-resource-boundary.md); its 64 Ki local validator ceiling is **Active PR / Proposed** until integrated into protected `main` with required exact-head evidence. + ## Status discipline Use these terms consistently: diff --git a/docs/doctoring/durable-etag-resource-boundary.md b/docs/doctoring/durable-etag-resource-boundary.md new file mode 100644 index 000000000..4a1fa68d1 --- /dev/null +++ b/docs/doctoring/durable-etag-resource-boundary.md @@ -0,0 +1,59 @@ +# Doctoring record: Durable ETag resource boundary + +**Date:** 2026-08-11 +**Status:** Active PR / Proposed +**Protected-main authority:** The current protected implementation validates RFC 9110 strong entity-tag syntax but does not yet apply the resource ceiling described below. +**Scope:** Provider-neutral durable autosave validator validation only. + +## Buyer-visible gap + +Inkspan's durable autosave session accepts server-issued strong entity tags at three local trust boundaries: initial session creation, successful durable-save callback results, and explicit conflict/failure recovery. Protected `main` validates the RFC 9110 character grammar with a regular expression, but the validator has no Inkspan-owned input ceiling. An arbitrarily large syntactically valid string can therefore force an unbounded regex scan before classification and, when accepted, become retained session/snapshot metadata. + +The host still owns transport and server field-size policy. Inkspan nevertheless owns the local validator predicate and retained local session state, so it must place a bounded resource policy before its own parser/regex boundary. + +## Decision + +The active implementation proposal caps the complete quoted strong entity tag at **64 Ki UTF-16 code units**. Values above that ceiling fail closed before the RFC grammar regular expression is evaluated. Values at or below the ceiling still have to satisfy the existing strong entity-tag grammar. + +This 64 Ki ceiling is an Inkspan local reliability/resource policy. RFC 9110 defines entity-tag syntax and comparison semantics; this record does **not** claim that RFC 9110 defines a 64 Ki entity-tag or HTTP-field maximum. + +One public predicate, `isStrongHttpEntityTag()`, remains the validation authority. Using the same predicate for initial options, returned replacement validators, and recovered validators prevents those entry points from drifting to different size or grammar rules. + +## Alternatives considered + +1. **Keep grammar-only validation.** Rejected because the local regex and retained snapshot state remain attacker/caller-amplifiable. +2. **Apply a host-configurable limit.** Rejected for the standalone predicate because every caller would need to re-establish a safe default, weakening deterministic package behavior. Hosts with different version-token protocols already have the lower-level autosave queue escape hatch. +3. **Use an HTTP transport/server limit as the only bound.** Rejected because standalone Inkspan has no transport authority and callers can invoke the public validator directly. +4. **Use UTF-8 byte counting.** Not selected for this boundary because the accepted HTTP `etagc` grammar is already restricted to ASCII plus `obs-text`; a constant-time JavaScript string-length preflight is sufficient to prevent the regex scan and directly bounds retained JS string size in code units. This does not change any host transport byte limit. + +## Failure and privacy semantics + +Oversized initial validators continue to surface only the redacted `invalid_options` category. Oversized recovery validators surface only `invalid_recovery_validator`. Oversized callback replacement validators are treated as an invalid save result and leave the previously accepted durable validator intact. None of those public failures copy the rejected validator into the error message. + +The accepted durable validator remains tenant-correlatable metadata. Existing guidance prohibiting public URLs, unauthenticated logs, analytics dimensions, and high-cardinality metric labels remains unchanged. + +## Ownership boundary + +Inkspan owns the resource-bounded local predicate and deterministic local validator handoff. The host continues to own authentication, authorization, tenancy, network transport, HTTP server configuration, persistence, atomic `If-Match` comparison/commit, credentials, migration, retention, durable audit, retry/idempotency policy, and conflict UX. No network, database, model, credential, or durable PDF/print authority is added. + +## Verification contract + +The change is accepted only when exact-head evidence proves all of the following: + +- a test-only predecessor fails because an oversized otherwise-valid tag reaches the old grammar-only path; +- the repaired predicate rejects oversized input before regex evaluation; +- ASCII and `obs-text` values at the exact local ceiling remain accepted when syntactically valid; +- the first otherwise-valid code unit beyond the ceiling is rejected; +- initial-session, replacement-result, and recovery boundaries all fail closed without retaining the oversized value; +- public errors remain payload-redacted; +- the framework-free autosave package surface preserves the same behavior; +- owned production statement, branch, function, and line coverage remains exactly 100%; and +- applicable CI, security, package, browser, Office, provenance/release-policy and review gates pass on one unchanged exact head. + +Until that active PR reaches protected `main`, the behavior in this record is proposed and must not be described as shipped. + +## References (APA 7th edition) + +Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). RFC Editor. https://doi.org/10.17487/RFC9110 + +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 diff --git a/src/autosave/session.entityTagPackageBoundary.test.ts b/src/autosave/session.entityTagPackageBoundary.test.ts new file mode 100644 index 000000000..085b1277a --- /dev/null +++ b/src/autosave/session.entityTagPackageBoundary.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { isStrongHttpEntityTag } from './package.js'; + +const MAX_ACCEPTED_ENTITY_TAG_CODE_UNITS = 64 * 1024; + +describe('framework-free autosave entity-tag package boundary', () => { + it('exposes the same fail-closed ceiling through the standalone package barrel', () => { + const atCeiling = `"${'p'.repeat(MAX_ACCEPTED_ENTITY_TAG_CODE_UNITS - 2)}"`; + const beyondCeiling = `"${'p'.repeat(MAX_ACCEPTED_ENTITY_TAG_CODE_UNITS - 1)}"`; + + expect(isStrongHttpEntityTag(atCeiling)).toBe(true); + expect(isStrongHttpEntityTag(beyondCeiling)).toBe(false); + }); +}); diff --git a/src/autosave/session.entityTagResourceBoundary.test.ts b/src/autosave/session.entityTagResourceBoundary.test.ts new file mode 100644 index 000000000..5f4e33469 --- /dev/null +++ b/src/autosave/session.entityTagResourceBoundary.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createDocumentAutosaveSession, + isStrongHttpEntityTag, + type DocumentAutosaveRevisionEvidence, + type DocumentAutosaveSessionSnapshot, +} from './package.js'; + +const MAX_ACCEPTED_ENTITY_TAG_CODE_UNITS = 64 * 1024; +const PRIVATE_ETAG_MARKER = 'PRIVATE_ETAG_SENTINEL'; + +/** Create one exact immutable revision fixture for durable-validator tests. */ +function createEvidence(): DocumentAutosaveRevisionEvidence { + const digestHex = '41'.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}"`, + }), + }); +} + +/** Create one syntactically RFC-compatible tag beyond Inkspan's local ceiling. */ +function createOversizedEntityTag(): string { + return `"${PRIVATE_ETAG_MARKER}${'a'.repeat(MAX_ACCEPTED_ENTITY_TAG_CODE_UNITS)}"`; +} + +describe('durable autosave entity-tag resource boundary', () => { + it('rejects an obviously oversized validator before regex evaluation', () => { + const regexTest = vi.spyOn(RegExp.prototype, 'test'); + + expect(isStrongHttpEntityTag(createOversizedEntityTag())).toBe(false); + expect(regexTest).not.toHaveBeenCalled(); + + regexTest.mockRestore(); + }); + + it('preserves syntactically valid ASCII and obs-text validators at the exact ceiling', () => { + const exactAsciiCandidate = `"${'a'.repeat(MAX_ACCEPTED_ENTITY_TAG_CODE_UNITS - 2)}"`; + const exactObsTextCandidate = `"${String.fromCharCode(0xff).repeat(MAX_ACCEPTED_ENTITY_TAG_CODE_UNITS - 2)}"`; + + expect(exactAsciiCandidate).toHaveLength(MAX_ACCEPTED_ENTITY_TAG_CODE_UNITS); + expect(exactObsTextCandidate).toHaveLength(MAX_ACCEPTED_ENTITY_TAG_CODE_UNITS); + expect(isStrongHttpEntityTag(exactAsciiCandidate)).toBe(true); + expect(isStrongHttpEntityTag(exactObsTextCandidate)).toBe(true); + }); + + it('rejects the first otherwise-valid code unit beyond the complete-tag ceiling', () => { + const oneOverCandidate = `"${'a'.repeat(MAX_ACCEPTED_ENTITY_TAG_CODE_UNITS - 1)}"`; + + expect(oneOverCandidate).toHaveLength(MAX_ACCEPTED_ENTITY_TAG_CODE_UNITS + 1); + expect(isStrongHttpEntityTag(oneOverCandidate)).toBe(false); + }); + + it('rejects an oversized initial validator with a payload-redacted error', () => { + let capturedError: unknown; + try { + createDocumentAutosaveSession({ + initialStrongEntityTag: createOversizedEntityTag(), + save: () => ({ status: 'conflict' }), + }); + } catch (error) { + capturedError = error; + } + + expect(capturedError).toMatchObject({ code: 'invalid_options' }); + expect((capturedError as Error).message).not.toContain(PRIVATE_ETAG_MARKER); + }); + + it('rejects an oversized recovered validator without replacing or exposing it', async () => { + const session = createDocumentAutosaveSession({ + initialStrongEntityTag: '"server-one"', + save: () => ({ status: 'conflict' }), + }); + + await expect(session.enqueue(createEvidence())).resolves.toMatchObject({ + status: 'conflict', + }); + let capturedError: unknown; + try { + session.resume(createOversizedEntityTag()); + } catch (error) { + capturedError = error; + } + expect(capturedError).toMatchObject({ code: 'invalid_recovery_validator' }); + expect((capturedError as Error).message).not.toContain(PRIVATE_ETAG_MARKER); + expect(session.getSnapshot()).toMatchObject({ + state: 'blocked', + durableStrongEntityTag: '"server-one"', + }); + }); + + it('fails closed without emitting or exposing an oversized replacement validator', async () => { + const oversizedEntityTag = createOversizedEntityTag(); + const observedSnapshots: DocumentAutosaveSessionSnapshot[] = []; + const session = createDocumentAutosaveSession({ + initialStrongEntityTag: '"server-one"', + save: () => ({ + status: 'saved', + nextStrongEntityTag: oversizedEntityTag, + }), + onSnapshotChange(snapshot) { + observedSnapshots.push(snapshot); + }, + }); + + const capturedError = await session.enqueue(createEvidence()).then( + () => null, + (error: unknown) => error, + ); + expect(capturedError).toMatchObject({ code: 'invalid_save_result' }); + expect((capturedError as Error).message).not.toContain(PRIVATE_ETAG_MARKER); + await Promise.resolve(); + expect(session.getSnapshot()).toMatchObject({ + state: 'blocked', + blockedReason: 'failure', + durableStrongEntityTag: '"server-one"', + }); + expect(observedSnapshots.length).toBeGreaterThan(0); + expect( + observedSnapshots.every( + (snapshot) => snapshot.durableStrongEntityTag === '"server-one"', + ), + ).toBe(true); + expect(JSON.stringify(observedSnapshots)).not.toContain(PRIVATE_ETAG_MARKER); + }); + + it('rejects malformed save status before enumerating caller-owned keys', async () => { + let ownKeysCalls = 0; + const invalidResult = new Proxy( + { status: 'invalid' }, + { + ownKeys() { + ownKeysCalls += 1; + throw new Error(PRIVATE_ETAG_MARKER); + }, + }, + ); + const session = createDocumentAutosaveSession({ + initialStrongEntityTag: '"server-one"', + save: () => invalidResult as never, + }); + + const capturedError = await session.enqueue(createEvidence()).then( + () => null, + (error: unknown) => error, + ); + + expect(capturedError).toMatchObject({ code: 'invalid_save_result' }); + expect((capturedError as Error).message).not.toContain(PRIVATE_ETAG_MARKER); + expect(ownKeysCalls).toBe(0); + }); + + it('rejects malformed saved results before enumerating caller-owned keys', async () => { + let ownKeysCalls = 0; + const invalidResult = new Proxy( + { status: 'saved' }, + { + ownKeys() { + ownKeysCalls += 1; + throw new Error(PRIVATE_ETAG_MARKER); + }, + }, + ); + const session = createDocumentAutosaveSession({ + initialStrongEntityTag: '"server-one"', + save: () => invalidResult as never, + }); + + const capturedError = await session.enqueue(createEvidence()).then( + () => null, + (error: unknown) => error, + ); + + expect(capturedError).toMatchObject({ code: 'invalid_save_result' }); + expect((capturedError as Error).message).not.toContain(PRIVATE_ETAG_MARKER); + expect(ownKeysCalls).toBe(0); + }); + + it('rejects saved durable results with extra enumerable fields', async () => { + const session = createDocumentAutosaveSession({ + initialStrongEntityTag: '"server-one"', + save: () => ({ + status: 'saved', + nextStrongEntityTag: '"server-two"', + unexpected: true, + }) as never, + }); + + const capturedError = await session.enqueue(createEvidence()).then( + () => null, + (error: unknown) => error, + ); + + expect(capturedError).toMatchObject({ code: 'invalid_save_result' }); + expect(session.getSnapshot()).toMatchObject({ + state: 'blocked', + durableStrongEntityTag: '"server-one"', + }); + }); + + it('rejects non-enumerable durable result fields as non-contract objects', async () => { + const hiddenConflict = Object.defineProperty({}, 'status', { + value: 'conflict', + enumerable: false, + }); + const hiddenValidator = Object.defineProperties( + {}, + { + status: { value: 'saved', enumerable: true }, + nextStrongEntityTag: { value: '"server-two"', enumerable: false }, + }, + ); + + for (const invalidResult of [hiddenConflict, hiddenValidator]) { + const session = createDocumentAutosaveSession({ + initialStrongEntityTag: '"server-one"', + save: () => invalidResult as never, + }); + + const capturedError = await session.enqueue(createEvidence()).then( + () => null, + (error: unknown) => error, + ); + + expect(capturedError).toMatchObject({ code: 'invalid_save_result' }); + expect(session.getSnapshot()).toMatchObject({ + state: 'blocked', + durableStrongEntityTag: '"server-one"', + }); + } + }); +}); diff --git a/src/autosave/session.ts b/src/autosave/session.ts index c410b5c29..878e479e8 100644 --- a/src/autosave/session.ts +++ b/src/autosave/session.ts @@ -139,6 +139,7 @@ interface InternalQueueAdapter { const STRONG_HTTP_ENTITY_TAG = /^"[\u0021\u0023-\u007e\u0080-\u00ff]*"$/u; +const MAX_STRONG_HTTP_ENTITY_TAG_CODE_UNITS = 64 * 1024; const DOCUMENT_AUTOSAVE_SESSION_OPTION_KEYS = [ 'initialStrongEntityTag', 'save', @@ -146,17 +147,21 @@ const DOCUMENT_AUTOSAVE_SESSION_OPTION_KEYS = [ ] as const; /** - * Determine whether a value is one RFC 9110 strong entity tag. + * Determine whether a value is one resource-bounded 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. + * The check accepts exactly one quoted opaque tag up to Inkspan's 64 Ki complete + * validator ceiling, rejects oversized values before regex evaluation, and then + * rejects the `W/` weak prefix, whitespace, control characters, Unicode outside + * the HTTP `obs-text` range, lists, wildcards, and unquoted values. The size + * ceiling is Inkspan local resource policy rather than an RFC field-size claim; + * accepted input is never trimmed or repaired. * * @param candidate - Unknown value obtained from a durable service boundary. - * @returns `true` only for one syntactically strong entity tag. + * @returns `true` only for one in-bound syntactically strong entity tag. */ export function isStrongHttpEntityTag(candidate: unknown): candidate is string { if (typeof candidate !== 'string') return false; + if (candidate.length > MAX_STRONG_HTTP_ENTITY_TAG_CODE_UNITS) return false; return STRONG_HTTP_ENTITY_TAG.test(candidate); } @@ -260,42 +265,45 @@ function readDurableSaveResult( ): 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') + !statusDescriptor.enumerable || + !Object.prototype.hasOwnProperty.call(statusDescriptor, 'value') || + (statusDescriptor.value !== 'conflict' && statusDescriptor.value !== 'saved') ) { return null; } - const nextDescriptor = Object.getOwnPropertyDescriptor( - value, - 'nextStrongEntityTag', - ); - if ( - nextDescriptor === undefined || - !Object.prototype.hasOwnProperty.call(nextDescriptor, 'value') || - !isStrongHttpEntityTag(nextDescriptor.value) - ) { - return null; + if (statusDescriptor.value === 'saved') { + const nextDescriptor = Object.getOwnPropertyDescriptor( + value, + 'nextStrongEntityTag', + ); + if ( + nextDescriptor === undefined || + !nextDescriptor.enumerable || + !Object.prototype.hasOwnProperty.call(nextDescriptor, 'value') || + !isStrongHttpEntityTag(nextDescriptor.value) + ) { + return null; + } + const keys = Reflect.ownKeys(value); + if ( + keys.length !== 2 || + !keys.includes('status') || + !keys.includes('nextStrongEntityTag') + ) { + return null; + } + return Object.freeze({ + status: 'saved', + nextStrongEntityTag: nextDescriptor.value, + }); } - return Object.freeze({ - status: 'saved', - nextStrongEntityTag: nextDescriptor.value, - }); + const keys = Reflect.ownKeys(value); + return keys.length === 1 && keys[0] === 'status' + ? Object.freeze({ status: 'conflict' }) + : null; } catch { return null; } diff --git a/src/durableEtagResourceDocumentation.test.ts b/src/durableEtagResourceDocumentation.test.ts new file mode 100644 index 000000000..9914f2663 --- /dev/null +++ b/src/durableEtagResourceDocumentation.test.ts @@ -0,0 +1,34 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const repositoryFile = (path: string): string => + readFileSync(resolve(process.cwd(), path), 'utf8'); + +describe('durable ETag resource-boundary documentation', () => { + it('keeps the active proposal discoverable without claiming protected-main maturity', () => { + const index = repositoryFile('docs/README.md'); + const recordPath = 'docs/doctoring/durable-etag-resource-boundary.md'; + + expect(existsSync(resolve(process.cwd(), recordPath))).toBe(true); + expect(index).toContain('doctoring/durable-etag-resource-boundary.md'); + expect(index).toContain('Active PR / Proposed'); + + const record = repositoryFile(recordPath); + expect(record).toContain('**Status:** Active PR / Proposed'); + expect(record).toContain('does not yet apply the resource ceiling'); + expect(record).toContain('64 Ki UTF-16 code units'); + expect(record).toContain('not** claim that RFC 9110 defines a 64 Ki'); + expect(record).toContain('Until that active PR reaches protected `main`'); + expect(record).toContain('authentication'); + expect(record).toContain('durable audit'); + }); + + it('keeps the protected autosave contract in the canonical graph', () => { + const index = repositoryFile('docs/README.md'); + + expect(index).toContain('[`document-autosave.md`](document-autosave.md)'); + expect(index).toContain('host-owned persistence boundary'); + }); +});