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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
run: npm ci --no-audit --no-fund
- name: Verify active v2 packages
if: steps.scope.outputs.package_full == 'true'
run: npm run archive:check && npm run verify -w @interactive-os/json-document && npm run verify:companions && npm run standard:check && npm run docs:evaluate
run: npm run verify -w @interactive-os/json-document && npm run verify:companions && npm run standard:check && npm run docs:evaluate
- name: Verify package documentation
if: steps.scope.outputs.package_full != 'true' && (steps.scope.outputs.package_docs == 'true' || steps.scope.outputs.package_smoke == 'true')
run: |
Expand Down
23 changes: 23 additions & 0 deletions .github/workflows/collaboration-soak.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Collaboration Soak

on:
workflow_dispatch:
schedule:
- cron: "0 19 * * 1"

permissions:
contents: read

jobs:
collaboration-soak:
name: Deterministic causal convergence
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Run the extended collaboration profile
run: npm run test:collaboration:soak
2 changes: 1 addition & 1 deletion apps/site/src/components/MarkdownViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import ReactMarkdown from "react-markdown";
import rehypeSlug from "rehype-slug";
import remarkGfm from "remark-gfm";

export type MarkdownHeading = { id: string; level: number; text: string };
type MarkdownHeading = { id: string; level: number; text: string };

export function MarkdownViewer({ source, hideTitle = false }: { source: string; hideTitle?: boolean }) {
return (
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"dev": "npm run dev -w @interactive-os/json-document-site",
"build": "npm run build -w @interactive-os/json-document",
"test": "npm test -w @interactive-os/json-document",
"test:collaboration:soak": "npm run test:soak -w @interactive-os/json-document-collaboration",
"typecheck": "npm run typecheck -w @interactive-os/json-document",
"smoke:package": "npm run smoke:package -w @interactive-os/json-document",
"perf:core": "npm run build -w @interactive-os/json-document && node scripts/benchmark-core.mjs",
Expand Down
27 changes: 27 additions & 0 deletions packages/json-document-collaboration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,33 @@ DOM/native-input publication leasing is likewise separate in
ingesting model Changes during composition and gates only rendering for the
leased surface.

## Release evidence

The ordinary package suite includes a provider-neutral task-board host that
depends only on the six-member `JSONDocument`. The same host commands run
unchanged against the local and collaboration providers, including a
concurrent card edit that follows a structural move by stable identity.

It also runs a small deterministic convergence soak on every verification.
Three replicas author randomized task-board commands and selective
undo/redo while bundles are partially, out of order, and repeatedly
delivered. Each case must converge before and after checkpoint restore,
continue the restored actor lineage without a fork, export one canonical
checkpoint, and survive new-epoch compaction.

Run the deeper reproducible profile with:

```sh
npm run test:collaboration:soak
```

The soak reports its seed in every failure so the exact schedule can be
replayed with `JSON_DOCUMENT_COLLABORATION_SOAK_SEED` and a case count of one.
This evidence protects the architecture but does not turn an RC into
production proof by itself. Stable promotion still requires sustained
external use, large-document performance evidence, and successful
offline/checkpoint/epoch operations under a real transport and storage host.

## Protocol v3 profile

The implemented profile includes:
Expand Down
1 change: 1 addition & 0 deletions packages/json-document-collaboration/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"build": "npm run clean && tsc -p tsconfig.json",
"prepack": "npm run build",
"test": "vitest run --config vitest.config.ts",
"test:soak": "JSON_DOCUMENT_COLLABORATION_SOAK_CASES=24 JSON_DOCUMENT_COLLABORATION_SOAK_STEPS=96 vitest run --config vitest.config.ts tests/convergence-soak.test.ts",
"typecheck": "tsc -p tsconfig.test.json --noEmit",
"verify": "npm run typecheck && npm test && npm run build"
},
Expand Down
46 changes: 46 additions & 0 deletions packages/json-document-collaboration/src/checkpoint.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
applyPatch,
type JSONCapabilityResult,
type JSONValue,
} from "@interactive-os/json-document";

Expand All @@ -21,6 +22,16 @@ type PreparedCheckpoint =
| { readonly ok: true; readonly checkpoint: CollaborationCheckpoint }
| { readonly ok: false; readonly reason: string };

type CheckpointVerifier = (
checkpoint: CollaborationCheckpoint,
) => JSONCapabilityResult;

type CheckpointVerificationFailure = {
readonly ok: false;
readonly code: string;
readonly reason: string;
};

export function createCheckpoint(
base: JSONValue,
membership: CollaborationMembership | null,
Expand Down Expand Up @@ -162,6 +173,34 @@ export function prepareCheckpoint(input: unknown): PreparedCheckpoint {
};
}

export function verifyCheckpointProof(
checkpoint: CollaborationCheckpoint,
verify: CheckpointVerifier | undefined,
): CheckpointVerificationFailure | null {
if (verify === undefined) return null;
try {
const result = verify(checkpoint);
if (result?.ok === true) return null;
if (result?.ok === false && typeof result.code === "string") {
return verificationFailure(
result.code,
result.reason ?? "checkpoint proof verification failed",
);
}
return verificationFailure(
"checkpoint_verification_failed",
"checkpoint verifier must return a capability result",
);
} catch (error) {
return verificationFailure(
"checkpoint_verification_failed",
error instanceof Error
? error.message
: "checkpoint proof verification failed",
);
}
}

function prepareMembership(
input: unknown,
):
Expand Down Expand Up @@ -206,6 +245,13 @@ function invalid(reason: string): { readonly ok: false; readonly reason: string
return { ok: false, reason };
}

function verificationFailure(
code: string,
reason: string,
): CheckpointVerificationFailure {
return Object.freeze({ ok: false, code, reason });
}

function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
Expand Down
55 changes: 5 additions & 50 deletions packages/json-document-collaboration/src/compact.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
import type {
JSONCapabilityResult,
JSONValue,
} from "@interactive-os/json-document";

import {
canonicalMembership,
changeIdKey,
Expand All @@ -16,6 +11,7 @@ import {
import {
createCheckpoint,
prepareCheckpoint,
verifyCheckpointProof,
} from "./checkpoint.js";
import {
acceptCandidate,
Expand Down Expand Up @@ -67,7 +63,7 @@ export function compactCollaborationCheckpoint(
const invalidOptions = validateOptions(checkpoint, options);
if (invalidOptions !== null) return invalidOptions;

const verification = verifyCheckpoint(checkpoint, options);
const verification = verifyCheckpointProof(checkpoint, options.verify);
if (verification !== null) return verification;

const changes = new Map<string, CollaborationChange>();
Expand Down Expand Up @@ -132,10 +128,7 @@ export function compactCollaborationCheckpoint(
initialProjection.reason ?? "checkpoint base is not materializable",
);
}
const initialAcceptance = safeAccept(
options.accepts,
initialProjection.value,
);
const initialAcceptance = acceptCandidate(options.accepts, initialProjection.value);
if (!initialAcceptance.ok) {
return failure(
initialAcceptance.code,
Expand All @@ -149,7 +142,7 @@ export function compactCollaborationCheckpoint(
materialized = materializeChanges(
initialTree,
graph.ordered,
(candidate) => safeAccept(options.accepts, candidate),
(candidate) => acceptCandidate(options.accepts, candidate),
);
} catch (error) {
return failure(
Expand All @@ -160,10 +153,7 @@ export function compactCollaborationCheckpoint(
);
}
const nextAccepts = effectiveNextAcceptance(checkpoint, options);
const nextAcceptance = safeAccept(
nextAccepts,
materialized.value,
);
const nextAcceptance = acceptCandidate(nextAccepts, materialized.value);
if (!nextAcceptance.ok) {
return failure(
nextAcceptance.code,
Expand Down Expand Up @@ -308,41 +298,6 @@ function effectiveNextAcceptance(
: undefined;
}

function verifyCheckpoint(
checkpoint: CollaborationCheckpoint,
options: CollaborationCompactionOptions,
): Extract<CollaborationCompactionResult, { readonly ok: false }> | null {
if (options.verify === undefined) return null;
try {
const result = options.verify(checkpoint);
if (result?.ok === true) return null;
if (result?.ok === false && typeof result.code === "string") {
return failure(
result.code,
result.reason ?? "checkpoint proof verification failed",
);
}
return failure(
"checkpoint_verification_failed",
"checkpoint verifier must return a capability result",
);
} catch (error) {
return failure(
"checkpoint_verification_failed",
error instanceof Error
? error.message
: "checkpoint proof verification failed",
);
}
}

function safeAccept(
accepts: ((candidate: JSONValue) => JSONCapabilityResult) | undefined,
candidate: JSONValue,
): JSONCapabilityResult {
return acceptCandidate(accepts, candidate);
}

function unauthorizedReference(
change: CollaborationChange,
membership: CollaborationMembership | null,
Expand Down
1 change: 0 additions & 1 deletion packages/json-document-collaboration/src/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ import {
projectTree,
resolveTextMemberSnapshot,
resolveTextSnapshot,
type TreeState,
} from "./tree.js";
import {
authoredTextAtomId,
Expand Down
2 changes: 1 addition & 1 deletion packages/json-document-collaboration/src/materialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ type HistoryOperation = Extract<
{ readonly kind: "undo-change" | "redo-change" }
>;

export interface MaterializedHistoryState {
interface MaterializedHistoryState {
readonly disabledByTarget: ReadonlyMap<string, ChangeId>;
readonly appliedKeys: ReadonlySet<string>;
readonly appliedUndoTargets: ReadonlyMap<string, ChangeId>;
Expand Down
45 changes: 6 additions & 39 deletions packages/json-document-collaboration/src/restore.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import {
canonicalStringify,
} from "./change.js";
import { prepareCheckpoint } from "./checkpoint.js";
import {
prepareCheckpoint,
verifyCheckpointProof,
} from "./checkpoint.js";
import {
createRestoredRuntime,
createRestoredTextRuntime,
} from "./create.js";
import type { JSONValue } from "@interactive-os/json-document";
import type {
CollaborationCheckpoint,
CollaborationHistoryRestoreResult,
CollaborationHistoryRuntime,
CollaborationRestoreOptions,
Expand All @@ -30,7 +32,7 @@ export function restoreCollaborationRuntime(
input: unknown,
options: CollaborationRestoreOptions,
): CollaborationRestoreResult {
const restored = restoreHistoryRuntime(input, options);
const restored = restoreCollaborationHistoryRuntime(input, options);
if (!restored.ok) return restored;
const runtime: CollaborationRuntime = Object.freeze({
document: restored.runtime.document,
Expand Down Expand Up @@ -63,13 +65,6 @@ export function restoreCollaborationTextRuntime(
});
}

function restoreHistoryRuntime(
input: unknown,
options: CollaborationRestoreOptions,
): CollaborationHistoryRestoreResult {
return restoreCollaborationHistoryRuntime(input, options);
}

function restoreProfileRuntime(
input: unknown,
options: CollaborationRestoreOptions,
Expand Down Expand Up @@ -109,7 +104,7 @@ function restoreProfileRuntime(
"restore ruleset does not match the checkpoint epoch",
);
}
const verification = verifyCheckpoint(checkpoint, options);
const verification = verifyCheckpointProof(checkpoint, options.verify);
if (verification !== null) return verification;

try {
Expand Down Expand Up @@ -152,34 +147,6 @@ function restoreProfileRuntime(
}
}

function verifyCheckpoint(
checkpoint: CollaborationCheckpoint,
options: CollaborationRestoreOptions,
): Extract<CollaborationHistoryRestoreResult, { readonly ok: false }> | null {
if (options.verify === undefined) return null;
try {
const result = options.verify(checkpoint);
if (result?.ok === true) return null;
if (result?.ok === false && typeof result.code === "string") {
return failure(
result.code,
result.reason ?? "checkpoint proof verification failed",
);
}
return failure(
"checkpoint_verification_failed",
"checkpoint verifier must return a capability result",
);
} catch (error) {
return failure(
"checkpoint_verification_failed",
error instanceof Error
? error.message
: "checkpoint proof verification failed",
);
}
}

function failure(
code: string,
reason: string,
Expand Down
Loading