Format & Branding — the .rewind contract

ExcelRewind architecture documentation · packages/format + packages/branding + scripts/rewind_writer.py

Contents
  1. Overview
  2. Architecture diagram
  3. Data-flow diagrams
  4. Process / sequence diagrams
  5. Interconnections
  6. Key functions & files
  7. Invariants & gotchas

1. Overview

packages/format (npm workspace @excelrewind/format) is the contract of the ExcelRewind product: the TypeScript types, binary container codec, replay reducer, encryption envelope, and pure spreadsheet-formula utilities that define what a .rewind file is. It ships no server and no UI — it is a dependency-light library (only @msgpack/msgpack and fflate) imported by every surface that reads or writes a .rewind file: the Excel add-in (apps/addin-excel), the zero-backend web viewer (apps/viewer), the Team console (apps/team), and the Google Sheets add-on (apps/addon-sheets). Per CLAUDE.md: "change the format here and only here."

packages/branding (@excelrewind/branding) is a one-file constants module: every user-facing string (product name, file extension, support email, the tamper-error copy) plus BUILD_TAG, the version string every UI surface displays and that scripts/smoke.mts greps for in a deployed bundle to catch a stale Cloudflare Pages deploy.

Nothing in either package makes a network call, touches the DOM, or calls Office.js — they run identically in a browser tab, a WebView2 Office.js task pane, a Cloudflare Worker, and Node 18+ (crypto.ts and hash.ts key off globalThis.crypto.subtle, which all four environments provide). Build: no bundler step of its own — each package is "main": "src/index.ts" and consumers' Vite/tsc configs compile it in-place as part of their own build. Tests run via npx vitest run from packages/format (13 test files) and are included in the root npm test. scripts/rewind_writer.py is a companion stdlib-only Python re-implementation of the write path, used only as a cross-language contract oracle (§6) — it is not part of the shipped product.

2. Architecture diagram

flowchart TB
  subgraph TYPES["packages/format/src/types.ts"]
    T1["RewindDocument / RewindMeta"]
    T2["CellChangeEvent / Annotation"]
    T3["EncryptSpec / UnlockOptions / EncDescriptor"]
    T4["Recipient (passphrase / org / rsa)"]
    T5["Baseline / GalleryItem / Lineage"]
  end

  subgraph CODEC["container.ts — the ZIP codec"]
    C1["packEntry / unpackEntry (deflate + MessagePack)"]
    C2["writeRewind()"]
    C3["readRewind() / peekRewind()"]
    C4["encryptContainer() / decryptContainer()"]
    C5["boundedFilter() — decompression-bomb caps"]
  end

  subgraph CRYPTO["crypto.ts — pure WebCrypto primitives"]
    X1["encryptBytes / decryptBytes (AES-256-GCM)"]
    X2["wrapKey / unwrapKey"]
    X3["deriveKek (PBKDF2-SHA256, 310k iters)"]
    X4["genRsaKeyPair / rsaWrapKey / rsaUnwrapKey (RSA-OAEP-3072)"]
    X5["makeRecoveryCode()"]
  end

  subgraph KEYRING["keyring.ts — key-row lifecycle"]
    K1["createKeyRow()"]
    K2["unlockKeyRow() / privFromRow()"]
    K3["escrowFromRow() / rewrapPass() / newRecovery()"]
  end

  subgraph PASS["passphrase.ts"]
    P1["checkPassphrase() — length/variety/blocklist gate"]
  end

  subgraph REPLAY["Pure spreadsheet-domain modules"]
    R1["replay.ts — applyEvent / replayTo / GridState"]
    R2["fill.ts — toR1C1 / fromR1C1 / detectFill / expandFill"]
    R3["history.ts — cellHistory()"]
    R4["branch.ts — branchPoint / branchDiff / mergeBranches"]
    R5["extrefs.ts — hasExternalRef / normalizeExternalRefs"]
    R6["spotlight.ts — spotlightCues()"]
    R7["a1.ts — A1 <-> index helpers"]
    R8["hash.ts — sha256Hex()"]
  end

  IDX["index.ts (barrel export = package public API)"]

  subgraph BRAND["packages/branding/src/index.ts"]
    B1["PRODUCT_NAME / FILE_EXTENSION / FILE_NOUN"]
    B2["ERR_TAMPERED / MS_DISCLAIMER"]
    B3["BUILD_TAG"]
  end

  PYORACLE["scripts/rewind_writer.py — stdlib MsgPack+ZIP re-implementation"]

  CODEC --> TYPES
  CODEC --> CRYPTO
  CODEC --> R8
  R1 --> R2
  R2 --> R7
  R3 --> R2
  R3 --> R7
  R4 --> R1
  R6 --> R5
  KEYRING --> CRYPTO
  IDX --> TYPES
  IDX --> CODEC
  IDX --> REPLAY
  IDX --> CRYPTO
  IDX --> KEYRING
  IDX --> PASS
  PYORACLE -. "byte-for-byte mirrors" .-> CODEC

3. Data-flow diagrams

3a. Plaintext write / read round trip

flowchart LR
  DOC["RewindDocument (in memory:
meta, timeline[], annotations[],
baseline?, media?, refs?)"] -->|"writeRewind(doc)"| SORT["sort timeline & annotations by t"] SORT --> PACK["packEntry() per component
= deflate(MessagePack(value))"] PACK --> HASH["sha256Hex() each entry -> meta.integrity map"] HASH --> META["meta.bin = packEntry(RewindMeta incl. integrity)"] META --> ZIP["zipSync(entries, level:0)
= .rewind bytes"] ZIP -->|"file save (add-in) / download (viewer)"| DISK[(".rewind file on disk
or attached to email/Slack")] DISK -->|"readRewind(bytes)"| UNZIP["unzipSync() -> entries map"] UNZIP --> METAR["inflate + decode meta.bin"] METAR --> VERGATE{"formatVersion <= FORMAT_VERSION?"} VERGATE -- no --> ERRV["throw Unsupported format version"] VERGATE -- yes --> INTCHK["recompute sha256Hex per entry,
compare to meta.integrity"] INTCHK -- mismatch --> ERRT["throw Integrity check failed
(surfaced as ERR_TAMPERED)"] INTCHK -- match --> UNPACK["unpackEntry() timeline/annotations/baseline/gallery"] UNPACK --> DOC2["RewindDocument back in memory"]

3b. Encrypted container (OPS-6) write / read

flowchart LR
  DOC["RewindDocument"] -->|"writeRewind(doc, encSpec)"| ENTRIES["same plaintext entries as 3a
(timeline.bin, annotations.bin, media/*, refs/*, meta.bin)"] ENTRIES --> CEK["randomKey() -> fresh 256-bit CEK"] CEK --> WRAP["wrapCek(cek, spec):
wrap CEK per recipient
(passphrase->PBKDF2 KEK, org->OMK, rsa->pubkey)"] WRAP --> ENV["EncEnvelope { enc, recipients[], escrows? }"] ENTRIES --> SEALFULL["encryptBytes(meta.bin bytes, cek, aad='meta-full.bin')
-> meta-full.bin"] ENTRIES --> SEALREST["encryptBytes(each entry, cek, aad=entryName)
-> sealed timeline.bin / annotations.bin / media/* / refs/*"] ENV --> METAOUT["meta.bin = packEntry(envelope) -- PLAINTEXT"] METAOUT --> ZIPE["zipSync(...) = encrypted .rewind bytes"] SEALFULL --> ZIPE SEALREST --> ZIPE ZIPE -->|"readRewind(bytes, {unlock})"| UNZIPE["unzipSync()"] UNZIPE --> READENV["decode meta.bin -> EncEnvelope (plaintext, readable without unlocking)"] READENV --> UNWRAP["unwrapCek(envelope, unlock):
try passphrase -> org OMK -> cached RSA priv -> escrow chain"] UNWRAP -- "no material / wrong secret" --> ERRU["throw: encrypted / wrong passphrase"] UNWRAP -- ok --> CEK2["CEK CryptoKey"] CEK2 --> DECALL["decryptBytes() every non-meta.bin entry;
GCM auth fail on any entry -> Integrity check failed"] DECALL --> DECFULL["decrypt meta-full.bin -> real RewindMeta"] DECFULL --> REST["rest of readRewind runs on now-plaintext entries
(identical to 3a from the integrity sweep onward)"]

3c. Crash-recovery buffer (add-in, IndexedDB)

flowchart LR
  START["recording starts, encryption enabled"] -->|"newBufferKey(spec) in buffer-crypto.ts"| BCEK["fresh buffer-CEK
wrapped to same recipients as the eventual save"] BCEK -->|"persist ONCE"| IDB[("IndexedDB:
rewind.active-session envelope")] LOOP["every ~1s: draft SessionDraft +
audio/video chunk"] -->|"sealBuffer(bytes, cek)
= encryptBytes(bytes, cek, aad='rewind.recovery-buffer')"| IDB CRASH["Excel / tab crash, reopen"] -->|"unlockBufferKey(envelope, unlock)
= unwrapCek() -- same code path as file unlock"| BCEK2["recovered buffer-CEK"] BCEK2 -->|"openBuffer(blob, cek) per chunk"| RESTORE["SessionDraft + chunks
restored to resume recording"]

4. Process / sequence diagrams

4a. Record → encrypt → save (add-in)

sequenceDiagram
  participant User
  participant Rec as "addin-excel recorder.ts"
  participant App as "addin-excel App.tsx"
  participant Persist as "persistence.ts / buffer-crypto.ts"
  participant Format as "@excelrewind/format container.ts"
  participant Disk as "local .rewind file"

  User->>Rec: edits cells (Office.js onChanged)
  Rec->>App: normalized CellChangeEvent[]
  App->>Persist: buffer to IndexedDB (sealed, 3c)
  User->>App: click "Stop & Save"
  App->>Format: writeRewind(doc, encSpec?)
  alt encryption OFF (free/starter tier or user opt-out)
    Format-->>Format: zipSync(plaintext entries)
  else encryption ON
    Format->>Format: randomKey() CEK, wrapCek(spec)
    Format->>Format: encryptBytes() every entry, aad=entry name
  end
  Format-->>App: .rewind bytes (Uint8Array)
  App->>Disk: File System Access API / download save
  App->>Persist: clear IndexedDB crash-recovery buffer

4b. Open & unlock an encrypted file (viewer)

sequenceDiagram
  participant User
  participant Viewer as "apps/viewer App.tsx"
  participant Format as "@excelrewind/format container.ts"
  participant Dialog as "UnlockDialog.tsx"

  User->>Viewer: open .rewind (drag/drop or link)
  Viewer->>Format: peekRewind(bytes)
  Format-->>Viewer: {encrypted:true, audioBytes, videoBytes, ...}
  Viewer->>Viewer: getCachedSecret() -- last working secret this session
  alt cached secret matches (same org key)
    Viewer->>Format: readRewind(bytes, {limited:true, unlock:cached})
    Format-->>Viewer: RewindDocument -- silent unlock, no dialog
  else no cached secret / cached secret fails
    Viewer->>Dialog: show unlock prompt
    User->>Dialog: enters passphrase (or recovery code)
    Dialog->>Format: readRewind(bytes, {limited:true, unlock:{passphrase}})
    Format->>Format: unwrapCek() tries recipients, then escrow chain
    alt wrong passphrase
      Format-->>Dialog: throws "Wrong passphrase or key"
      Dialog-->>User: inline error, retry
    else correct
      Format->>Format: decryptContainer() + integrity sweep (bounded, limited:true)
      Format-->>Dialog: RewindDocument
      Dialog->>Dialog: setCachedSecret() -- remember for next file this session
    end
  end
  Viewer->>Viewer: replayTo() renders grid at t=0

4c. Branch merge (add-in App.tsx, rendering BranchMerge.tsx)

sequenceDiagram
  participant User
  participant App as "addin-excel App.tsx"
  participant UI as "BranchMerge.tsx (presentation only)"
  participant Branch as "branch.ts"
  participant Replay as "replay.ts"

  User->>App: pick two same-family .rewind files (a, b)
  App->>Branch: sameFamily(a, b)
  Branch-->>App: false -> abort, "not related sessions"
  App->>Branch: branchDiff(a, b)
  Branch->>Branch: branchPoint(a, b) internally --
walk sorted timelines pairwise until first mismatch Branch->>Replay: replayTo(timeline, Infinity, baseline) for both a and b Branch-->>App: {branchPointT, per-cell {aFinal, bFinal, conflict}} App->>UI: render diff, collect conflict choices User->>UI: resolve each conflict (keep a / keep b) UI-->>App: choices map App->>Branch: mergeBranches(a, b, choices) Branch->>Branch: time-shift b's suffix past a's end,
rewrite contradicting from-values against a's final grid,
tag deciding writes source:'merge' Branch-->>App: merged RewindDocument (new sessionId "merge-...", lineage set) App->>App: writeRewind(merged) -> save new .rewind

4d. Cross-language contract test (LibreOffice port gate)

sequenceDiagram
  participant CI as "vitest (crosslang.test.ts)"
  participant Py as "scripts/rewind_writer.py"
  participant Format as "readRewind() (TS)"

  CI->>CI: pythonAvailable()? (execFileSync python --version)
  alt python missing
    CI-->>CI: describe.skipIf -> suite skipped, CI stays green
  else python present
    CI->>Py: execFileSync python rewind_writer.py <tmpfile>
    Py->>Py: raw-deflate(msgpack(value)) per entry, sha256 integrity map,
ZIP_STORED (no zip-level compression) Py-->>CI: session.rewind bytes written to temp dir CI->>Format: readRewind(bytes) Format->>Format: unzip, verify integrity, decode msgpack alt any mismatch (deflate/msgpack framing diverges) Format-->>CI: throws -> test fails, port gate blocks merge else byte contract honored Format-->>CI: RewindDocument matching Python's deterministic_session() CI->>CI: assert meta/timeline/annotations/baseline round-trip exactly end end

5. Interconnections

packages/format and packages/branding perform no I/O of their own — no network calls, no filesystem access beyond what a caller hands them as bytes. Every "interconnection" below is an in-process library import by another workspace package (a monorepo edge, not a wire protocol), except the Python oracle (a subprocess) and the DB-schema mirror (a data-shape contract, not a live call from this package).

flowchart LR
  FMT["packages/format
+ packages/branding"] ADDIN["apps/addin-excel"] VIEWER["apps/viewer"] TEAM["apps/team"] SHEETS["apps/addon-sheets"] SMOKE["scripts/smoke.mts"] PY["scripts/rewind_writer.py"] DB[("Supabase org_keys / user_keys
(backend-data)")] FMT -->|"writeRewind / readRewind / peekRewind / branch.ts / keyring.ts (via keyring-client.ts) / wrapCek / unwrapCek (buffer-crypto.ts)"| ADDIN FMT -->|"readRewind(limited:true) / peekRewind / replayTo / branchDiff / spotlightCues / UnlockOptions"| VIEWER FMT -->|"createKeyRow / unlockKeyRow / rewrapPass / newRecovery (keyring.ts, Encryption.tsx only)"| TEAM FMT -->|"CellChangeEvent / Annotation types, writeRewind (bundled sidebar.html)"| SHEETS FMT -->|"BUILD_TAG (branding)"| SMOKE PY -.->|"byte-for-byte mirrors container.ts; verified by crosslang.test.ts"| FMT FMT -.->|"KeyRowFields column-neutral shape matches"| DB
External surfaces this module's contract touches
PeerDirectionProtocol / mechanismPurpose
apps/addin-excelformat → addin-excelESM import (Vite compiles src/index.ts in-place)Writes the session .rewind on save via writeRewind(doc, encSpec)encSpec is resolved by apps/addin-excel/src/keyring-client.ts's getEncryptSpec(), which calls createKeyRow/unlockKeyRow/privFromRow/rewrapPass/newRecovery/escrowFromRow/unwrapUnderMaster against keyring.ts directly (the actual key-row lifecycle client, not apps/team); encryption itself happens inside writeRewind — the add-in never calls the unexported encryptContainer/decryptContainer directly. Separately, apps/addin-excel/src/buffer-crypto.ts reuses wrapCek/unwrapCek/encryptBytes/decryptBytes directly to seal the IndexedDB crash-recovery buffer; replays for in-Excel temp-sheet playback via replayTo; branch/merge UI (App.tsx, rendering BranchMerge.tsx) via branch.ts.
apps/viewerformat → viewerESM importReads a shared .rewind with {limited:true} (decompression-bomb caps apply — untrusted input), unlocks encrypted files via UnlockOptions, replays the grid, renders Provenance Spotlight cues, cell history, branch diffs.
apps/teamformat → teamESM importOrg/personal key-rotation screen Encryption.tsx calls createKeyRow/unlockKeyRow/rewrapPass/newRecovery against keyring.ts directly. Account.tsx does NOT import @excelrewind/format at all — it's billing/profile UI only. escrowFromRow is not called from apps/team; it's called only from apps/addin-excel/src/keyring-client.ts (see that row).
apps/addon-sheetsformat → addon-sheetsESM import, bundled into one dist/sidebar.htmlGoogle Sheets sidebar's poll-diff engine emits the same CellChangeEvent shape and calls writeRewind to produce a cross-platform-compatible .rewind.
scripts/smoke.mtsbranding → smokeESM import + string match over deployed HTML/JSPost-deploy check that the live add-in/viewer bundle contains the current BUILD_TAG — catches a stale Cloudflare Pages deploy.
scripts/rewind_writer.pyformat ↔ Python (subprocess)vitest spawns python scripts/rewind_writer.py <out>, then readRewind() on the resultCross-language contract oracle: proves the byte format (ZIP_STORED + raw-deflate MessagePack + SHA-256 integrity map) is implementable outside Node/fflate — the gate for the planned LibreOffice port.
Supabase org_keys / user_keys (backend-data)schema mirror, not a live callColumn-name-neutral shape (keyring.ts's KeyRowFields)keyring.ts is deliberately Supabase-agnostic — callers (team console, add-in) map DB row columns to/from KeyRowFields so the crypto stays testable without a DB and the zero-knowledge property holds (format package never sees a Supabase client).

6. Key functions & files

packages/format/src/types.ts
ExportWhat it doesWhy it matters
CellChangeEventCore timestamped event shape: t, sheet, cell, from, to, formula_from, formula_to, type + optional source, author, rich, flag, mediaId, fill.source:'gap'|'calc'|'merge' tells the Assumption Extractor (future AI pipeline) which values were NOT a user decision — get this wrong and the AI pipeline learns from Excel's own recalculation as if a human typed it.
FORMAT_VERSION = 1The plaintext format version. Has not changed since MVP.New optional fields are added additively (baseline, video, lineage, spotlights, externalRefs) specifically so old readers keep working and FORMAT_VERSION never has to bump.
EncDescriptor / Recipient / EncryptSpec / UnlockOptions / EscrowEntryThe encryption envelope's type vocabulary — three recipient kinds (passphrase/org/rsa) and the file-embedded escrow shape.Defines exactly what unlock material a reader must supply and what an encrypted file can prove.
OBJECTS_MARKER / CHARTS_MARKER / PIVOTS_MARKER / TABLES_MARKERSynthetic cell addresses like "(charts)" for object count-change trace events.Charts are not part of the Office.js shapes collection — hard-won platform knowledge; without a separate marker the recorder can't tell "a chart appeared" from "a shape appeared."
packages/format/src/container.ts
ExportWhat it doesWhy it matters
packEntry / unpackEntryvalue -> deflate(MessagePack(value)) and back. unpackEntry takes a maxOut bound.The deflate pass is deliberate obfuscation (not encryption) — raw MessagePack keeps strings verbatim, so an unencrypted .rewind would otherwise be greppable in a text editor.
writeRewind(doc, enc?)Sorts timeline/annotations, packs every component, computes the integrity SHA-256 map, assembles RewindMeta, zips at store level (0). Encrypts (§3b) when enc is passed.The single write path both platforms funnel through; encryption is a pure wrapper so the plaintext path is byte-for-byte unchanged when enc is omitted.
readRewind(bytes, opts)Unzips, decodes meta.bin, decrypts if enc present, checks formatVersion <= FORMAT_VERSION, verifies every entry's SHA-256 against meta.integrity, reconstructs the document (audio/video track normalization, refs, gallery).opts.limited is the untrusted-input gate — only the viewer sets it (a received/shared file); the add-in reads its own local file unbounded. Forgetting limited:true on a new untrusted-input surface reopens the decompression-bomb hole.
peekRewind(bytes)Inflates only meta.bin; tallies audio/video byte sizes from the ZIP directory's declared originalSize without inflating those entries.Lets the viewer size a "this file has 40 min of audio — load it?" prompt on a multi-GB file cheaply.
wrapCek(cekRaw, spec) / unwrapCek(envelope, unlock)Wrap a raw CEK to every recipient in an EncryptSpec; resolve it back from UnlockOptions, trying passphrase/org recipients, then cached RSA privs, then the escrow chain.Exported specifically so apps/addin-excel/src/buffer-crypto.ts can wrap/unwrap the crash-recovery buffer's CEK through the identical code path a real file uses — one crypto implementation, two call sites.
boundedFilter(keep)fflate filter closure that throws once declared uncompressed size/count exceeds MAX_ENTRY_UNCOMPRESSED (256MB) / MAX_TOTAL_UNCOMPRESSED (512MB) / MAX_ENTRY_COUNT (1024); paired with a proportional inflateBound() cap (ratio 100:1) on the real inflate call since originalSize is attacker-declared.Decompression-bomb guard — applied only under opts.limited, i.e. only on the viewer's untrusted-file path.
ENTRY_META / ENTRY_META_FULL / ENTRY_TIMELINE / ENTRY_ANNOTATIONS / ENTRY_SNAPSHOT / ENTRY_BASELINE / ENTRY_GALLERY / MEDIA_PREFIX / REFS_PREFIXContainer entry-name constants.The literal on-disk contract; the Python oracle (§6 below) must reproduce these exact strings.
packages/format/src/crypto.ts, keyring.ts, passphrase.ts
ExportWhat it doesWhy it matters
encryptBytes / decryptBytesAES-256-GCM seal/open, blob layout [iv(12) | ciphertext+tag(16)], fresh random IV every call, optional AAD.AAD binds ciphertext to its entry name (or to 'rewind.recovery-buffer' for the crash buffer) so a ciphertext can't be swapped between container entries or contexts.
deriveKek(passphrase, salt, iters)PBKDF2-SHA256 → AES-256-GCM key, ~0.23s at the default 310,000 iterations.PBKDF2_ITERS is the tunable work factor; stored per-record (not global) so it can be raised later without breaking old files.
genRsaKeyPair / rsaWrapKey / rsaUnwrapKeyRSA-OAEP-3072 keypair; wrap/unwrap a raw CEK under a public/private key.Lets the add-in encrypt-to-org/user at save time using only a public key — no secret prompt interrupts recording.
makeRecoveryCode()160 random bits → base32, grouped XXXX-XXXX-... (8 groups).The offline unlock path when a passphrase is forgotten — the whole reason escrow entries exist.
createKeyRow(passphrase) (keyring.ts)Mints a master key + RSA keypair + recovery code, wraps the master under both passphrase and recovery code, seals the RSA priv under the master.Returns the raw master key to the CALLER — keyring.ts never persists it; only the wrapped forms are meant to reach a DB row.
escrowFromRow(keyId, fields)Builds the EscrowEntry embedded verbatim into an encrypted file.This is what makes an encrypted .rewind self-contained: the zero-backend viewer can unlock with just a passphrase, no Supabase session (W2 in the code comments).
checkPassphrase(p) (passphrase.ts)Length ≥12, not a repeated-char run, ≥2 character classes, not on a small common-password blocklist.Documented as a partial defense (CRYPTO_REVIEW HIGH-1) — raises the floor against offline GPU cracking of the escrow, does not make it infeasible. No zxcvbn dependency by design.
packages/format/src/replay.ts, fill.ts, history.ts, branch.ts, extrefs.ts, spotlight.ts, a1.ts, hash.ts
ExportWhat it doesWhy it matters
applyEvent / replayTo / seedBaseline (replay.ts)Fold a sorted CellChangeEvent[] (optionally seeded from a Baseline) into a GridState (Map<sheet, Map<cell, CellState>>) as of time t.The single replay reducer every surface (in-Excel temp-sheet replay, web viewer, branch diff) shares — a bug here mis-renders every product surface at once.
toR1C1 / fromR1C1 / detectFill / expandFill (fill.ts)A1↔R1C1 translation of relative refs; detects a uniform drag-fill (same R1C1 formula across N cells) and compresses it to one range event; expands it back for replay.Turns a 1,666-row VLOOKUP drag into one timeline event instead of 1,666 — without this the timeline would be unusable for large models. expandFill caps at 10,000 cells and degrades to a no-op rather than throwing, since replay runs on every scrub.
cellHistory(doc, sheet, cell) (history.ts)Deterministic, non-AI "Explain this cell": ordered list of baseline seed + every direct/fill-derived change to one cell, with nearby annotations attached.Ships as a real (non-AI) feature; the AI-powered assumption extraction is explicitly deferred post-MVP per CLAUDE.md — this is the interim, always-correct substitute.
branchPoint / branchDiff / mergeBranches (branch.ts)"git for spreadsheets": finds the last identical timeline prefix between two same-family sessions, diffs their divergent suffixes per-cell, and merges with a conflict chooser.mergeBranches is explicitly an approximation (comment: "we don't re-simulate b's intermediate formula chain against a's grid") — only from/formula_from on b's suffix are patched against a's final grid.
hasExternalRef / extractExternalRefs / normalizeExternalRefs / parseExternalRefs (extrefs.ts)Detects and rewrites cross-workbook formula refs; converts the closed-workbook form (absolute path baked in) to the portable open-workbook form [Book2.xlsx]Sheet1!A1.A closed-form ref bakes in a path meaningless on another machine (replays as #REF!) — this is what makes bundled refs/* workbooks actually replay correctly elsewhere.
spotlightCues(doc, opts) (spotlight.ts)Builds the ordered "Provenance Spotlight" cue list — Tier 2 uses recorded meta.spotlights spans; Tier 1 derives cues from formula external refs, deduped by file|sheet|range.Tier-1 (derived) is the free-tier fallback: no explicit "I looked at this" recording needed, just formula analysis.
indexToCol / colToIndex / normalizeCell / parseA1 / toA1 / expandAddress / boundsOf (a1.ts)A1-notation utilities shared by recorder and viewer.expandAddress caps at 10,000 cells — same bomb-guard pattern as rangeCells/expandFill.
sha256Hex(data) (hash.ts)SHA-256 hex digest via globalThis.crypto.subtle.One-liner, but it's the integrity primitive every entry hash and the OMK fingerprint (omkFingerprint) is built from — no Node crypto import, keeps the package browser/Worker-portable.
packages/branding/src/index.ts
ExportValue / roleWhy it matters
PRODUCT_NAME'ExcelRewind' — codename, per repo convention.The ONLY place to edit for a rebrand; see RENAME.md. AppSource fallback documented inline: swap to 'SheetRewind' here if Microsoft's marketplace review objects to "Excel" in the name.
FILE_EXTENSION'.rewind' (built from PRODUCT_SLUG='rewind').Every file-open dialog, download filename, and MIME association reads this — never a hardcoded '.rewind' literal elsewhere per the module's own doc comment.
ERR_TAMPEREDUser-facing string shown when integrity check fails.Maps 1:1 onto container.ts's "Integrity check failed" throw — the only place that error class surfaces to a user.
MS_DISCLAIMERNon-affiliation notice.Legal/trademark requirement shown in viewer/install/privacy footers.
BUILD_TAG'v3.23.3' at time of writing.Single shared version line across BOTH Windows and Mac Excel builds (one add-in bundle serves both) — bump on every ship; scripts/smoke.mts greps the deployed bundle for this exact string to catch a stale Cloudflare Pages deploy.
scripts/rewind_writer.py
FunctionWhat it doesWhy it matters
_mp / _mp_int / _mp_str / _mp_bin / _mp_array / _mp_mapHand-rolled MessagePack encoder (stdlib only — no msgpack pip package).Proves the wire format is implementable without Node's @msgpack/msgpack, a prerequisite for the LibreOffice port.
pack_entry(value)value -> raw-deflate(msgpack(value)) using zlib.compressobj(6, DEFLATED, -MAX_WBITS)raw deflate, no zlib header, matching fflate's inflateSync expectation exactly.The one line that would silently break cross-language compatibility if someone "simplified" it to zlib.compress() (which adds a 2-byte header + Adler-32 checksum fflate doesn't expect).
write_rewind(...)Mirrors container.ts's writeRewind: sorts, packs, computes the SHA-256 integrity map, writes a ZIP_STORED archive.Deflate streams differ byte-for-byte from fflate's — irrelevant, because integrity hashes are computed over this writer's own deflated bytes, and readRewind just inflates whatever it receives.
deterministic_session(out_path)Writes a fixed session (3 events, 2 annotations, a baseline, platform:'libreoffice') exercised by packages/format/test/crosslang.test.ts.The concrete oracle payload; the TS test asserts readRewind() reproduces every field exactly.

7. Invariants & gotchas

Never modify the original spreadsheet. Nothing in this package writes back to an .xlsx/Sheet — ENTRY_SNAPSHOT (workbook.xlsx) is an optional end-of-session copy bundled into the .rewind, never written back to the source file. This is a product-defining constraint from CLAUDE.md, not an implementation detail.
Deflate is deterrence, not encryption. The container.ts header comment says this explicitly: a plaintext .rewind's deflated MessagePack is "obfuscated beyond casual readability" only. Real confidentiality requires writeRewind(doc, encSpec) — the AES-256-GCM path is additive and opt-in (OPS-6). Per the rewind-encryption-gap memory: the SDD's "AES-256 user-controlled keys" claim was NOT implemented until v3.23.0 — verify any doc/marketing security claim against the actual code path before repeating it.
FORMAT_VERSION stays 1 by design. Every feature added since MVP (baseline, gallery, multi-track audio/video, lineage/branch-merge, externalRefs, spotlights) is an additive optional field so old readers silently ignore what they don't understand and new readers accept old files (meta.formatVersion <= FORMAT_VERSION gate in readRewind). Encrypted containers instead bump a separate counter — the plaintext envelope declares formatVersion = FORMAT_VERSION + 1 (currently 2) purely so a pre-encryption reader rejects an encrypted file with a clean "Unsupported format version: 2" instead of choking on ciphertext trying to msgpack-decode it. Encryption-aware readers gate on enc.v, never on this number.
Zero-knowledge is architectural, not just a policy. crypto.ts's header comment: "the server must never be able to decrypt, so all key material stays client-side." The package has zero dependency on any Supabase/HTTP client — keyring.ts's KeyRowFields is a column-name-neutral shape callers map to/from their own DB rows, keeping the crypto testable and provably unaware of any backend.
Decompression-bomb caps only apply under opts.limited. readRewind's doc comment: "OFF by default: the add-in opens the user's OWN local file, which may be any size." Only apps/viewer (an untrusted, possibly-shared file) passes {limited:true}. A future surface that accepts a .rewind from an untrusted source (e.g. a public upload endpoint) MUST also pass limited:true or the size/entry-count/inflate-ratio guards never engage.
Charts are not shapes in Office.js. OBJECTS_MARKER/CHARTS_MARKER/ PIVOTS_MARKER/TABLES_MARKER exist because the JS API's shapes collection does not include charts, and there is no native insert-event for any of these object families — the recorder has to diff per-sheet object counts and stamp a synthetic cell address. Hard-won platform knowledge encoded directly in the type file.
Rich values (Stocks/Geography linked data types) can't be represented by the classic values API — CellChangeEvent.rich marks that to/BaselineCell.v carries the cell's display text, not the underlying structured value. Downstream code (replay, history) must not treat a rich cell's stored value as round-trippable back into Excel.
Fill/paste range events are one-event-for-N-cells, and the two shapes are NOT interchangeable: a drag-fill carries a single shared formulaR1C1 (every cell's formula reconstructed via fromR1C1); a paste/block carries per-cell formulas verbatim (used when the multi-cell edit is not a uniform fill). expandFill silently no-ops (returns []) rather than throwing on an over-cap range (≥10,000 cells) — replay must never crash mid-scrub on a legitimately large file.
Session tags (SESSION_TAGS) are append-only. The type comment: the five original tags are "baked into existing .rewind files," and 'Personal / other' must stay last (UX convention) and present (it's the training-exclusion tag per CLAUDE.md's AI-pipeline constraint — only free-tier, non-Personal-tagged sessions may enter model training).
Branch merge is an approximation, documented as such. mergeBranches's own comment: it patches from/formula_from on b's suffix against a's final grid but does NOT re-simulate b's intermediate formula chain — good enough for the deciding write, not a full three-way formula merge.
Passphrase strength is an honest partial defense. checkPassphrase's header explicitly disclaims completeness: it raises the floor against offline GPU cracking (escrow ships inside every encrypted file) but does not make a determined attack infeasible. No new dependency (no zxcvbn) — deliberately cheap heuristics only.
MVP sync precision is 2–3 seconds (per CLAUDE.md) — CellChangeEvent.t is documented as "2–3s precision is fine." Do not build downstream logic (this package or any consumer) that assumes millisecond-accurate ordering beyond what the sort-by-t contract already guarantees.