Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8c5246e
test(reliability): require autosave array preflight
seonghobae Aug 11, 2026
e0f4f54
fix(reliability): preflight autosave array length
seonghobae Aug 11, 2026
d7d8d87
test(reliability): preflight detached autosave digest length
seonghobae Aug 11, 2026
6d75be1
fix(reliability): preflight detached autosave digest length
seonghobae Aug 11, 2026
c55519c
test(reliability): expose autosave array length proxy trap
seonghobae Aug 13, 2026
ca8da4a
fix(reliability): avoid array length getter execution
seonghobae Aug 14, 2026
cba0ecd
fix(autosave): remove unreachable array descriptor branches
seonghobae Aug 14, 2026
83b7d7c
fix(autosave): preserve entity-tag source spelling
seonghobae Aug 14, 2026
286d41e
test(autosave): preflight over-depth array children
seonghobae Aug 15, 2026
38d7568
fix(autosave): preflight over-depth array children
seonghobae Aug 15, 2026
d421cd2
test(autosave): preflight over-depth object children
seonghobae Aug 15, 2026
ccae833
fix(autosave): preflight over-depth object children
seonghobae Aug 15, 2026
f18568a
test(autosave): cover empty object at depth ceiling
seonghobae Aug 15, 2026
60ea3c1
fix(autosave): avoid over-depth object descriptor reads
seonghobae Aug 15, 2026
7138816
fix(autosave): enforce traversal budget before enqueue
seonghobae Aug 16, 2026
5e97c48
chore: synchronize autosave evidence hardening with protected main
seonghobae Aug 17, 2026
245b7c6
test(reliability): preflight missing autosave evidence fields
seonghobae Aug 18, 2026
57baa55
fix(reliability): preflight required autosave evidence fields
seonghobae Aug 18, 2026
385f4f0
test(reliability): preserve exact autosave evidence record shape
seonghobae Aug 18, 2026
fbecfa0
fix(reliability): remove unreachable exact-key rescan
seonghobae Aug 18, 2026
62f406c
test(reliability): cover unfrozen autosave evidence preflight
seonghobae Aug 18, 2026
df227e6
Merge remote-tracking branch 'origin/main' into codex/pr184-restack
seonghobae Sep 4, 2026
5d91500
test(ci): cover event-specific Python matrix
seonghobae Sep 4, 2026
6b41807
test(ci): bind Python matrix to event
seonghobae Sep 4, 2026
e146a47
revert(ci): restore Office contract owner
seonghobae Sep 4, 2026
b775ac3
chore: align autosave preflight owner with protected main
seonghobae Sep 6, 2026
4fa8d65
test(autosave): expose frozen-state enumeration before array limits
seonghobae Sep 7, 2026
3122703
fix(autosave): preflight arrays before frozen-state enumeration
seonghobae Sep 7, 2026
f7f511d
test(autosave): retain bounded array immutability rejection
seonghobae Sep 7, 2026
903b317
docs(autosave): record intrinsic array enumeration preflight
seonghobae Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/doctoring/document-autosave-queue.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,32 @@ hashing boundary. Deep immutability proves that the submitted graph cannot be
mutated after validation; it does not prove that a caller-supplied digest was
honestly derived from that graph.

## Array preflight finding — 2026-09-07

Status: Active PR / Proposed (#184), not protected-main implementation.

The earlier array preflight avoided explicit key enumeration but first checked
whether each container was frozen. Frozen-state inspection also enumerates a
Proxy's keys. A packed public queue probe with a frozen, oversized sparse array
was correctly rejected without calling the host save function, yet its key trap
ran once before rejection. A costly trap could therefore run even when the
array's length already made acceptance impossible.

The candidate keeps early frozen-state validation for objects and checks array
length, remaining value capacity, and child depth before array frozen-state
inspection. It still rejects bounded mutable arrays and preserves dense-array,
descriptor, prototype, cycle, schema, digest, and detached-snapshot checks.
No API, limit, durable-save authority, or error category changes. This avoids
unnecessary enumeration; it does not place a time limit on arbitrary Proxy code
that runs during otherwise necessary reflection.

The two regressions in `evidenceValidationArrayPreflight.test.ts` first failed
with one key-trap invocation instead of zero for oversized and over-depth
arrays. A separate bounded-unfrozen-array case preserves the immutability gate.
Both public queue and durable session reuse this validation boundary. New-head
packed consumer verification remains required; predecessor archive verification
does not prove the candidate implementation.

## Security and privacy considerations

Revision tags are equality validators, not authorization tokens, signatures,
Expand Down
36 changes: 36 additions & 0 deletions src/autosave/detachedDigestResourceBoundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { createDetachedAutosaveRevisionEvidence } from './evidenceValidation.js';

afterEach(() => {
vi.restoreAllMocks();
});

function createFrozenEvidence(digestHex: string): unknown {
const documentJson = Object.freeze({
type: 'doc',
content: Object.freeze([]),
});
const envelope = Object.freeze({
schemaId: 'https://inkspan.io/schemas/document-envelope/v1',
schemaVersion: 1,
documentJson,
});
const revision = Object.freeze({
algorithm: 'SHA-256',
digestHex,
strongEntityTag: `"sha256-${digestHex}"`,
});
return Object.freeze({ envelope, revision });
}

describe('detached autosave digest resource preflight', () => {
it('rejects an impossible SHA-256 digest length before regex scanning', () => {
const regexTest = vi.spyOn(RegExp.prototype, 'test');

expect(
createDetachedAutosaveRevisionEvidence(createFrozenEvidence('a'.repeat(65))),
).toBeNull();
expect(regexTest).not.toHaveBeenCalled();
});
});
79 changes: 50 additions & 29 deletions src/autosave/evidenceValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ interface JsonTraversalEntry {
readonly depth: number;
}

type JsonContainerChildren =
| Readonly<{ kind: 'array'; length: number }>
| Readonly<{ kind: 'object'; keys: (string | symbol)[] }>;

/** Detached evidence shape returned to the public autosave queue. */
export interface DetachedDocumentAutosaveRevisionEvidence {
/** Detached active-schema document envelope. */
Expand Down Expand Up @@ -46,23 +50,19 @@ function readExactFrozenDataRecord(
expectedKeys: readonly string[],
): ExactDataRecord | null {
try {
if (
typeof value !== 'object' ||
value === null ||
!Object.isFrozen(value)
) {
return null;
if (typeof value !== 'object' || value === null) return null;

for (const expectedKey of expectedKeys) {
if (Object.getOwnPropertyDescriptor(value, expectedKey) === undefined) {
return null;
}
}

if (!Object.isFrozen(value)) return null;

const ownKeys = Reflect.ownKeys(value);
if (
ownKeys.length !== expectedKeys.length ||
ownKeys.some(
(key) =>
typeof key !== 'string' || !expectedKeys.includes(key),
)
) {
return null;
}
if (ownKeys.length !== expectedKeys.length) return null;

const record: ExactDataRecord = {};
for (const expectedKey of expectedKeys) {
const descriptor = Object.getOwnPropertyDescriptor(value, expectedKey);
Expand Down Expand Up @@ -105,12 +105,6 @@ export function isDeeplyFrozenDocumentJson(rootValue: unknown): boolean {
while (pendingEntries.length > 0) {
const currentEntry = pendingEntries.pop() as JsonTraversalEntry;
inspectedValueCount += 1;
if (
inspectedValueCount > MAX_AUTOSAVE_EVIDENCE_JSON_VALUES ||
currentEntry.depth > MAX_AUTOSAVE_EVIDENCE_NESTING_DEPTH
) {
return false;
}

const currentValue = currentEntry.value;
if (
Expand All @@ -128,19 +122,47 @@ export function isDeeplyFrozenDocumentJson(rootValue: unknown): boolean {
}
if (
typeof currentValue !== 'object' ||
visitedContainers.has(currentValue) ||
!Object.isFrozen(currentValue)
visitedContainers.has(currentValue)
) {
return false;
}
visitedContainers.add(currentValue);

const childDepth = currentEntry.depth + 1;
let children: JsonContainerChildren;
if (Array.isArray(currentValue)) {
const length = currentValue.length;
const length = Object.getOwnPropertyDescriptor(
currentValue,
'length',
)!.value as number;
children = { kind: 'array', length };
} else {
if (!Object.isFrozen(currentValue)) return false;
const prototype = Object.getPrototypeOf(currentValue);
if (prototype !== Object.prototype && prototype !== null) return false;
children = { kind: 'object', keys: Reflect.ownKeys(currentValue) };
}

const childCount =
children.kind === 'array' ? children.length : children.keys.length;
const remainingValueCapacity =
MAX_AUTOSAVE_EVIDENCE_JSON_VALUES -
inspectedValueCount -
pendingEntries.length;
if (childCount > remainingValueCapacity) return false;
if (
childCount > 0 &&
childDepth > MAX_AUTOSAVE_EVIDENCE_NESTING_DEPTH
) {
return false;
}

if (children.kind === 'array') {
// Frozen-state inspection also enumerates Proxy keys; preflight first.
if (!Object.isFrozen(currentValue)) return false;
const ownKeys = Reflect.ownKeys(currentValue);
if (ownKeys.length !== length + 1) return false;
for (let index = 0; index < length; index += 1) {
if (ownKeys.length !== children.length + 1) return false;
for (let index = 0; index < children.length; index += 1) {
const descriptor = Object.getOwnPropertyDescriptor(
currentValue,
String(index),
Expand All @@ -160,9 +182,7 @@ export function isDeeplyFrozenDocumentJson(rootValue: unknown): boolean {
continue;
}

const prototype = Object.getPrototypeOf(currentValue);
if (prototype !== Object.prototype && prototype !== null) return false;
for (const key of Reflect.ownKeys(currentValue)) {
for (const key of children.keys) {
if (typeof key !== 'string') return false;
const descriptor = Object.getOwnPropertyDescriptor(currentValue, key);
if (
Expand Down Expand Up @@ -234,6 +254,7 @@ export function createDetachedAutosaveRevisionEvidence(
revisionRecord === null ||
revisionRecord.algorithm !== 'SHA-256' ||
typeof revisionRecord.digestHex !== 'string' ||
revisionRecord.digestHex.length !== 64 ||
!LOWERCASE_SHA256_DIGEST.test(revisionRecord.digestHex) ||
typeof revisionRecord.strongEntityTag !== 'string' ||
revisionRecord.strongEntityTag !==
Expand Down
159 changes: 159 additions & 0 deletions src/autosave/evidenceValidationArrayPreflight.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { describe, expect, it, vi } from 'vitest';

import {
createDetachedAutosaveRevisionEvidence,
isDeeplyFrozenDocumentJson,
} from './evidenceValidation.js';

describe('autosave detached evidence array resource preflight', () => {
it('still rejects a bounded unfrozen array', () => {
expect(isDeeplyFrozenDocumentJson([null])).toBe(false);
});

it('rejects an impossible array length before frozen-state key enumeration', () => {
let ownKeysCalls = 0;
const oversizedArray = new Proxy(Object.freeze(new Array(1_000_001)), {
ownKeys(target) {
ownKeysCalls += 1;
return Reflect.ownKeys(target);
},
});

expect(isDeeplyFrozenDocumentJson(oversizedArray)).toBe(false);
expect(ownKeysCalls).toBe(0);
});

it('rejects over-depth array children before frozen-state key enumeration', () => {
let ownKeysCalls = 0;
let root: readonly unknown[] = new Proxy(Object.freeze([null]), {
ownKeys(target) {
ownKeysCalls += 1;
return Reflect.ownKeys(target);
},
});
for (let depth = 0; depth < 128; depth += 1) root = Object.freeze([root]);

expect(isDeeplyFrozenDocumentJson(root)).toBe(false);
expect(ownKeysCalls).toBe(0);
});

it('rejects an impossible array length before explicit own-key enumeration', () => {
const oversizedArray = new Array<unknown>(1_000_001);
Object.freeze(oversizedArray);

const ownKeys = vi.spyOn(Reflect, 'ownKeys');
try {
expect(isDeeplyFrozenDocumentJson(oversizedArray)).toBe(false);
expect(ownKeys).not.toHaveBeenCalledWith(oversizedArray);
} finally {
ownKeys.mockRestore();
}
});

it('does not execute array length get traps while validating frozen evidence', () => {
const target = Object.freeze([1]);
let lengthRead = false;
const proxiedArray = new Proxy(target, {
get(currentTarget, property, receiver) {
if (property === 'length') lengthRead = true;
return Reflect.get(currentTarget, property, receiver);
},
});

expect(isDeeplyFrozenDocumentJson(proxiedArray)).toBe(true);
expect(lengthRead).toBe(false);
});

it('rejects over-depth array children before reading their descriptors', () => {
const deepestArray: readonly unknown[] = Object.freeze([null]);
let root: readonly unknown[] = deepestArray;
for (let depth = 0; depth < 128; depth += 1) {
root = Object.freeze([root]);
}

const getOwnPropertyDescriptor = vi.spyOn(
Object,
'getOwnPropertyDescriptor',
);
try {
expect(isDeeplyFrozenDocumentJson(root)).toBe(false);
expect(
getOwnPropertyDescriptor.mock.calls.some(
([value, property]) => value === deepestArray && property === '0',
),
).toBe(false);
} finally {
getOwnPropertyDescriptor.mockRestore();
}
});

it('rejects over-depth object children before reading their descriptors', () => {
const deepestObject = Object.freeze({ child: null });
let root: Readonly<{ child: unknown }> = deepestObject;
for (let depth = 0; depth < 128; depth += 1) {
root = Object.freeze({ child: root });
}

const getOwnPropertyDescriptor = vi.spyOn(
Object,
'getOwnPropertyDescriptor',
);
try {
expect(isDeeplyFrozenDocumentJson(root)).toBe(false);
expect(
getOwnPropertyDescriptor.mock.calls.some(
([value, property]) => value === deepestObject && property === 'child',
),
).toBe(false);
} finally {
getOwnPropertyDescriptor.mockRestore();
}
});

it('accepts an empty object exactly at the maximum nesting depth', () => {
const deepestObject = Object.freeze({});
let root: Readonly<Record<string, unknown>> = deepestObject;
for (let depth = 0; depth < 128; depth += 1) {
root = Object.freeze({ child: root });
}

expect(isDeeplyFrozenDocumentJson(root)).toBe(true);
});

it('rejects missing record members before whole-record key enumeration', () => {
const incompleteEvidence = Object.freeze({ envelope: null });
let ownKeysCalls = 0;
const proxiedEvidence = new Proxy(incompleteEvidence, {
ownKeys(target) {
ownKeysCalls += 1;
return Reflect.ownKeys(target);
},
});

expect(createDetachedAutosaveRevisionEvidence(proxiedEvidence)).toBeNull();
expect(ownKeysCalls).toBe(0);
});

it('rejects a complete unfrozen record before whole-record key enumeration', () => {
const unfrozenEvidence = { envelope: null, revision: null };
const ownKeys = vi.spyOn(Reflect, 'ownKeys');
try {
expect(createDetachedAutosaveRevisionEvidence(unfrozenEvidence)).toBeNull();
expect(ownKeys).not.toHaveBeenCalledWith(unfrozenEvidence);
} finally {
ownKeys.mockRestore();
}
});

it('rejects unexpected record members after required-key preflight', () => {
const evidenceWithExtraMember = Object.freeze({
envelope: null,
revision: null,
unexpected: null,
});

expect(
createDetachedAutosaveRevisionEvidence(evidenceWithExtraMember),
).toBeNull();
});
});
Loading