3878 words
19 minutes
Feature Specification: OCR Version Control

1. Overview#

This document specifies the Shared Multi-User OCR System for Mokuro Library, implementing a database-master hybrid model with per-user caching.

Core Principles#

  • Database as Master: PostgreSQL/SQLite is the single source of truth for all history and state.
  • Per-User Snapshot Caching: Each user maintains a private snapshot file for read performance.
  • Copy-on-Write Branching: Users view Admin’s “Official” branch by default; edits fork into a private branch.
  • Cascade Cleanup: Deleting a branch root automatically removes all descendant patches.

2. Database Schema#

2.1 OcrBranch#

Tracks the current state of a volume for a specific user.

model OcrBranch {
id String @id @default(ulid())
volumeId String
userId String
headPatchId String // Current HEAD (latest state)
headPatch Patch @relation("BranchHead", fields: [headPatchId], references: [id])
rootPatchId String? // First private patch (NULL = clean/synced)
rootPatch Patch? @relation("BranchRoot", fields: [rootPatchId], references: [id])
snapshotPatchId String? // Patch ID of cached snapshot
version Int @default(0) // Optimistic lock counter
@@unique([volumeId, userId])
}

2.2 Patch#

Doubly-linked list for admin, singly-linked for users. Cascade delete enables cleanup.

model Patch {
id String @id @default(ulid())
parentId String?
parent Patch? @relation("HistoryTree", fields: [parentId], references: [id], onDelete: Cascade)
children Patch[] @relation("HistoryTree")
nextPatchId String? @unique // Forward pointer (admin only)
nextPatch Patch? @relation("AdminChain", fields: [nextPatchId], references: [id])
volumeId String
userId String
operation String // JSON: PatchOperation
createdAt DateTime @default(now())
}

Genesis Patch: parentId = NULL, operation = { op: 'genesis', path: mokuroPath }

2.3 Snapshot Strategy#

2.3.1 Snapshot Validity and Failure Modes#

A snapshot represents the fully materialized document state at a specific patch. Snapshots are binary-valid: they are either fully valid or fully stale.

Invariant (Snapshot Validity): A snapshot is valid if and only if snapshotPatchId === headPatchId and the ancestry of headPatchId has not been rewritten.

Snapshots may become unusable in two distinct ways:

  • Stale snapshot The snapshot exists and its internal patch identifier matches snapshotPatchId, but snapshotPatchId ≠ headPatchId. In this case, the snapshot MUST be synchronized by replaying patches forward or backward until it reaches headPatchId.

  • Corrupt or missing snapshot The snapshot file is missing, or its internal patch identifier does not match snapshotPatchId. In this case, the snapshot MUST be regenerated from genesis.

Snapshots are never partially valid.


2.3.2 Snapshot Synchronization#

When a snapshot is stale, it MUST be synchronized to the current branch HEAD by replaying patches.

Synchronization MAY require replaying patches forward or backward, depending on whether the snapshot patch precedes or follows the current HEAD.

Backward synchronization may occur after undo operations.


2.3.3 Snapshot Invalidation Semantics#

A snapshot MUST be treated as invalid whenever the branch’s effective state changes.

In particular, snapshot invalidation occurs when:

  • The branch HEAD changes (patch, undo, redo, rebase completion, reset, officialize)
  • The branch root changes (first fork, branch drag, reset, officialize)
  • A rebase aborts or completes
  • Patch compression rewrites the branch history

Snapshot invalidation is determined solely by observable branch state changes and does not independently reason about patch ancestry.

2.4 Lazy Genesis#

Branches and genesis patches are created on-demand when a volume is first accessed.

2.5 Steady-State Invariants#

The system maintains the following invariants at all times:

  • rootPatchId = null if and only if the branch has no private edits.
  • User patches never participate in the admin forward chain.
  • Redo requires a unique forward continuation; ambiguity is an invalid system state.
  • Snapshots are valid only at the branch HEAD.

3. Data Types#

3.1 PatchOperation#

export type PatchOperation =
| { op: 'genesis'; path: string }
| { op: 'replace'; path: string; value: PatchValue; old_value: PatchValue }
| { op: 'add'; path: string; value: PatchValue }
| { op: 'remove'; path: string; old_value: PatchValue }
| { op: 'reorder'; path: string; new_order: number[] };

3.2 Value Types#

export type Quad = [[number, number], [number, number], [number, number], [number, number]];
export type Rect = [number, number, number, number];
export interface UnifiedLine { text: string; coords: Quad; }
export interface UnifiedBlock { box: Rect; vertical: boolean; font_size?: number; lines: UnifiedLine[]; }
export type PatchValue = string | boolean | number | Rect | Quad | UnifiedBlock | UnifiedLine;

4. Workflows#

4.1 Editing (Fork-on-Write)#

  1. User edits trigger patch creation.

  2. If rootPatchId === null (clean), set rootPatchId = newPatch.id (fork).

  3. Update headPatchId = newPatch.id.

  4. Prune future history If the branch HEAD is not at the tip of its private timeline (i.e., redo history exists), applying a new patch deletes all forward patches from the current HEAD to preserve a single forward path.

4.2 Undo / Redo (User: Pointer Navigation)#

User undo and redo navigate a user branch’s history by moving the branch HEAD pointer backward or forward through the patch timeline. These operations do not rewrite history; they only change which patch is currently active.

Undo and redo are defined only for user branches. Admin redo is not supported.


4.2.1 User Undo#

User undo reverts the most recently applied patch.

Conceptually, undo performs a backward crawl:

  • If the current HEAD has a parent, the HEAD moves to that parent.
  • If the current HEAD has no parent, the branch is at the start of its history and undo is not possible.

The server returns the inverse of the undone patch so the client can immediately revert local state.


4.2.2 User Redo (Forward Navigation Priority)#

Redo advances the branch HEAD by exactly one patch, following a unique forward continuation determined by the following priority order:

  1. Re-enter private history If the branch has a private root and the current HEAD is positioned at the fork point, redo advances into the branch’s private root.

  2. Follow authoritative forward edge If the current patch defines a forward continuation via an authoritative forward edge, redo advances along that edge.

  3. Follow unique child Otherwise, if the current patch has exactly one child, redo advances to that child.

  4. Invalid state If multiple forward paths exist, redo is undefined and treated as an invalid system state.

Redo always advances the HEAD by exactly one patch.


4.2.3 Invalid States#

Redo assumes the forward direction from the current HEAD is unambiguous.

If the patch graph provides multiple competing forward paths, or if branch pointers violate expected ordering, redo is undefined and treated as an invalid system state rather than user error.


4.2.4 Guarantees#

  • Undo moves the branch HEAD backward by one patch.
  • Redo moves the branch HEAD forward by one patch.
  • Neither operation rewrites or deletes patches.
  • User branches always observe a consistent and continuous history during undo/redo navigation.

4.2.5 Backward Snapshot Synchronization#

After undo operations, it is possible for a snapshot to represent a patch that is chronologically ahead of the current HEAD.

In such cases, the snapshot MUST be synchronized backward to the current HEAD before any new edits are applied.

4.3 Admin Undo With Branch Drag#

Admin undo reverts the current admin HEAD by one patch. Because this operation rewrites shared history, it is only permitted when its effects on user branches are unambiguous.

The defining behavior of admin undo is branch drag: when a user branch depends on the undone admin patch, the branch’s root pointer is dragged backward together with the admin HEAD so that the branch’s history remains continuous after the rewrite.


4.3.1 Dependency Types#

A user branch is considered dependent on the current admin HEAD if either:

  1. HEAD-dependent branch The branch has no private edits (rootPatchId = null) and its headPatchId equals the admin head.

  2. ROOT-dependent branch The branch has private edits whose root patch descends directly from the admin head.

In both cases, removing the admin patch without adjustment would leave the branch with a broken ancestry.


4.3.2 Undo Rules#

Let P be the current admin head and P.parent the new admin head after undo.

  • If no user branch depends on P: The admin head pointer moves backward to P.parent. No user branches are affected.

  • If exactly one user branch depends on P: The admin head pointer moves backward to P.parent, and the dependent user branch’s root pointer is dragged backward so that the undone patch remains part of the branch’s private history.

  • If more than one user branch depends on P: Undo is rejected. Admin history is considered solidified.


4.3.3 Branch Drag Semantics#

When branch drag occurs:

  • The user branch’s rootPatchId is updated to point to the undone patch.
  • If the branch previously had no private history, its headPatchId is also updated to this patch.
  • If the branch already had private edits, the existing root is reattached beneath the dragged root.

Conceptually, the branch’s root moves backward together with the admin undo, preserving the branch’s visible history while allowing the admin branch to rewind.

Any patch duplication required to support this behavior is an implementation detail and does not affect the logical history model.


4.3.4 Guarantees#

  • Admin undo never leaves a user branch with a dangling or discontinuous history.
  • Admin redo is not supported.
  • User branches always observe a consistent ancestry, even when admin history is rewritten.

4.3.5 Admin Chain Invariant#

After a successful admin undo, the new admin HEAD MUST NOT retain any forward (nextPatchId) links.

Admin history may be rewritten only while the dependent user branch can be uniquely identified and dragged backward.

Admin history is always a single forward chain with no redo capability.

4.4 Rebase (Effect Propagation Model)#

Rebase synchronizes a user branch with the current admin branch by replaying user edits on top of admin changes. This is implemented as an effect propagation algorithm: each admin patch is converted into an abstract effect, which is then pushed forward through the user’s patch sequence.

Rebase never mutates existing patches. Instead, it computes a transformed user patch list and later replays it on top of the admin head.


4.4.1 Patch Sequences#

Let:

  • A = [A₁, A₂, …, Aₙ] be admin patches since the fork point (oldest → newest)
  • U = [U₁, U₂, …, Uₘ] be user patches since the fork point (oldest → newest)

Rebase processes admin patches one at a time, updating the user patch list after each step.


4.4.2 Core Loop#

For each admin patch Aᵢ:

  1. Convert Aᵢ.operation into an Effect E (see Appendix B.2 for effect types).

  2. Initialize an empty list U'.

  3. Iterate through the current user patch list U from left to right:

    • Apply transform(Uⱼ, E)
    • If a conflict requiring user choice is detected, rebase pauses.
    • If the transformed user patch is not discarded, append it to U'.
    • Update E to the returned effect.
  4. Replace U ← U' and continue with the next admin patch.

After all admin patches are processed, the final U represents the rebased user edits.


4.4.3 Effects#

An Effect represents the semantic impact of an admin patch on later operations:

  • Structural effects (shift_up, shift_down, permute) describe how array indices change.
  • Content effects describe value replacement at an exact path.
  • Effects may evolve as they pass through user patches, or be absorbed entirely.

Reorder effects store the inverse permutation of the admin reorder so that composition during rebase is well-defined.


4.4.4 Transform Semantics#

Each (user patch, effect) pair is classified by their path relationship (e.g. no overlap, structural interaction, direct conflict).

  • Non-conflicting interactions deterministically rewrite paths or permutations.
  • Conflicting interactions may either auto-resolve or require user choice.
  • In many resolutions, the effect is absorbed and becomes identity.

Exact behaviors are defined in Appendix B.


4.4.5 Termination and Complexity (Effect-Based Rebase)#

This section analyzes the termination and time complexity of the effect propagation rebase model described in Appendix B.

Termination#

Effect-based rebase always terminates.

  • Each user patch is transported through a finite sequence of upstream effects.
  • Structural effects (index shifts, permutations) monotonically simplify as they are absorbed, rewritten, or eliminated.
  • Conflict resolution rules either:
    • drop a patch,
    • rewrite it to be compatible with the effect, or
    • absorb the effect into the patch (effect → identity).

No rule introduces new effects or increases effect complexity. Therefore, the process strictly progresses and must terminate.

Complexity Model#

Let:

  • |A| be the number of upstream (admin) patches,
  • |U| be the number of user patches,
  • D be the maximum patch path depth (number of identity-bearing container levels),
  • L be the average container size affected by structural operations.

Rebase proceeds by transporting each user patch through each upstream effect, yielding |A| × |U| patch–effect interactions.

For each interaction:

  • Intersection classification requires comparing patch paths and effect paths, which is O(D).
  • Structural transport (index shifting or permutation application) is O(L) in the worst case.
  • Payload rewriting (when required) is proportional to the payload size, which is also O(L) on average.

Thus, each interaction costs O(D + L).

Total Time Complexity#

The total time complexity is therefore: O(|A| × |U| × (D + L))

Space Complexity#

The algorithm retains:

  • the upstream effects A,
  • the user patch sequence U,
  • and the rewritten user patch sequence U′.

Because each patch stores a path of depth D and a payload whose average size is O(L), the space required to store a patch is O(D + L).

Therefore, space complexity is: O((|A| + |U|) × (D + L))

If effects are streamed and not retained, this reduces to: O(|U| × (D + L))


Invariant: User patches are never applied directly to admin history. All synchronization is performed by transforming user patches relative to admin effects.

Conflict Resolution: See Appendix B for detailed conflict tables.

4.5 Reset#

Reset discards a user’s private edits and realigns the branch with the admin state.

Reset is performed in two phases:

  1. Branch state is updated atomically in the database.
  2. The branch snapshot is rebuilt from the admin state if required.

4.6 Officialize (Admin Fast-Forward)#

Admin incorporates user’s patches:

  1. Verify user branch is direct descendant of admin HEAD
  2. Update admin headPatchId to user’s HEAD
  3. Set nextPatchId on merged patches (doubly-link admin chain)
  4. Reset user branch to clean state

4.7 Patch Compression#

Patch compression reduces a patch sequence by eliminating edits that do not contribute to the final document state. Compression does not introduce a new reconciliation or transformation mechanism. Instead, it reuses the rebase machinery directly, applying it to a counterfactual change in history.

Where rebase introduces a new patch into the past and rewrites future patches to compensate, compression removes an existing patch from the past and rewrites future patches in the same manner.


4.7.1 Conceptual Model#

At any point during compression, the patch sequence is partitioned as:

C · P · T

where:

  • C is the compressed prefix (patches already accepted as part of history),
  • P is the patch under consideration,
  • T is the remaining suffix.

Compression asks the following counterfactual question:

If patch P had never occurred, could the remaining patches be rewritten—using the same rules as rebase—to produce the same final document state?

This question is evaluated by treating the removal of P as a change to the past and forcing the future patches to reinterpret themselves accordingly.


4.7.2 Elimination Test (Rebase Reuse)#

To test whether P can be eliminated, compression performs a rebase-style propagation:

  1. Compute the inverse of P and convert it into an effect E.

  2. Propagate E forward through the suffix T using the same effect transformation logic used during rebase.

  3. During propagation, conflicts are resolved using keep_mine semantics, exactly as in rebase.

  4. As in rebase, propagation may rewrite, transform, or discard patches in T, producing a rewritten suffix T'.

  5. If propagation terminates with the identity effect, then the disappearance of P has been fully absorbed by rewriting the future.

    • P is eliminated.
    • The patch sequence becomes C · T'.
  6. If propagation terminates with a non-identity effect, then the disappearance of P cannot be hidden from the future.

    • P is subsumed into the compressed prefix.
    • The patch sequence becomes C' · T, where C' = C · P.

Compression then continues with the updated (C, T).


4.7.3 Resolution Semantics (keep_mine)#

All propagation during compression uses the same resolution semantics as rebase.

Specifically, conflicts are resolved using keep_mine, which rewrites future patches to behave as if the modified history had always been the case. Resolution does not select between alternatives; it retroactively adjusts assumptions about the past while preserving the intent of the future.

Resolution never halts and may freely rewrite, transform, or discard patches in the suffix, exactly as during rebase.


4.7.4 Properties#

  • Patch compression is implemented by reusing the rebase effect-propagation machinery.
  • Compression treats patch removal as a change to history, not as a local edit.
  • No canonical patch representation is required.
  • Structural and content patches are handled uniformly.
  • Compression preserves the final document state.
  • Time complexity is quadratic in the number of patches.

Invariant: A patch P is eliminated if and only if rebase-style propagation of its inverse through the remaining patches—under keep_mine semantics—fully absorbs the effect of its removal.


5. API Specification#

5.1 API Tree#

/api/library/volume/:id
├── /patch POST - Apply edit patch
├── /undo POST - Undo last patch
├── /redo POST - Redo undone patch
├── /reset POST - Reset to admin state (user only)
├── /officialize POST - Fast-forward admin to user (admin only)
├── /rebase
│ ├── /start POST - Start rebase session
│ ├── /continue POST - Continue with resolution
│ └── /abort POST - Abort rebase session
├── /status GET - Get sync status
├── /history GET - Get patch history
└── /snapshot POST - Force snapshot rebuild

5.2 Patching & Editing#

POST /api/library/volume/:id/patch

  • Body: { operation: PatchOperation, branchVersion: number }
  • Response: { success, newHeadId, newVersion, patch }
  • Errors: 400 (validation), 404 (not found), 409 (version mismatch)

POST /api/library/volume/:id/undo

  • Body: { branchVersion: number }
  • Response: { success, newHeadId, newVersion, patch } (patch is inverse)
  • Errors: 400 (at genesis), 404, 409

POST /api/library/volume/:id/redo

  • Body: { branchVersion: number }
  • Response: { success, newHeadId, newVersion, patch }
  • Errors: 400 (nothing to redo), 404, 405 (admin), 409

5.3 Synchronization#

POST /api/library/volume/:id/rebase/start

  • Body: { } (empty)
  • Response: { status: 'complete', newHeadId } or { status: 'paused', rebaseId, conflict }

POST /api/library/volume/:id/rebase/continue

  • Body: { rebaseId, resolution: 'keep_admin' | 'keep_mine' }
  • Response: Same as start

POST /api/library/volume/:id/rebase/abort

  • Body: { rebaseId }
  • Response: { status: 'aborted' }

POST /api/library/volume/:id/reset

  • Body: { }
  • Response: { success: true }
  • Errors: 404, 405 (admin cannot reset)

5.4 Officialize#

POST /api/library/volume/:id/officialize

  • Body: { sourceBranchUserId: string }
  • Response: { success, newHeadId }
  • Errors: 400 (cannot fast-forward), 403 (non-admin), 404

5.5 Status & History#

GET /api/library/volume/:id/status

  • Response: { hasAhead, hasBehind, version, headPatchId }

GET /api/library/volume/:id/history

  • Query: limit, offset
  • Response: { patches: [...], total }

5.6 Strategy Pattern#

EndpointUserAdmin
patchFork-on-writeDirect edit
undoHEAD crawlBranch drag or blocked
redoFollow childNot available (405)
resetDiscard editsNot applicable (405)
rebaseSync with adminNot applicable (405)
officializeNot applicable (403)Fast-forward

6. Security & Migration#

6.1 Security Considerations#

  • Ownership checks: Users can only modify their own branches
  • Admin-only: Officialize requires admin authentication
  • Optimistic locking: Prevents concurrent edit conflicts

6.2 Migration#

Existing .mokuro files work without migration. Genesis patches are created lazily on first access.


Appendix A: Path Specification#

This appendix defines the user-facing patch operation paths. Paths use JSON-pointer–like segments with arrays addressed by numeric indices. Array appends use index array.length.

A.1 Block Operations#

OperationPathValue Type
Add Block/pages/{p}/blocks/{b}UnifiedBlock
Remove Block/pages/{p}/blocks/{b}N/A (old_value required)
Reorder Blocks/pages/{p}/blocksN/A (new_order required)
Resize Box/pages/{p}/blocks/{b}/boxRect
Set Vertical/pages/{p}/blocks/{b}/verticalboolean
Set Font Size/pages/{p}/blocks/{b}/font_sizenumber

A.2 Line Operations#

OperationPathValue Type
Add Line/pages/{p}/blocks/{b}/lines/{l}UnifiedLine
Remove Line/pages/{p}/blocks/{b}/lines/{l}N/A (old_value required)
Reorder Lines/pages/{p}/blocks/{b}/linesN/A (new_order required)
Edit Text/pages/{p}/blocks/{b}/lines/{l}/textstring
Edit Coords/pages/{p}/blocks/{b}/lines/{l}/coordsQuad

Notes

  • To append, use {b} or {l} equal to array.length.
  • Direct modification of the lines_coords array is forbidden; use /lines/{l}/coords.

Appendix B: Rebase Semantics#

This appendix defines how a user patch operation is transported through an upstream effect (admin change) during rebase.

B.0 Conventions#

Structural effects

  • shift_up(A, k): an element was inserted into array at path A at index k.
  • shift_down(A, k, D): an element at A/k was removed; D is the deleted payload.
  • permute(A, π): array at A was reordered by permutation π.
  • content(P, vA): leaf at path P was replaced to value vA.

Permutation convention

  • π(i) maps old index → new index.
  • Composition is function composition where inner happens first:
    • [(outer ∘ inner)(i) = outer(inner(i))]
    • (matches result[i] = outer[inner[i]])

Internal implementation note (not user-facing): reorder/permute are treated as acting “at the array node” for intersection checks (often represented by a sentinel like /-1). The semantics below remain the same.


B.1 Intersection Classification#

TypeMeaningWhat Transforms
no_hitNo overlap between user patch and effectNone
collateral_ancestor_hitEffect is structural on an array A, and the user patch targets a descendant inside A (but not at the same array level)User patch path (index transport)
collateral_descendant_hitUser patch is structural on an array A, and the effect targets a descendant inside AEffect path (index transport)
sibling_hitBoth are structural at the same array levelBoth (mutual transport)
direct_hitExact target match at the same nodeConflict
ancestor_hitUser patch targets inside a subtree whose root node was affectedConflict
descendant_hitEffect targets inside a subtree whose root node was affected by the user patchConflict

Important nuance: shift_up is treated as “inserting into a gap,” so it does not produce direct_hit / ancestor_hit with an element path; it interacts structurally (e.g., sibling_hit / descendant_hit cases).


B.2 Deterministic Structural Transforms (No Conflict)#

These cases rewrite indices/paths without invoking conflict resolution.

B.2.1 collateral_ancestor_hit (structural effect on array, user patch inside that array)#

Let effect act on array A and user patch target A/i/... (so the first segment under A is an index i).

effect.typeIndex transport for user index iDescription
shift_up(A, k)i' = i + 1 if i ≥ k, else i' = iinsertion pushes later indices right
shift_down(A, k, D)i' = i − 1 if i > k, else i' = ideletion pulls strictly-later indices left
permute(A, π)i' = π(i)reorder maps old index to new index

User path becomes A/i'/.... Effect itself is unchanged.


B.2.2 collateral_descendant_hit (user structural patch on array, effect inside that array)#

Let user patch act on array A and effect target A/i/....

user.opIndex transport for effect index iDescription
add at A/ki' = i + 1 if i ≥ k, else i' = iinsertion shifts later effect targets right
remove at A/ki' = i − 1 if i > k, else i' = ideletion shifts strictly-later effect targets left
reorder(A, ρ)i' = ρ(i)reorder maps old index to new index

Effect path becomes A/i'/.... User op is unchanged.


B.2.3 sibling_hit (both structural on the same array)#

Here both sides are structural on the same array A. Rebase proceeds as mutual transport:

  1. transport the user index/permutation through the effect, and
  2. update the effect index/permutation to account for the user structural change.
(a) user reorder(A, ρ) vs effect shift_up(A, k) or shift_down(A, k, D)#
effect.typeDescription
shift_up(A, k)Extend the user permutation to length+1 to include the inserted element at index k (preserving relative order of existing elements). Then map the inserted element’s index through the extended permutation to determine the effect’s new index.
shift_down(A, k, D)First map the deleted element’s index through the user permutation to determine where that element would be after user reorder; then shrink the user permutation to length−1 by removing the deleted element’s slot.

Note: user reorder vs effect permute is treated as direct_hit (reorder_collision), not a sibling transform.

(b) user add/remove vs effect shift_up/shift_down#

Let user index be u (for add or remove at A/u) and effect index be k.

effect.typeTransport user index u through effectThen update effect index k through user structural change
shift_up(A, k)u' = u+1 if u ≥ k, else u' = uif user inserts/removes at u' and u' < k, then k shifts by +1 (insert) or −1 (remove)
shift_down(A, k, D)u' = u−1 if u > k, else u' = u (the equality case is a direct_hit conflict)if user inserts/removes at u' and u' < k, then k shifts by +1 (insert) or −1 (remove)
(c) user add/remove vs effect permute(A, π)#
user.opDescription
add at A/uExpand effect permutation π to length+1 to account for the inserted element, then transport the user insertion index through the expanded permutation to get the rebased insertion index. The effect keeps the expanded permutation.
remove at A/uTransport the user removal index through π to get the rebased removal index, then shrink effect permutation to length−1 by removing the removed slot. The effect keeps the shrunk permutation.

B.3 Conflict Resolution Matrix#

Conflicts arise only for direct_hit, ancestor_hit, and specific descendant_hit cases.

Intersectioneffect.typeuser.opConflictAutokeep_adminkeep_mine
direct_hitshift_downaddshift_down_into_addkeep patch; move delete index from k to k+1
direct_hitshift_downremovedouble_deletedrop patch; effect → identity
direct_hitpermutereorderreorder_collisiondrop user reorder; effect permutation becomes ρ⁻¹ ∘ πkeep reorder; rewrite to π⁻¹ ∘ ρ; effect → identity
direct_hitcontentreplacecontent_conflictdrop user replace; keep effectkeep replace, but set old_value := vA; effect → identity
ancestor_hitshift_downany (inside deleted subtree)dead_zonedrop user op; but fold user change into deleted payload D := Apply(D, r, user_op)resurrect: output add at deleted path with D := Apply(D, r, user_op); effect → identity
descendant_hitanyremovereverse_dead_zonedrop user remove; output shift_up reinserting the (updated) old payloadkeep user remove but update old_value by folding admin change into it; effect → identity
descendant_hitanyaddeffect_shiftkeep patch; transport effect target path through the insertion (i ≥ k ⇒ i+1)
descendant_hitanyreordereffect_shiftkeep patch; transport effect target path through the reorder (i → ρ(i))

Where:

  • In dead_zone, r is the relative path tail inside the deleted element: user targets A/k/r.
  • In reverse_dead_zone, the “fold admin change into payload” applies the admin effect (converted to an op when possible) into the removed element’s payload at the appropriate relative tail.

Invariant (operational):

  • keep_mine returns a concrete user op and absorbs the effect (effect → identity).
  • keep_admin returns no user op (op → null) and preserves a residual effect, sometimes rewritten to account for the user’s original intent.

B.4 Inversion Property (keep_admin from keep_mine)#

For every conflict that supports both resolutions (dead_zone, reverse_dead_zone, reorder_collision, content_conflict), the keep_admin outcome can be derived as:

Do keep_mine first, obtaining (op_mine, identity),
then move the inverse of op_mine into the effect and set op := null.

Formally: [ \texttt{keep_admin}(u,e) \equiv (,op := null,; effect := \text(op_^),) \quad\text\quad (op_, id)=\texttt{keep_mine}(u,e) ]

Why it matches exactly here: in content_conflict, keep_mine first rewrites the user replace to have old_value := vA (the admin-set value). That makes its inverse effect precisely “set to vA,” which is exactly the keep_admin residual content effect.

Feature Specification: OCR Version Control
https://nguyenston.github.io/posts/unlisted/ocr-version-control/
Author
Nguyen Phuc Nguyen
Published at
2026-01-27
License
CC BY-NC-SA 4.0