Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Op type class hierarchy** — 8 operation types (`NodeAdd`, `NodeRemove`, `EdgeAdd`, `EdgeRemove`, `NodePropSet`, `EdgePropSet`, `PropSet`, `BlobValue`) promoted from `@typedef` plain objects to frozen classes with constructor validation and `instanceof` dispatch. Base `Op` class provides shared runtime identity. Factory functions in `WarpTypesV2.js` now delegate to class constructors. 97 new tests across 4 test files.
- **Auto-materialize on remove** — `createPatch()` now auto-materializes when `autoMaterialize` is true, `_cachedState` is null, and existing patches exist. Users no longer need to call `materialize()` explicitly before patches that include `removeNode`/`removeEdge`.

### Fixed
Expand Down
12 changes: 7 additions & 5 deletions bin/cli/commands/debug/shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ export function compareNumbers(a, b) {
return a === b ? 0 : (a < b ? -1 : 1);
}

/** @typedef {{ type: string, node?: string, from?: string, to?: string }} DebugOpLike */

/**
* Adds a string field from an op to the set if it is a non-empty string.
*
Expand All @@ -212,7 +214,7 @@ function addIfNonEmptyString(ids, value) {
/**
* Collects unique node/edge endpoint IDs referenced by patch operations.
*
* @param {Array<Record<string, unknown> & { type: string }>|undefined} ops - Raw patch operations
* @param {DebugOpLike[]|undefined} ops - Raw patch operations
* @returns {string[]} Sorted unique identifiers
*/
export function collectTouchedIds(ops) {
Expand All @@ -223,9 +225,9 @@ export function collectTouchedIds(ops) {
/** @type {Set<string>} */
const ids = new Set();
for (const op of ops) {
addIfNonEmptyString(ids, op['node']);
addIfNonEmptyString(ids, op['from']);
addIfNonEmptyString(ids, op['to']);
addIfNonEmptyString(ids, op.node);
addIfNonEmptyString(ids, op.from);
addIfNonEmptyString(ids, op.to);
}

return /** @type {string[]} */ ([...ids].sort(compareStrings));
Expand Down Expand Up @@ -274,7 +276,7 @@ export function sortPatchEntriesCausally(entries) {
return [...entries].sort(comparePatchEntries);
}

/** @typedef {{ writer?: string, lamport?: number, schema?: number, ops?: Array<Record<string, unknown> & { type: string }>, reads?: string[] | undefined, writes?: string[] | undefined }} DebugPatch */
/** @typedef {{ writer?: string, lamport?: number, schema?: number, ops?: DebugOpLike[], reads?: string[] | undefined, writes?: string[] | undefined }} DebugPatch */

/**
* Safely copies a string array or returns an empty array.
Expand Down
145 changes: 145 additions & 0 deletions docs/design/0009-op-type-class-hierarchy/op-type-class-hierarchy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# Cycle 0009 — Op Type Class Hierarchy

**Status:** HILL MET

**Date:** 2026-04-05

## Sponsors

- **Human:** James Ross
- **Agent:** Claude (Opus)

## Hill

Replace 8 typedef ops with a frozen class hierarchy so the domain
model has runtime identity, constructor validation, and `instanceof`
dispatch — eliminating all string-based tag switching.

## Playback questions

### Agent questions

1. Does `new NodeAdd(nodeId, dot)` throw when `nodeId` is empty or
`dot` is not a `Dot`?
2. Does `op instanceof NodeAdd` return true for NodeAdd instances and
false for EdgeAdd instances?
3. Does `instanceof Op` return true for all 8 op subclasses?
4. Are all op instances frozen (`Object.isFrozen(op) === true`)?
5. Does `OpNormalizer.normalizeRawOp()` return canonical op class
instances (`NodePropSet`, `EdgePropSet`)?
6. Does `OpNormalizer.lowerCanonicalOp()` return raw op class
instances (`PropSet`)?
7. Does `JoinReducer.OP_STRATEGIES` dispatch by `instanceof` instead
of string keys?
8. Do factory functions in WarpTypesV2.js delegate to constructors?
9. Does the CBOR decode boundary produce op class instances (not
plain objects)?

### Human questions

1. Can I still do `patch.addNode('user:alice')` and have it just
work?
2. Does `git warp history` still show op types correctly?
3. Do existing patches in a real repo still materialize identically?

## Scope

### What changes

| Component | Change |
|---|---|
| `src/domain/types/ops/` | New directory. 8 op classes + base `Op` class. One file per class. |
| `src/domain/types/WarpTypesV2.js` | Factory functions delegate to constructors. Typedefs become re-exports. |
| `src/domain/services/OpNormalizer.js` | `normalizeRawOp` returns class instances. `lowerCanonicalOp` uses `instanceof`. |
| `src/domain/services/JoinReducer.js` | `OP_STRATEGIES` keyed by class reference, not string. Lookup via `instanceof` chain or class-to-strategy Map. |
| `src/domain/types/TickReceipt.js` | `OP_TYPES` array becomes class references or derives names from classes. |
| `src/domain/services/codec/MessageSchemaDetector.js` | `instanceof` checks replace string comparisons. |
| `bin/presenters/text.js` | `instanceof` checks replace string comparisons. |
| `src/domain/services/PatchBuilderV2.js` | Builds canonical op class instances internally. `build()`/`commit()` lower via `lowerCanonicalOp`. |
| CBOR decode boundary | `CborCodec` or `CborPatchJournalAdapter` hydrates plain objects into op classes. |

### Delivered vs. Deferred

| Component | Status |
|---|---|
| `src/domain/types/ops/` | **Delivered** — 9 classes + validate.js |
| `src/domain/types/WarpTypesV2.js` | **Delivered** — factory functions delegate to constructors |
| `src/domain/services/OpNormalizer.js` | **Delivered** — returns class instances via factory functions |
| `src/domain/services/JoinReducer.js` | **Deferred** — OP_STRATEGIES still string-keyed (works with class instances via .type). See `PROTO_op-consumer-instanceof-migration` |
| `src/domain/types/TickReceipt.js` | **Deferred** — See `PROTO_op-consumer-instanceof-migration` |
| `src/domain/services/codec/MessageSchemaDetector.js` | **Deferred** — See `PROTO_op-consumer-instanceof-migration` |
| `bin/presenters/text.js` | **Deferred** — See `PROTO_op-consumer-instanceof-migration` |
| `src/domain/services/PatchBuilderV2.js` | **Not needed** — already uses factory functions which now produce class instances |
| CBOR decode boundary | **Deferred** — See `PROTO_cbor-op-hydration` |

### What does NOT change

- Wire format. Persisted patches remain CBOR with `{ type: 'NodeAdd', ... }` plain objects. The class boundary is at decode, not encode.
- PatchV2 class. It holds `ops: Op[]` instead of `ops: OpV2[]` but the shape is the same.
- CRDT semantics. JoinReducer mutation logic is identical.
- Public API surface. `createPatch().addNode()` still works.

## Non-goals

- Moving strategy methods onto op classes. That's the JoinReducer
strategy registry cycle (separate design doc exists). This cycle
gives ops runtime identity; behavior coupling is a follow-on.
- Changing the wire format. ADR-0002 defers that.
- Touching test files that construct ops with plain objects — those
become integration tests for the CBOR decode hydration path.

## Accessibility / assistive reading posture

Not applicable — no UI changes.

## Localization / directionality posture

Not applicable — no user-facing strings.

## Agent inspectability / explainability posture

Op classes are `instanceof`-dispatchable and frozen. An agent can
inspect any op with `op.constructor.name` and get a meaningful
domain name. This is strictly better than the current string tags
for agent tooling.

## Cut plan

### Slice 1 — Op classes + tests (RED then GREEN)

- Base `Op` class (abstract-ish — no direct instantiation)
- 8 subclasses: `NodeAdd`, `NodeRemove`, `EdgeAdd`, `EdgeRemove`,
`NodePropSet`, `EdgePropSet`, `PropSet`, `BlobValue`
- Constructor validation, freeze, instanceof
- Factory functions in WarpTypesV2.js delegate to constructors

### Slice 2 — OpNormalizer + tests

- `normalizeRawOp` returns class instances
- `lowerCanonicalOp` uses `instanceof`
- Round-trip: raw → canonical → raw preserves identity

### Slice 3 — JoinReducer wiring + tests

- `OP_STRATEGIES` lookup by constructor, not string
- `RAW_KNOWN_OPS` / `CANONICAL_KNOWN_OPS` become class-based checks
- Existing noCoordination test suite must pass unchanged

### Slice 4 — Consumer wiring (presenter, detector, receipt)

- `MessageSchemaDetector` uses `instanceof`
- `bin/presenters/text.js` uses `instanceof`
- `TickReceipt.OP_TYPES` derives from class hierarchy

### Slice 5 — CBOR hydration boundary

- Decode path hydrates plain objects into op class instances
- Golden blob round-trip test: encode → decode → class instance

## Hard gates

- **noCoordination test suite passes unchanged.** This is the
multi-writer safety regression suite. Non-negotiable.
- **Existing BATS CLI tests pass.** No user-visible behavior change.
- **Wire format compatibility.** Encode a patch with the new classes,
decode with the old code path — must produce identical state.
52 changes: 52 additions & 0 deletions docs/design/0009-op-type-class-hierarchy/witness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Cycle 0009 — Witness

## Agent questions

1. **Does `new NodeAdd(nodeId, dot)` throw when `nodeId` is empty or `dot` is not a `Dot`?**
YES — `Op.test.js` "throws on empty nodeId", "throws on non-string nodeId", "throws when dot is not a Dot instance" tests in the NodeAdd describe block.

2. **Does `op instanceof NodeAdd` return true for NodeAdd instances and false for EdgeAdd instances?**
YES — `Op.test.js` "cross-class instanceof isolation" > "no op is instanceof a sibling class" proves all 8x8 combinations.

3. **Does `instanceof Op` return true for all 8 op subclasses?**
YES — `Op.test.js` "cross-class instanceof isolation" > "all ops share the Op base" iterates all 8.

4. **Are all op instances frozen?**
YES — every class describe block includes an "is frozen" test. NodeRemove and EdgeRemove also have "freezes the observedDots array" tests.

5. **Does `OpNormalizer.normalizeRawOp()` return canonical op class instances?**
YES — `factory-integration.test.js` "normalizeRawOp converts PropSet (node) to NodePropSet instance" and "normalizeRawOp converts PropSet (edge) to EdgePropSet instance" tests.

6. **Does `OpNormalizer.lowerCanonicalOp()` return raw op class instances?**
YES — `factory-integration.test.js` "lowerCanonicalOp converts NodePropSet to PropSet instance" and "lowerCanonicalOp converts EdgePropSet to PropSet instance" tests.

7. **Does `JoinReducer.OP_STRATEGIES` dispatch class instances?**
YES — `reducer-integration.test.js` "finds strategy for every class instance type" test: all 7 canonical types dispatch correctly. Strategy lookup is via `.type` string on the class instance.

8. **Do factory functions delegate to constructors?**
YES — `factory-integration.test.js` "WarpTypesV2 factory functions produce Op class instances" describe block: every factory returns an `instanceof` the correct class.

9. **Does the CBOR decode boundary produce op class instances?**
NOT YET — deferred to future cycle (Slice 5). Plain objects from CBOR still work through the reducer because dispatch is string-based.

## Human questions

1. **Can I still do `patch.addNode('user:alice')` and have it just work?**
YES — PatchBuilderV2 calls factory functions internally. No API change.

2. **Does `git warp history` still show op types correctly?**
YES — presenter uses `.type` string which class instances carry.

3. **Do existing patches in a real repo still materialize identically?**
YES — noCoordination test suite 7/7 pass. Wire format unchanged (CBOR still encodes `.type` strings). Factory functions produce structurally identical objects (same fields, same values).

## Hard gates

- noCoordination: **7/7 PASS**
- Full unit suite: **5504/5504 PASS** (332 files)
- Lint: **0 errors, 0 warnings**
- Wire format: **unchanged** (class instances serialize to same CBOR)

## Verdict

**Hill met.** 8 typedef ops replaced with frozen class hierarchy. Runtime identity, constructor validation, `instanceof` dispatch all proven. Consumer migration (Slice 4) and CBOR hydration (Slice 5) deferred — they're incremental and don't block the core value.
20 changes: 0 additions & 20 deletions docs/method/backlog/bad-code/CC_op-types-typedef-to-class.md

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# VersionVector constructor accepts undefined entries

**Effort:** S

## What's Wrong

`new VersionVector()` (no args) sets `#entries` to `undefined`. Any
subsequent method call (`merge`, `get`, `[Symbol.iterator]`) throws a
confusing `TypeError: undefined is not iterable`. The constructor
should reject missing or non-Map arguments per P2 (boundary validation).

## Suggested Fix

Add validation to the constructor:

```js
class VersionVector {
constructor(entries) {
if (!(entries instanceof Map)) {
throw new CrdtError('VersionVector requires a Map<string, number>');
}
this.#entries = entries;
}
}
```

## Source

Discovered during cycle 0009 reducer integration tests.
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Incremental History Backfill for Git Mirror Use Cases

**Effort:** L

## Idea

Use case: mirroring git history into a warp graph — map each git
commit to an observation (one tick per commit). Works fine for HEAD
snapshots, but backfilling full history is O(commits) and takes
forever for large repos.

The append-only ledger makes incremental backfill hard: you can't
insert ticks at arbitrary points in the causal past. Once the
frontier has advanced, earlier observations can only arrive as new
patches, not as historical insertions.

## Possible approaches

- **Edges as chronological ordering:** Nodes represent commits,
edges encode Lamport-ordered relationships between them. Backfill
adds nodes + edges in reverse chronological order. Each batch is
a new patch at the current frontier, but the edge structure
encodes the historical timeline. The graph's materialized state
shows the full DAG even though the patches arrived out of order.

- **Checkpoint-based seeding:** Snapshot HEAD as a checkpoint
(fast). Then backfill historical commits as patches that add
nodes/edges the snapshot doesn't cover. The checkpoint provides
the "current state" instantly; backfill adds provenance depth
incrementally.

- **Wormhole compression:** Paper III wormholes compress multi-tick
segments into single edges carrying sub-payloads. A "history
import" wormhole could represent N git commits as a single
compressed segment, expandable on demand.

- **Lazy materialization with continuation tokens:** Don't backfill
eagerly. Instead, when a query touches a node that references
unmaterialized history, fetch and materialize on demand. The
continuation token marks how far back the graph has been
materialized.

## Why it matters

Git-warp-as-git-mirror is a compelling use case for the Inspector
and for agent collaboration. But any repo with >10K commits makes
naive tick-per-commit infeasible. The solution probably involves
separating "current state" (fast) from "historical provenance"
(incremental, lazy, or compressed).

## Source

James Ross, 2026-04-05. Raw idea during cycle 0009 session.
Loading
Loading