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.
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
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).
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/viewer
format → viewer
ESM import
Reads 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/team
format → team
ESM import
Org/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-sheets
format → addon-sheets
ESM import, bundled into one dist/sidebar.html
Google Sheets sidebar's poll-diff engine emits the same CellChangeEvent shape and calls writeRewind to produce a cross-platform-compatible .rewind.
scripts/smoke.mts
branding → smoke
ESM import + string match over deployed HTML/JS
Post-deploy check that the live add-in/viewer bundle contains the current BUILD_TAG — catches a stale Cloudflare Pages deploy.
scripts/rewind_writer.py
format ↔ Python (subprocess)
vitest spawns python scripts/rewind_writer.py <out>, then readRewind() on the result
Cross-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.
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
Export
What it does
Why it matters
CellChangeEvent
Core 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 = 1
The 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.
Synthetic 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
Export
What it does
Why it matters
packEntry / unpackEntry
value -> 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 onlymeta.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.
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.
AES-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 / rsaUnwrapKey
RSA-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.
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.
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.
"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.
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.
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
Export
Value / role
Why 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_TAMPERED
User-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_DISCLAIMER
Non-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.
Hand-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.