From 672800ac320400922e62d00d3e9f3341a8988f46 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 15:38:00 +0900 Subject: [PATCH 01/25] test(reliability): define durable ETag resource-boundary RED --- .../session.entityTagResourceBoundary.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/autosave/session.entityTagResourceBoundary.test.ts diff --git a/src/autosave/session.entityTagResourceBoundary.test.ts b/src/autosave/session.entityTagResourceBoundary.test.ts new file mode 100644 index 000000000..4ed7c50c0 --- /dev/null +++ b/src/autosave/session.entityTagResourceBoundary.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest'; +import { isStrongHttpEntityTag } from './package.js'; + +const MAX_ACCEPTED_ENTITY_TAG_CODE_UNITS = 64 * 1024; + +describe('durable autosave entity-tag resource boundary', () => { + it('rejects an obviously oversized validator before regex evaluation', () => { + const regexTest = vi.spyOn(RegExp.prototype, 'test'); + const oversizedCandidate = `"${'a'.repeat(MAX_ACCEPTED_ENTITY_TAG_CODE_UNITS)}"`; + + expect(isStrongHttpEntityTag(oversizedCandidate)).toBe(false); + expect(regexTest).not.toHaveBeenCalled(); + + regexTest.mockRestore(); + }); + + it('preserves a syntactically valid validator at the exact ceiling', () => { + const exactCandidate = `"${'a'.repeat(MAX_ACCEPTED_ENTITY_TAG_CODE_UNITS - 2)}"`; + + expect(exactCandidate).toHaveLength(MAX_ACCEPTED_ENTITY_TAG_CODE_UNITS); + expect(isStrongHttpEntityTag(exactCandidate)).toBe(true); + }); +}); From e699defd86d8387b6747f595909cad04695f7d1c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 15:42:24 +0900 Subject: [PATCH 02/25] fix(reliability): bound durable ETag validation --- src/autosave/session.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/autosave/session.ts b/src/autosave/session.ts index c410b5c29..6e004c37e 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); } From f7bb50dc7423e4a9b0c6902d6f33df18c6ef9e33 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 15:43:53 +0900 Subject: [PATCH 03/25] test(reliability): cover durable ETag retention boundaries --- .../session.entityTagResourceBoundary.test.ts | 77 ++++++++++++++++++- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/src/autosave/session.entityTagResourceBoundary.test.ts b/src/autosave/session.entityTagResourceBoundary.test.ts index 4ed7c50c0..6bc173d9e 100644 --- a/src/autosave/session.entityTagResourceBoundary.test.ts +++ b/src/autosave/session.entityTagResourceBoundary.test.ts @@ -1,14 +1,39 @@ import { describe, expect, it, vi } from 'vitest'; -import { isStrongHttpEntityTag } from './package.js'; +import { + createDocumentAutosaveSession, + isStrongHttpEntityTag, + type DocumentAutosaveRevisionEvidence, +} from './package.js'; const MAX_ACCEPTED_ENTITY_TAG_CODE_UNITS = 64 * 1024; +/** 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 `"${'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'); - const oversizedCandidate = `"${'a'.repeat(MAX_ACCEPTED_ENTITY_TAG_CODE_UNITS)}"`; - expect(isStrongHttpEntityTag(oversizedCandidate)).toBe(false); + expect(isStrongHttpEntityTag(createOversizedEntityTag())).toBe(false); expect(regexTest).not.toHaveBeenCalled(); regexTest.mockRestore(); @@ -20,4 +45,50 @@ describe('durable autosave entity-tag resource boundary', () => { expect(exactCandidate).toHaveLength(MAX_ACCEPTED_ENTITY_TAG_CODE_UNITS); expect(isStrongHttpEntityTag(exactCandidate)).toBe(true); }); + + it('rejects an oversized initial durable validator before retaining session state', () => { + expect(() => + createDocumentAutosaveSession({ + initialStrongEntityTag: createOversizedEntityTag(), + save: () => ({ status: 'conflict' }), + }), + ).toThrowError(expect.objectContaining({ code: 'invalid_options' })); + }); + + it('rejects an oversized recovered validator without replacing the durable base', async () => { + const session = createDocumentAutosaveSession({ + initialStrongEntityTag: '"server-one"', + save: () => ({ status: 'conflict' }), + }); + + await expect(session.enqueue(createEvidence())).resolves.toMatchObject({ + status: 'conflict', + }); + expect(() => session.resume(createOversizedEntityTag())).toThrowError( + expect.objectContaining({ code: 'invalid_recovery_validator' }), + ); + expect(session.getSnapshot()).toMatchObject({ + state: 'blocked', + durableStrongEntityTag: '"server-one"', + }); + }); + + it('fails closed when a save callback returns an oversized replacement validator', async () => { + const session = createDocumentAutosaveSession({ + initialStrongEntityTag: '"server-one"', + save: () => ({ + status: 'saved', + nextStrongEntityTag: createOversizedEntityTag(), + }), + }); + + await expect(session.enqueue(createEvidence())).rejects.toMatchObject({ + code: 'invalid_save_result', + }); + expect(session.getSnapshot()).toMatchObject({ + state: 'blocked', + blockedReason: 'failure', + durableStrongEntityTag: '"server-one"', + }); + }); }); From abf9e23e295d91d1ca7a01898f8ee0cf88533078 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 15:45:57 +0900 Subject: [PATCH 04/25] docs(index): expose provider-neutral autosave contract --- docs/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/README.md b/docs/README.md index 2b617f7cc..8d8a60001 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,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 | From 02d1138510dab561e71db13e4d94a73c5b3037b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 15:46:54 +0900 Subject: [PATCH 05/25] test(reliability): prove exact durable ETag ceiling semantics --- .../session.entityTagResourceBoundary.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/autosave/session.entityTagResourceBoundary.test.ts b/src/autosave/session.entityTagResourceBoundary.test.ts index 6bc173d9e..a259d7ed7 100644 --- a/src/autosave/session.entityTagResourceBoundary.test.ts +++ b/src/autosave/session.entityTagResourceBoundary.test.ts @@ -39,11 +39,21 @@ describe('durable autosave entity-tag resource boundary', () => { regexTest.mockRestore(); }); - it('preserves a syntactically valid validator at the exact ceiling', () => { - const exactCandidate = `"${'a'.repeat(MAX_ACCEPTED_ENTITY_TAG_CODE_UNITS - 2)}"`; + 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(exactCandidate).toHaveLength(MAX_ACCEPTED_ENTITY_TAG_CODE_UNITS); - expect(isStrongHttpEntityTag(exactCandidate)).toBe(true); + 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 durable validator before retaining session state', () => { From aceea538316bb7b4f29b943ea06df97ff7127f34 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 15:48:17 +0900 Subject: [PATCH 06/25] docs(doctoring): record durable ETag resource boundary --- .../durable-etag-resource-boundary.md | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 docs/doctoring/durable-etag-resource-boundary.md 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 From b957779342e6e44a71b49069cc23ef8c72bb1e61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 15:48:52 +0900 Subject: [PATCH 07/25] test(package): verify autosave ETag resource ceiling --- .../session.entityTagPackageBoundary.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/autosave/session.entityTagPackageBoundary.test.ts 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); + }); +}); From b356eb900848cda6073d9aca826b64274a9d5408 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 15:50:02 +0900 Subject: [PATCH 08/25] docs(index): distinguish active autosave resource proposal --- docs/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/README.md b/docs/README.md index 8d8a60001..75f26f82e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,6 +31,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: From f5e1fa4496be28c7de28a27bc770d75b71ef5138 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 15:50:48 +0900 Subject: [PATCH 09/25] test(reliability): prove oversized ETags never reach observers --- .../session.entityTagResourceBoundary.test.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/autosave/session.entityTagResourceBoundary.test.ts b/src/autosave/session.entityTagResourceBoundary.test.ts index a259d7ed7..74961995c 100644 --- a/src/autosave/session.entityTagResourceBoundary.test.ts +++ b/src/autosave/session.entityTagResourceBoundary.test.ts @@ -3,6 +3,7 @@ import { createDocumentAutosaveSession, isStrongHttpEntityTag, type DocumentAutosaveRevisionEvidence, + type DocumentAutosaveSessionSnapshot, } from './package.js'; const MAX_ACCEPTED_ENTITY_TAG_CODE_UNITS = 64 * 1024; @@ -83,22 +84,35 @@ describe('durable autosave entity-tag resource boundary', () => { }); }); - it('fails closed when a save callback returns an oversized replacement validator', async () => { + it('fails closed without emitting an oversized replacement validator', async () => { + const oversizedEntityTag = createOversizedEntityTag(); + const observedSnapshots: DocumentAutosaveSessionSnapshot[] = []; const session = createDocumentAutosaveSession({ initialStrongEntityTag: '"server-one"', save: () => ({ status: 'saved', - nextStrongEntityTag: createOversizedEntityTag(), + nextStrongEntityTag: oversizedEntityTag, }), + onSnapshotChange(snapshot) { + observedSnapshots.push(snapshot); + }, }); await expect(session.enqueue(createEvidence())).rejects.toMatchObject({ code: 'invalid_save_result', }); + 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(oversizedEntityTag); }); }); From 3262a1a3e589bbf4915a44cb4b8d39de00bf7b1a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 15:55:01 +0900 Subject: [PATCH 10/25] test(docs): enforce active ETag resource-boundary maturity --- src/durableEtagResourceDocumentation.test.ts | 34 ++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/durableEtagResourceDocumentation.test.ts 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'); + }); +}); From d481346f7bf88e06c6e3927830855532156b2376 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 15:56:15 +0900 Subject: [PATCH 11/25] test(reliability): prove oversized ETag failures stay redacted --- .../session.entityTagResourceBoundary.test.ts | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/src/autosave/session.entityTagResourceBoundary.test.ts b/src/autosave/session.entityTagResourceBoundary.test.ts index 74961995c..ef7ea57b8 100644 --- a/src/autosave/session.entityTagResourceBoundary.test.ts +++ b/src/autosave/session.entityTagResourceBoundary.test.ts @@ -7,6 +7,7 @@ import { } 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 { @@ -27,7 +28,7 @@ function createEvidence(): DocumentAutosaveRevisionEvidence { /** Create one syntactically RFC-compatible tag beyond Inkspan's local ceiling. */ function createOversizedEntityTag(): string { - return `"${'a'.repeat(MAX_ACCEPTED_ENTITY_TAG_CODE_UNITS)}"`; + return `"${PRIVATE_ETAG_MARKER}${'a'.repeat(MAX_ACCEPTED_ENTITY_TAG_CODE_UNITS)}"`; } describe('durable autosave entity-tag resource boundary', () => { @@ -57,16 +58,22 @@ describe('durable autosave entity-tag resource boundary', () => { expect(isStrongHttpEntityTag(oneOverCandidate)).toBe(false); }); - it('rejects an oversized initial durable validator before retaining session state', () => { - expect(() => + it('rejects an oversized initial validator with a payload-redacted error', () => { + let capturedError: unknown; + try { createDocumentAutosaveSession({ initialStrongEntityTag: createOversizedEntityTag(), save: () => ({ status: 'conflict' }), - }), - ).toThrowError(expect.objectContaining({ code: 'invalid_options' })); + }); + } 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 the durable base', async () => { + it('rejects an oversized recovered validator without replacing or exposing it', async () => { const session = createDocumentAutosaveSession({ initialStrongEntityTag: '"server-one"', save: () => ({ status: 'conflict' }), @@ -75,16 +82,21 @@ describe('durable autosave entity-tag resource boundary', () => { await expect(session.enqueue(createEvidence())).resolves.toMatchObject({ status: 'conflict', }); - expect(() => session.resume(createOversizedEntityTag())).toThrowError( - expect.objectContaining({ code: 'invalid_recovery_validator' }), - ); + 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 an oversized replacement validator', async () => { + it('fails closed without emitting or exposing an oversized replacement validator', async () => { const oversizedEntityTag = createOversizedEntityTag(); const observedSnapshots: DocumentAutosaveSessionSnapshot[] = []; const session = createDocumentAutosaveSession({ @@ -98,9 +110,12 @@ describe('durable autosave entity-tag resource boundary', () => { }, }); - await expect(session.enqueue(createEvidence())).rejects.toMatchObject({ - code: 'invalid_save_result', - }); + 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', @@ -113,6 +128,6 @@ describe('durable autosave entity-tag resource boundary', () => { (snapshot) => snapshot.durableStrongEntityTag === '"server-one"', ), ).toBe(true); - expect(JSON.stringify(observedSnapshots)).not.toContain(oversizedEntityTag); + expect(JSON.stringify(observedSnapshots)).not.toContain(PRIVATE_ETAG_MARKER); }); }); From a063212389b4678ddef8033fb102367d621258d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 17:07:28 +0900 Subject: [PATCH 12/25] test(reliability): preflight malformed durable save status --- .../session.entityTagResourceBoundary.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/autosave/session.entityTagResourceBoundary.test.ts b/src/autosave/session.entityTagResourceBoundary.test.ts index ef7ea57b8..f4f74dadb 100644 --- a/src/autosave/session.entityTagResourceBoundary.test.ts +++ b/src/autosave/session.entityTagResourceBoundary.test.ts @@ -130,4 +130,30 @@ describe('durable autosave entity-tag resource boundary', () => { ).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); + }); }); From dba2d9f124e825462178f3c62ebb903c7ec21004 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 17:12:31 +0900 Subject: [PATCH 13/25] fix(reliability): preflight durable save status --- src/autosave/session.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/autosave/session.ts b/src/autosave/session.ts index 6e004c37e..8d2890a13 100644 --- a/src/autosave/session.ts +++ b/src/autosave/session.ts @@ -265,21 +265,21 @@ 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') + !Object.prototype.hasOwnProperty.call(statusDescriptor, 'value') || + (statusDescriptor.value !== 'conflict' && statusDescriptor.value !== 'saved') ) { return null; } + const keys = Reflect.ownKeys(value); 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') @@ -472,4 +472,4 @@ export function createDocumentAutosaveSession( close, getSnapshot, }); -} +} \ No newline at end of file From 8fa10e81a92128190eade210c089431ce1085904 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:31:43 +0900 Subject: [PATCH 14/25] test(autosave): reject hidden durable result fields --- .../session.entityTagResourceBoundary.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/autosave/session.entityTagResourceBoundary.test.ts b/src/autosave/session.entityTagResourceBoundary.test.ts index f4f74dadb..e391aa8d6 100644 --- a/src/autosave/session.entityTagResourceBoundary.test.ts +++ b/src/autosave/session.entityTagResourceBoundary.test.ts @@ -156,4 +156,36 @@ describe('durable autosave entity-tag resource boundary', () => { expect((capturedError as Error).message).not.toContain(PRIVATE_ETAG_MARKER); expect(ownKeysCalls).toBe(0); }); + + 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"', + }); + } + }); }); From ffe87e8bc09c849c1bb322b9061f9b0245ebf1e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:35:25 +0900 Subject: [PATCH 15/25] fix(autosave): reject hidden durable result fields --- src/autosave/session.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/autosave/session.ts b/src/autosave/session.ts index 8d2890a13..495de97a6 100644 --- a/src/autosave/session.ts +++ b/src/autosave/session.ts @@ -268,6 +268,7 @@ function readDurableSaveResult( const statusDescriptor = Object.getOwnPropertyDescriptor(value, 'status'); if ( statusDescriptor === undefined || + !statusDescriptor.enumerable || !Object.prototype.hasOwnProperty.call(statusDescriptor, 'value') || (statusDescriptor.value !== 'conflict' && statusDescriptor.value !== 'saved') ) { @@ -292,6 +293,7 @@ function readDurableSaveResult( ); if ( nextDescriptor === undefined || + !nextDescriptor.enumerable || !Object.prototype.hasOwnProperty.call(nextDescriptor, 'value') || !isStrongHttpEntityTag(nextDescriptor.value) ) { @@ -472,4 +474,4 @@ export function createDocumentAutosaveSession( close, getSnapshot, }); -} \ No newline at end of file +} From 79cd18d943c095aa2b345d4fc56b517a03f3dfa8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:39:19 +0900 Subject: [PATCH 16/25] test(autosave): preflight saved validator before own-key scan --- .../session.entityTagResourceBoundary.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/autosave/session.entityTagResourceBoundary.test.ts b/src/autosave/session.entityTagResourceBoundary.test.ts index e391aa8d6..47722f1da 100644 --- a/src/autosave/session.entityTagResourceBoundary.test.ts +++ b/src/autosave/session.entityTagResourceBoundary.test.ts @@ -157,6 +157,32 @@ describe('durable autosave entity-tag resource boundary', () => { 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 non-enumerable durable result fields as non-contract objects', async () => { const hiddenConflict = Object.defineProperty({}, 'status', { value: 'conflict', From 5cbbaaca66843bc2c103de59b1d05b17c431e29b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:43:59 +0900 Subject: [PATCH 17/25] fix(autosave): preflight saved validator before own-key scan --- src/autosave/session.ts | 57 +++++++++++++++++++++-------------------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/src/autosave/session.ts b/src/autosave/session.ts index 495de97a6..878e479e8 100644 --- a/src/autosave/session.ts +++ b/src/autosave/session.ts @@ -274,35 +274,36 @@ function readDurableSaveResult( ) { return null; } - const keys = Reflect.ownKeys(value); - if (statusDescriptor.value === 'conflict') { - return keys.length === 1 && keys[0] === 'status' - ? Object.freeze({ status: 'conflict' }) - : null; - } - if ( - keys.length !== 2 || - !keys.includes('status') || - !keys.includes('nextStrongEntityTag') - ) { - return null; - } - const nextDescriptor = Object.getOwnPropertyDescriptor( - value, - 'nextStrongEntityTag', - ); - if ( - nextDescriptor === undefined || - !nextDescriptor.enumerable || - !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; } From 9b5ade5be709bd576686eb4e839e9428ca9314cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:48:27 +0900 Subject: [PATCH 18/25] test(autosave): cover exact saved-result shape rejection --- .../session.entityTagResourceBoundary.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/autosave/session.entityTagResourceBoundary.test.ts b/src/autosave/session.entityTagResourceBoundary.test.ts index 47722f1da..5f4e33469 100644 --- a/src/autosave/session.entityTagResourceBoundary.test.ts +++ b/src/autosave/session.entityTagResourceBoundary.test.ts @@ -183,6 +183,28 @@ describe('durable autosave entity-tag resource boundary', () => { 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', From edfd32cdf515f40041941a6b1ffa3788e9299c6c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:56:19 +0900 Subject: [PATCH 19/25] fix(ci): reconcile release workflow with protected main --- .github/workflows/release.yml | 94 ++++++++++++++++++++++++++++++----- 1 file changed, 82 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cfb80a5ab..2cabb6dab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: with: fetch-depth: 0 - name: Set up pnpm - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -91,7 +91,8 @@ jobs: mv "$package_file" release/ - name: Install hash-locked Office dependencies working-directory: office - run: python -m pip install --require-hashes --only-binary=:all: -r requirements-ci.txt + run: | + python -m pip install --require-hashes --only-binary=:all: -r requirements-ci.txt - name: Verify Office dependency consistency working-directory: office run: python -m pip check @@ -121,11 +122,65 @@ jobs: assert any(name.endswith('.dist-info/licenses/LICENSE') for name in names) PY mv dist/*.whl ../release/ + - name: Install Cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + with: + cosign-release: 'v3.0.6' + - name: Install signature-verified Syft + run: | + set -euo pipefail + syft_installer="$RUNNER_TEMP/syft-install.sh" + curl --fail --silent --show-error --location \ + --proto '=https' \ + --output "$syft_installer" \ + https://raw.githubusercontent.com/anchore/syft/16223e6dd7893fe578787658ceb876257483d404/install.sh + mkdir -p "$RUNNER_TEMP/syft-bin" + DOWNLOAD_TAG_INSTALL_SCRIPT=false \ + sh "$syft_installer" -v -b "$RUNNER_TEMP/syft-bin" v1.50.0 + "$RUNNER_TEMP/syft-bin/syft" version + echo "$RUNNER_TEMP/syft-bin" >> "$GITHUB_PATH" + - name: Generate release SBOM + run: | + set -euo pipefail + syft scan dir:. -o spdx-json > release/inkspan.spdx.json + - name: Validate release SBOM + run: | + set -euo pipefail + node <<'NODE' + const { readFileSync, statSync } = require('node:fs'); + + const sbomPath = 'release/inkspan.spdx.json'; + const sbom = JSON.parse(readFileSync(sbomPath, 'utf8')); + const packageMetadata = JSON.parse(readFileSync('package.json', 'utf8')); + const officeMetadata = readFileSync('office/pyproject.toml', 'utf8'); + if (statSync(sbomPath).size > 16 * 1024 * 1024) { + throw new Error('Release SBOM exceeds the 16 MiB actions/attest input limit.'); + } + if (sbom.spdxVersion !== 'SPDX-2.3') { + throw new Error(`Release SBOM must be SPDX-2.3; found ${sbom.spdxVersion ?? 'missing'}.`); + } + if (!Array.isArray(sbom.packages) || sbom.packages.length === 0) { + throw new Error('Release SBOM package inventory must not be empty.'); + } + const sbomPackageNames = new Set(sbom.packages.map((pkg) => pkg.name)); + if (packageMetadata.name !== '@contextualwisdomlab/cwl-editor') { + throw new Error('Release source has an unexpected editor package identity.'); + } + if (!/^name\s*=\s*["']inkspan-office["']\s*$/m.test(officeMetadata)) { + throw new Error('Release source has an unexpected Office package identity.'); + } + if (!sbomPackageNames.has(packageMetadata.name)) { + throw new Error('Release SBOM inventory must include the editor package identity.'); + } + if (!sbomPackageNames.has('inkspan-office')) { + throw new Error('Release SBOM inventory must include the Office package identity.'); + } + NODE - name: Generate release checksums run: | set -euo pipefail cd release - sha256sum -- *.tgz *.whl > SHA256SUMS + sha256sum -- *.tgz *.whl inkspan.spdx.json > SHA256SUMS - name: Transfer exact release artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -152,7 +207,7 @@ jobs: ref: ${{ github.sha }} persist-credentials: false - name: Set up pnpm - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -237,7 +292,7 @@ jobs: - name: Verify bounded local release artifact set run: | set -euo pipefail - expected_asset_count=3 + expected_asset_count=4 mapfile -t local_entries < <( find release -mindepth 1 -maxdepth 1 -printf '%f\n' | LC_ALL=C sort ) @@ -254,8 +309,9 @@ jobs: || ${#local_assets[@]} -ne $expected_asset_count \ || ${#npm_assets[@]} -ne 1 \ || ${#wheel_assets[@]} -ne 1 \ + || ! -f release/inkspan.spdx.json \ || ! -f release/SHA256SUMS ]]; then - echo "::error::Unexpected local release artifact set; require exactly one *.tgz, one *.whl, and SHA256SUMS." + echo "::error::Unexpected local release artifact set; require exactly one *.tgz, one *.whl, inkspan.spdx.json, and SHA256SUMS." exit 1 fi - name: Attest release artifacts @@ -264,15 +320,28 @@ jobs: subject-path: | release/*.tgz release/*.whl + release/inkspan.spdx.json release/SHA256SUMS + - name: Attest release packages with SBOM + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-path: | + release/*.tgz + release/*.whl + sbom-path: release/inkspan.spdx.json - name: Verify generated attestations env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - for artifact in release/*.tgz release/*.whl release/SHA256SUMS; do + for artifact in release/*.tgz release/*.whl release/inkspan.spdx.json release/SHA256SUMS; do gh attestation verify "$artifact" --repo "$GITHUB_REPOSITORY" done + for artifact in release/*.tgz release/*.whl; do + gh attestation verify "$artifact" \ + --repo "$GITHUB_REPOSITORY" \ + --predicate-type https://spdx.dev/Document/v2.3 + done - name: Prepare draft GitHub release env: GH_TOKEN: ${{ github.token }} @@ -304,7 +373,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - expected_asset_count=3 + expected_asset_count=4 mapfile -t local_entries < <( find release -mindepth 1 -maxdepth 1 -printf '%f\n' | LC_ALL=C sort ) @@ -321,8 +390,9 @@ jobs: || ${#local_assets[@]} -ne $expected_asset_count \ || ${#npm_assets[@]} -ne 1 \ || ${#wheel_assets[@]} -ne 1 \ + || ! -f release/inkspan.spdx.json \ || ! -f release/SHA256SUMS ]]; then - echo "::error::Unexpected local release artifact set; require exactly one *.tgz, one *.whl, and SHA256SUMS." + echo "::error::Unexpected local release artifact set; require exactly one *.tgz, one *.whl, inkspan.spdx.json, and SHA256SUMS." exit 1 fi @@ -414,7 +484,7 @@ jobs: fi gh release verify "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" - for artifact in release/*.tgz release/*.whl release/SHA256SUMS; do + for artifact in release/*.tgz release/*.whl release/inkspan.spdx.json release/SHA256SUMS; do gh release verify-asset "$GITHUB_REF_NAME" "$artifact" \ --repo "$GITHUB_REPOSITORY" done @@ -600,7 +670,7 @@ jobs: process.exit(2); } process.stdout.write(url.origin); - NODE + NODE )" || { echo "::error::npm dist.tarball must stay on the canonical registry.npmjs.org HTTPS origin." exit 1 @@ -646,4 +716,4 @@ jobs: done echo "::error::Registry publication verification did not converge to the exact artifact digests." - exit 1 + exit 1 \ No newline at end of file From c72e9255aaa8865c6e01708c5bc3626136dfc973 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 23:06:03 +0900 Subject: [PATCH 20/25] fix(ci): align release asset contract tests --- src/releaseDraftAssetEntryType.test.ts | 6 +++++- src/releaseDraftAssetInventory.test.ts | 9 ++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/releaseDraftAssetEntryType.test.ts b/src/releaseDraftAssetEntryType.test.ts index 4b1d9dd32..be84fbb12 100644 --- a/src/releaseDraftAssetEntryType.test.ts +++ b/src/releaseDraftAssetEntryType.test.ts @@ -56,6 +56,10 @@ function runLocalReleaseInventory( mkdirSync(releaseDirectory); writeFileSync(join(releaseDirectory, 'inkspan.tgz'), 'npm-package'); writeFileSync(join(releaseDirectory, 'inkspan_office.whl'), 'office-wheel'); + writeFileSync( + join(releaseDirectory, 'inkspan.spdx.json'), + '{"spdxVersion":"SPDX-2.3","packages":[]}', + ); writeFileSync(join(releaseDirectory, 'SHA256SUMS'), 'checksums'); mutate?.(releaseDirectory); @@ -74,7 +78,7 @@ function runLocalReleaseInventory( } describe('local release artifact entry-type boundary', () => { - it('accepts exactly the three expected regular release files', () => { + it('accepts exactly the four expected regular release files', () => { if (process.platform !== 'linux') return; const result = runLocalReleaseInventory(); diff --git a/src/releaseDraftAssetInventory.test.ts b/src/releaseDraftAssetInventory.test.ts index 972abe0fb..ff785feef 100644 --- a/src/releaseDraftAssetInventory.test.ts +++ b/src/releaseDraftAssetInventory.test.ts @@ -79,6 +79,7 @@ function runReleaseInventory( const localFiles = { 'inkspan.tgz': 'npm-package', 'inkspan_office.whl': 'office-wheel', + 'inkspan.spdx.json': '{"spdxVersion":"SPDX-2.3","packages":[]}', SHA256SUMS: 'checksums', } as const; for (const [name, content] of Object.entries(localFiles)) { @@ -153,9 +154,10 @@ describe('release draft asset inventory contract', () => { localValidationIndex, attestIndex, ); - expect(localValidationStep).toContain('expected_asset_count=3'); + expect(localValidationStep).toContain('expected_asset_count=4'); expect(localValidationStep).toContain('*.tgz'); expect(localValidationStep).toContain('*.whl'); + expect(localValidationStep).toContain('inkspan.spdx.json'); expect(localValidationStep).toContain('SHA256SUMS'); expect(localValidationStep).toContain( 'Unexpected local release artifact set', @@ -195,7 +197,7 @@ describe('release draft asset inventory contract', () => { expect(inventoryStep).toContain('Draft release asset digest mismatch'); }); - it('admits only the expected npm, wheel, and checksum artifact set', () => { + it('admits only the expected npm, wheel, SBOM, and checksum artifact set', () => { const inventoryIndex = workflow.indexOf( '- name: Verify exact draft release asset inventory', ); @@ -204,9 +206,10 @@ describe('release draft asset inventory contract', () => { ); const inventoryStep = workflow.slice(inventoryIndex, publishIndex); - expect(inventoryStep).toContain('expected_asset_count=3'); + expect(inventoryStep).toContain('expected_asset_count=4'); expect(inventoryStep).toContain('*.tgz'); expect(inventoryStep).toContain('*.whl'); + expect(inventoryStep).toContain('inkspan.spdx.json'); expect(inventoryStep).toContain('SHA256SUMS'); expect(inventoryStep).toContain('Unexpected local release artifact set'); expect(inventoryStep).toContain("asset_name='.assets[].name'"); From 71d1bf2743446530d657ed2ac864312d1781ab78 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:41:25 +0900 Subject: [PATCH 21/25] docs(release): converge four-file SBOM contract --- docs/release-security.md | 53 +++++++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/docs/release-security.md b/docs/release-security.md index 5e1e01574..08e1d6ff7 100644 --- a/docs/release-security.md +++ b/docs/release-security.md @@ -1,6 +1,6 @@ # Release security and provenance contract -Inkspan release artifacts are part of the product boundary. Buyers and CWL/naruon integrators must be able to determine which source revision produced an npm tarball or Office wheel, verify that the artifact was not substituted, and reproduce the repository's release gates without trusting a long-lived publication secret. +Inkspan release artifacts are part of the product boundary. Buyers and CWL/naruon integrators must be able to determine which source revision produced an npm tarball or Office wheel, inspect the release SBOM, verify that no artifact was substituted, and reproduce the repository's release gates without trusting a long-lived publication secret. ## Release trigger and identity @@ -20,7 +20,7 @@ For stable registry releases, the root and Office package versions must both equ The GitHub Release path has a source-bearing build stage followed by a source-free publication stage, and external registry publication is downstream of that validated artifact boundary: -1. `build-release-artifacts` has read-only repository access. It checks identity, installs dependencies, runs all quality gates, builds both distributions, and creates checksums. +1. `build-release-artifacts` has read-only repository access. It checks identity, installs dependencies, runs all quality gates, builds both distributions, generates an SPDX 2.3 SBOM with signature-verified Syft, validates the SBOM, and creates checksums for the complete release set. 2. `publish-release` receives only the validated files through GitHub's workflow artifact service. This smaller job alone receives the GitHub release, OpenID Connect, and attestation authority needed to create the immutable GitHub Release. 3. `publish-npm` and `publish-pypi` consume the same validated npm tarball and Office wheel after the GitHub Release boundary. They receive OIDC only inside their protected registry environments and do not rebuild the packages. 4. `verify-registry-publication` has no publishing credential. It performs post-publication digest verification against the public npm and PyPI registry identities and the exact validated local artifacts. @@ -44,13 +44,23 @@ The release workflow repeats merge and product gates against the tagged source r 9. hash-locked Office dependency installation on Python 3.14 for the release build; 10. Office dependency consistency, 100% shipped-symbol docstring coverage, and 100% branch coverage; 11. Office wheel construction and inspection for the bundled schema and license; -12. SHA-256 checksum generation for every distributable artifact; -13. checksum verification after the privilege boundary; -14. exact draft asset inventory and digest verification before GitHub publication; and -15. public npm and PyPI post-publication digest verification for stable registry releases. +12. installation of the exact Syft v1.50.0 release through its commit-pinned installer with Cosign verification enabled, so the signed checksum material is verified before the Syft binary is accepted; +13. deterministic SPDX 2.3 SBOM generation and validation for a non-empty inventory containing both `@contextualwisdomlab/cwl-editor` and `inkspan-office`; +14. SHA-256 checksum generation for the npm tarball, Office wheel, `inkspan.spdx.json`, and checksum manifest boundary; +15. checksum verification after the privilege boundary; +16. exact draft asset inventory and digest verification before GitHub publication; and +17. public npm and PyPI post-publication digest verification for stable registry releases. No release draft is created or modified unless every source-bearing build gate succeeds on the tagged commit. A stable release is not treated as registry-complete until both registry publication jobs and the downstream public digest verification succeed. +## SBOM generator trust boundary + +The release path does not delegate Syft installation to an action that can retrieve a mutable installer from another branch. It installs Cosign from a full-commit-pinned `sigstore/cosign-installer` action, downloads Syft's installer from the exact commit behind the annotated `v1.50.0` tag, disables installer-script redirection with `DOWNLOAD_TAG_INSTALL_SCRIPT=false`, and invokes the installer with `-v`. The Syft installer therefore verifies the release checksum signature and certificate before accepting the downloaded Syft binary, then still verifies the binary checksum. + +Only that signature-verified Syft executable is added to the workflow `PATH` and used to generate `release/inkspan.spdx.json`. The workflow then validates the SPDX version, package inventory, expected Inkspan package identities, and the bounded attestation-input size before the SBOM can cross the build/publication privilege boundary. + +This controls the generator bootstrap path; it does not assert that an SBOM is a vulnerability scan or license-policy decision. Consumers and release operators must interpret the inventory separately from provenance and security-scan results. + ## Immutable GitHub publication Immutable releases must be enabled for the canonical repository before a release tag is pushed. Reading or changing that repository setting requires Administration permission, which the release workflow intentionally does not receive. Instead, the workflow verifies the immutable state of the published release through the ordinary release API available to its narrowly scoped contents token. @@ -71,11 +81,12 @@ A resumed draft is not assumed to contain only artifacts from the current workfl Immediately after upload and before the draft is published, the workflow therefore fails closed unless all of these conditions hold: -- the local release directory contains exactly one npm `*.tgz`, one Office `*.whl`, and `SHA256SUMS`; +- the local release directory contains exactly one npm `*.tgz`, one Office `*.whl`, `inkspan.spdx.json`, and `SHA256SUMS`; +- `SHA256SUMS` binds the npm tarball, Office wheel, and SBOM digest to the transferred local release set; - the canonical GitHub Releases API still reports the release as a draft; - the sorted remote asset-name set exactly equals the sorted local artifact-name set; - every remote asset reports the `uploaded` state; and -- every GitHub release-asset `sha256:` digest exactly equals a newly computed SHA-256 digest of the corresponding transferred local file. +- every GitHub release-asset `sha256:` digest, including the SBOM digest and checksum-manifest digest, exactly equals a newly computed SHA-256 digest of the corresponding transferred local file. The draft lookup deliberately uses the authenticated, paginated **List releases** REST endpoint and filters its complete result for the exact tag. GitHub documents that authenticated callers with repository push access receive draft releases from this endpoint. The `Get a release by tag name` endpoint is documented for a **published** release, so it is not used as evidence for this pre-publication gate. The publish job fails unless the paginated listing contains exactly one release matching the tag and that object still reports `draft: true`. @@ -91,19 +102,20 @@ Enabling immutable releases is an administrative repository control. Repository ## Published artifacts -Each successful GitHub release contains: +Each successful GitHub release contains exactly four files: - the exact npm tarball produced by `npm pack`; -- the `inkspan-office` wheel built from `office/`; and -- `SHA256SUMS` covering both distributable artifacts. +- the `inkspan-office` wheel built from `office/`; +- `inkspan.spdx.json`, the validated SPDX 2.3 SBOM generated by signature-verified Syft; and +- `SHA256SUMS` covering the npm tarball, Office wheel, and SBOM. -The workflow does not rebuild artifacts after the read-only build job. The same transferred files are checksum-verified, attested, uploaded, inventory-checked against the draft, published to GitHub, and—on stable releases—forwarded to npm and PyPI. +The workflow does not rebuild artifacts after the read-only build job. The same transferred files are checksum-verified, attested, uploaded, inventory-checked against the draft, and published to GitHub; on stable releases, the npm tarball and Office wheel are then forwarded unchanged to npm and PyPI. ## Provenance and verification -The isolated GitHub publication job requests a short-lived OpenID Connect identity and uses GitHub artifact attestations to create signed SLSA provenance for the npm tarball, Office wheel, and checksum manifest. The repository is public, so the attestation is backed by the public Sigstore transparency infrastructure used by GitHub. +The isolated GitHub publication job requests a short-lived OpenID Connect identity and uses GitHub artifact attestations to create signed SLSA provenance for the npm tarball, Office wheel, `inkspan.spdx.json`, and checksum manifest. It also creates SPDX SBOM attestations binding the npm tarball and Office wheel to the validated `inkspan.spdx.json` predicate. The repository is public, so the attestation is backed by the public Sigstore transparency infrastructure used by GitHub. -Consumers should verify release-level and file-level provenance as well as checksums, using the actual version and filenames from the selected release: +Consumers should verify release-level and file-level provenance, the SBOM predicate, and checksums, using the actual version and filenames from the selected release: ```bash VERSION=0.6.0 @@ -113,9 +125,16 @@ gh release verify "v${VERSION}" --repo ContextualWisdomLab/inkspan gh release verify-asset "v${VERSION}" "contextualwisdomlab-cwl-editor-${VERSION}.tgz" \ --repo ContextualWisdomLab/inkspan +gh release verify-asset "v${VERSION}" "inkspan.spdx.json" \ + --repo ContextualWisdomLab/inkspan + gh attestation verify "inkspan_office-${VERSION}-py3-none-any.whl" \ --repo ContextualWisdomLab/inkspan +gh attestation verify "contextualwisdomlab-cwl-editor-${VERSION}.tgz" \ + --repo ContextualWisdomLab/inkspan \ + --predicate-type https://spdx.dev/Document/v2.3 + sha256sum --check SHA256SUMS ``` @@ -134,11 +153,15 @@ npm and PyPI are independent immutable publication domains. If one registry acce ## Workflow security properties - Every third-party GitHub Action is pinned to a complete commit SHA. +- Syft is installed from the exact commit behind v1.50.0 with signed-checksum verification enabled; a mutable branch installer is not part of the supported generator path. +- Only the signature-verified Syft binary generates the release SBOM. - The default and source-bearing build-job workflow tokens are read-only. - GitHub release, OpenID Connect, and attestation permissions are scoped to the source-free jobs that actually require them. - Release tags must identify the exact current protected-main tip. - Stable root, Office, and tag versions must match before registry publication. +- The local and draft release contract is exactly one npm tarball, one Office wheel, `inkspan.spdx.json`, and `SHA256SUMS`. - The draft asset set and every GitHub-reported SHA-256 asset digest must exactly match the transferred local release set before GitHub publication. +- The SBOM digest is covered by `SHA256SUMS`, remote release-asset digest verification, and release provenance; package attestations additionally bind the distributable packages to the SPDX predicate. - The published GitHub release must report an immutable state; a mutable outcome is deleted and rejected. - Existing published assets are never refreshed, replaced, or deleted by a successful workflow path. - Stable npm and PyPI publication uses protected OIDC environments rather than long-lived registry secrets. @@ -161,6 +184,8 @@ The release pipeline does not add runtime coupling. The npm package remains a ho - GitHub release attestation verification: - GitHub artifact attestations: - GitHub artifact-attestation concepts: +- Syft signed-release installer verification: +- Sigstore Cosign installer: - npm Trusted Publishing and automatic provenance: - PyPI Trusted Publishing: - PyPI Trusted Publishing security model: From 0e0694a7f4a8cfe6dc3f96e1d67a964d4814bf89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 11:32:45 +0900 Subject: [PATCH 22/25] fix(release): preserve protected exact-checkout workflow on autosave branch --- .github/workflows/release.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2cabb6dab..e1d41ef66 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,9 +22,18 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out the tagged source - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.sha }} fetch-depth: 0 + persist-credentials: false + - name: Verify exact checkout + env: + INKSPAN_EXPECTED_HEAD_SHA: ${{ github.sha }} + run: | + set -euo pipefail + actual_head="$(git rev-parse HEAD)" + test "$actual_head" = "$INKSPAN_EXPECTED_HEAD_SHA" - name: Set up pnpm uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - name: Set up Node.js @@ -468,7 +477,6 @@ jobs: gh release edit "$GITHUB_REF_NAME" \ --repo "$GITHUB_REPOSITORY" \ --draft=false - release_immutable="$(gh release view "$GITHUB_REF_NAME" \ --repo "$GITHUB_REPOSITORY" \ --json isImmutable \ From 18b7ff4d5d6a8690a670d9dfa3c6cf031022a2ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:26:47 +0900 Subject: [PATCH 23/25] test(ci): cover event-specific Python matrix Signed-off-by: Seongho Bae --- office/tests/test_python_support_contract.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/office/tests/test_python_support_contract.py b/office/tests/test_python_support_contract.py index 7104fd661..209f48454 100644 --- a/office/tests/test_python_support_contract.py +++ b/office/tests/test_python_support_contract.py @@ -50,10 +50,14 @@ def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: office_job = _workflow_job_block(workflow, "office") assert "runs-on: ubuntu-24.04" in office_job assert "runs-on: ubuntu-latest" not in office_job - matrix_match = re.search(r'python-version:\s*\[([^\]]+)\]', office_job) + matrix_match = re.search(r"python-version:\s*(.+)", office_job) assert matrix_match is not None - matrix_versions = tuple(re.findall(r'"(3\.\d+)"', matrix_match.group(1))) - assert matrix_versions == SUPPORTED_PYTHON_VERSIONS + pull_request_versions, push_versions = ( + tuple(re.findall(r'"(3\.\d+)"', versions)) + for versions in re.findall(r"fromJSON\('(\[[^']+\])'\)", matrix_match.group(1)) + ) + assert pull_request_versions == (SUPPORTED_PYTHON_VERSIONS[-1],) + assert push_versions == SUPPORTED_PYTHON_VERSIONS def test_python_support_documentation_matches_the_fixed_ci_environment() -> None: From 3dd58388019e9b007fe3f130476a2e6f0c5eb9ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:32:54 +0900 Subject: [PATCH 24/25] test(ci): bind Python matrix to event Signed-off-by: Seongho Bae --- office/tests/test_python_support_contract.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/office/tests/test_python_support_contract.py b/office/tests/test_python_support_contract.py index 209f48454..a52ddec39 100644 --- a/office/tests/test_python_support_contract.py +++ b/office/tests/test_python_support_contract.py @@ -50,11 +50,16 @@ def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: office_job = _workflow_job_block(workflow, "office") assert "runs-on: ubuntu-24.04" in office_job assert "runs-on: ubuntu-latest" not in office_job - matrix_match = re.search(r"python-version:\s*(.+)", office_job) + matrix_match = re.search( + r"python-version:\s*\$\{\{\s*github\.event_name\s*==\s*'pull_request'" + r"\s*&&\s*fromJSON\('(\[[^']+\])'\)\s*\|\|\s*" + r"fromJSON\('(\[[^']+\])'\)\s*\}\}", + office_job, + ) assert matrix_match is not None pull_request_versions, push_versions = ( tuple(re.findall(r'"(3\.\d+)"', versions)) - for versions in re.findall(r"fromJSON\('(\[[^']+\])'\)", matrix_match.group(1)) + for versions in matrix_match.groups() ) assert pull_request_versions == (SUPPORTED_PYTHON_VERSIONS[-1],) assert push_versions == SUPPORTED_PYTHON_VERSIONS From c70c92e0778e15a78cbcd0455473131ee221a566 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:22:13 +0900 Subject: [PATCH 25/25] revert(ci): restore Office contract owner Remove the duplicated Python support contract changes from this autosave ETag branch. PR #405 remains the single writer while this branch keeps its bounded durable ETag delta. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) --- office/tests/test_python_support_contract.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/office/tests/test_python_support_contract.py b/office/tests/test_python_support_contract.py index a52ddec39..7104fd661 100644 --- a/office/tests/test_python_support_contract.py +++ b/office/tests/test_python_support_contract.py @@ -50,19 +50,10 @@ def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: office_job = _workflow_job_block(workflow, "office") assert "runs-on: ubuntu-24.04" in office_job assert "runs-on: ubuntu-latest" not in office_job - matrix_match = re.search( - r"python-version:\s*\$\{\{\s*github\.event_name\s*==\s*'pull_request'" - r"\s*&&\s*fromJSON\('(\[[^']+\])'\)\s*\|\|\s*" - r"fromJSON\('(\[[^']+\])'\)\s*\}\}", - office_job, - ) + matrix_match = re.search(r'python-version:\s*\[([^\]]+)\]', office_job) assert matrix_match is not None - pull_request_versions, push_versions = ( - tuple(re.findall(r'"(3\.\d+)"', versions)) - for versions in matrix_match.groups() - ) - assert pull_request_versions == (SUPPORTED_PYTHON_VERSIONS[-1],) - assert push_versions == SUPPORTED_PYTHON_VERSIONS + matrix_versions = tuple(re.findall(r'"(3\.\d+)"', matrix_match.group(1))) + assert matrix_versions == SUPPORTED_PYTHON_VERSIONS def test_python_support_documentation_matches_the_fixed_ci_environment() -> None: