Migration specification
Localized copy for the build. Source of truth: ezacto
knowledge/docs/. Synced 2026-08-27 — if editing, edit the brain copy and re-sync.
title: ezacto-migrate — Harvest Extraction & Migration Specification type: spec status: draft version: 0.1.0 date: 2026-08-25 sources:
- knowledge/research/harvest-api-v2.md
- knowledge/docs/domain-model.md
- knowledge/research/harvest-market.md
ezacto-migrate
The wedge (D7) and the acceptance test for the domain model (PLANNING-PLAN §5). Runs first against CONFLICT's own account — the OSS gate — then, unchanged, for anyone leaving Harvest.
Definition of done for a migration: the reconciliation report (§6) shows zero unexplained deltas, and every explained delta is one of the documented gaps in §7. "It imported without errors" is not done.
Product shape: a standalone CLI (published as ezacto-migrate, built from the oss
repo's packages/migrate), zero dependency on the rest of
ezacto at extract time. Extract works even if you never install ezacto — that is the
point of the wedge: get your data out first, decide later.
ezacto-migrate auth # PAT + account discovery
ezacto-migrate extract # Harvest → snapshot dir (resumable)
ezacto-migrate verify # snapshot internal consistency + reports checksums
ezacto-migrate load # snapshot → ezacto org DB (D1 or SQLite file)
ezacto-migrate reconcile # ezacto DB ↔ snapshot ↔ Harvest reports
ezacto-migrate sync # incremental updated_since re-extract + upsert (parallel-run)
1. Auth & preflight
PAT only (no OAuth app needed for a one-shot tool). Headers on every call:
Authorization: Bearer, Harvest-Account-Id, User-Agent: ezacto-migrate (email) —
the missing User-Agent 400 is the first support ticket otherwise.
Preflight sequence:
GET id.getharvest.com/api/v2/accounts— enumerate accounts, pick, resolveHarvest-Account-Id(never ask the user to find it in the UI).GET /v2/company— recordwants_timestamp_timers(which time-entry shape to expect),clock(how to parsestarted_time/ended_timestrings back to canonicalHH:MM), the four*_featureflags (which trees will be absent), and display settings (imported intoorganization).GET /v2/users/me— confirm the token's user is an administrator; warn loudly if not (a member-scoped extract silently sees a fraction of the account).
2. Extract
2.1 Order (FK-safe, from research §15.3)
1 company 7 projects
2 users → per-user billable_rates, 8 task_assignments (account-wide sweep)
cost_rates, teammates 9 user_assignments (account-wide sweep,
3 roles is_active=true AND =false)
4 clients → contacts 10 estimates → messages
5 tasks 11 invoices → messages, payments
6 expense_categories, 12 time_entries (windowed from/to)
invoice_item_categories, 13 expenses (windowed) → receipt binaries
estimate_item_categories 14 reports checksums (reports budget)
Account-wide assignment sweeps (8, 9), not per-project loops. Explicitly sweep
is_active=false too — users/{id}/project_assignments only returns active.
2.2 Rate budget
General endpoints: 100 req / 15 s; reports: 100 req / 15 min. Steps 1–13 use
only the general budget; step 14 is the sole reports consumer. Client behavior:
token bucket at ~6 req/s sustained, honor Retry-After on 429, exponential backoff
on 5xx. Cost model for a mid-sized account (tens of invoices, ~140 requests): the
per-invoice message+payment loops dominate at 2 calls each — minutes, not hours.
Print a call-count estimate
before starting.
2.3 Snapshot format (the load-bearing artifact)
A directory, not a database — inspectable, diffable, committable if the user wants:
snapshot/
├── manifest.json # account id, company name, started/finished_at,
│ # tool version, per-resource counts + page counts,
│ # updated_since watermark per resource
├── raw/<resource>.jsonl # one Harvest object per line, verbatim, unmodified
├── receipts/<expense_id>.<ext> # Harvest vocabulary; loads as an expense attachment
└── checksums.json # step-14 report aggregates (§6)
Raw means raw. No transformation at extract time — transform bugs must be fixable
by re-running load without re-extracting (the extract is the expensive, rate-limited
step; the transform is free). Pagination follows the response links verbatim (doc
mandate), per_page=2000, cursor mode.
2.4 Resumability
After every page: append objects, fsync, update manifest.json progress. A crash or
429 storm resumes mid-resource from the last cursor. extract re-run on a complete
snapshot becomes an incremental pass using per-resource updated_since watermarks.
Deletes are invisible to updated_since (no webhooks, research §13) — sync
therefore also does periodic full-ID sweeps per resource and marks vanished ids as
deleted-upstream in the manifest.
3. Transform & load
Consumes raw/, produces an ezacto org DB. Pure function of the snapshot: same
snapshot in, byte-identical DB out (ordering fixed, timestamps carried from source).
Key mappings (details in domain-model §7):
| Harvest | ezacto | Rule |
|---|---|---|
| ids | harvest_id columns |
Native ids assigned fresh; every table keeps the Harvest id, unique-indexed — this is what makes sync upserts and the shim's id echo possible. |
| decimal hours | seconds |
×3600, round half-even, record residue in load report if any. |
entry rounded_hours |
rounded_seconds |
Convert the imported value to seconds and store it verbatim; never apply native rounding during load. |
money amounts (totals, amount, payments) |
cents | ×100 exact; fail loudly on >2 decimal places, never round silently. |
per-unit rates (unit_price) |
cents | ×100, round half-even, record residue in load report if any. A Harvest mileage category at 0.485/mile is the IRS half-cent rate, not bad data; the line amount stays exact, so a rounded rate never moves a total. |
| invoice tax/discount percentages | *_rate_ppm |
Parse decimal percentage text exactly into parts per million of one (7.25% = 72_500); fail beyond four fractional percentage digits, never pass through REAL. |
started_time "8:00am" |
HH:MM |
Parse per snapshot company.clock. |
access_roles array |
profile + manager_grants |
Per domain-model §2.2. |
is_billable+is_fixed_fee |
billing_method |
Truth table; conflicting combos (billable=false, fixed_fee=true) recorded as anomalies, imported as non_billable. |
| billable/cost rate rows | append-only tables | Verify Harvest's derived end_date chain matches ours (invariant 8); mismatch = anomaly, ours wins. |
entry billable_rate/cost_rate |
snapshot columns | Copied verbatim from Harvest, never re-resolved — Harvest's historical resolution is truth for imported rows. |
invoice/estimate creator {id,name} |
nullable real creator-user FK + immutable source creator id/name provenance | Resolve the FK when the imported user exists; never discard source attribution or create a bare/mandatory placeholder when it does not. |
| invoice/estimate message sender scalars | immutable sent_by, sent_by_email, sent_from, sent_from_email snapshots |
Copy verbatim; a later nullable sender-identity relation is enrichment, not historical truth. |
invoice derived totals + payment_options + updated_at |
native D21 totals + importer-owned source_* observation |
Copy source amount/due/tax/tax2/discount cents, option array, and canonical source timestamp verbatim, then compute native totals by D21 so any undocumented rounding/allocation delta remains explainable. Refresh the observation atomically only when source updated_at advances; equal/older sync input is a no-op. Never guess what Harvest ach means natively. |
| payment recorder/gateway/transaction | manual/manual payment + immutable source provenance |
Resolve a nullable real recording-user FK when possible; retain recorder name/email and gateway id/name verbatim. Preserve the source transaction id without guessing a native provider from the gateway name. |
payment response paid_at + paid_date |
canonical XOR + immutable source_paid_at/source_paid_date |
Copy both response fields. Prefer paid_at canonically when both are present; compare its UTC date to paid_date, recording an anomaly on disagreement without discarding either source value. The shim re-emits the preserved pair. |
invoice state + timestamps |
same | States imported as-is; state machine governs post-import mutations only. |
3.1 D1 write constraints (from D3 — designed in, not discovered)
- ≤100 bound parameters per statement ⇒ batch inserts at
floor(100 / column_count)rows per statement. - ≤1000 statements per Worker invocation ⇒ when loading to hosted D1,
loadruns as a queued, checkpointed job consuming the snapshot from R2; local SQLite loads have no such ceiling and are the default dev path. - 30 s query duration ⇒ no mega-transactions; chunk per-resource with a
load-progress table so a resumed load is idempotent. Invoice, estimate, and their
message imports must never use
INSERT OR REPLACEorON CONFLICT DO UPDATE: their identity-collision guards intentionally reject both shortcuts because replacement can delete children, rotate a nativeclient_key, or bypass immutable provenance. In one container transaction or D1 batch, issue anUPDATE ... WHERE harvest_id = ?followed byINSERT ... SELECT ... WHERE NOT EXISTS (...). Omitclient_keyso SQLite generates a new native bearer secret, and include creator/sender provenance in the update so any historical drift fails loudly rather than being hidden. Invoicesource_*total/options observations are the explicit exception to provenance immutability: the importer owns them and refreshes the whole set only from a strictly newer Harvestupdated_at. Native operations cannot write them. Payment/provider-account/bank-deposit identities added by D21 use the same pre-insert collision-guard rule, with regressions provingINSERT OR REPLACEpreserves the original rows, totals, children, and deposit match state.
4. Receipts
Download every receipt.url binary; store under content hash; verify
file_size matches. Failures are anomalies, not fatal (Harvest serves receipts
through time-limited URLs — re-extract refreshes them). On load, a Harvest receipt
becomes an expense-owned row in the shared attachment model; there is no separate
native receipt table or expense.receipt_id. Hash representation, logical dedupe,
and the exact field mapping are specified in
Attachment storage contract.
5. The sync loop (parallel-run)
Market research: switchers parallel-run for up to a year. So sync is not a
convenience — it is the adoption path. extract --since + upsert-by-harvest_id
- delete detection, run on a schedule. One-directional (Harvest → ezacto) forever; we never write back. Cutover is: stop syncing, start entering time in ezacto, keep Harvest read-only until the subscription lapses.
6. Reconciliation (the actual gate)
Three-way check, emitted as reconciliation-report.md + machine-readable JSON:
A. Snapshot ↔ Harvest reports (step 14, reports budget):
reports/time/{clients,projects,tasks,team}per fiscal year of account history: total_hours, billable_hours, billable_amount per row.reports/expenses/*same grains;reports/uninvoicedcurrent period;reports/project_budgetspent/remaining.- Rows are per-currency (research §12.1) — group accordingly or every multi-currency client is a false delta.
B. ezacto DB ↔ snapshot: row counts per resource; Σ seconds and Σ cents per (user, project, month) — invariant 14; invoice totals/due per invoice to the cent; retainer dangling-id count (expected, §7).
C. ezacto DB internal: domain-model §8 invariants run as checks against imported data (notably 4, 5, 8, 11).
Every delta is classified: rounding (bounded, explained), gap (§7, enumerated),
or UNEXPLAINED (fails the run). The report prints the three classes separately;
the gate is zero UNEXPLAINED.
7. Known, documented gaps (from research — decided handling)
| Gap | Handling |
|---|---|
Retainers: no API. Invoices reference dangling retainer.ids. |
Create money-denominated stub retainer rows from the distinct ids found on invoices; balances are unknowable via API. migrate finish-retainers prints a worksheet (client, linked invoices) for manual balance entry from the Harvest UI. Ledger opens with a cents-denominated manual adjustment entry. |
Recurring invoices: no API. recurring_invoice_id dangles. |
Same: stub rows + worksheet from the UI's 3 visible definitions (subject template, cadence, amount are all on screen — HRVST20/15). |
| Estimates/approval/activity-log modules disabled on the source account | Nothing to extract; extractor skips per company feature flags and says so. Invoices raised before the module was turned off can still carry an estimate.id (the source account had one); the link is dropped and recorded as an unresolved_estimate_reference anomaly rather than failing the import, since there is nothing to link to and nothing to recover. |
The one invoice that actually carried an estimate reference. Invoice 9076155 (ezacto id 556, number 742) pointed at estimate 1035594, which no snapshot contains: estimate_feature was false on the source account, so estimates, estimate_messages and estimate_item_categories all swept 0 rows, 0 pages. |
Loaded with estimate_id NULL and recorded as an unresolved_estimate_reference anomaly, per the row above; the reconciler cites it as migration-spec-7-estimates-module-disabled, so the run still passed at 0 unexplained. Named here because the source account is closed and the reference can never be resolved: of 741 invoices in production, estimate_id is NULL on all 741, and a future reader must not take that to mean no invoice was ever raised from an estimate. One was. |
| Report-only fields (utilization) | Derived, not stored — recomputed by ezacto; reconciled in A. |
| Forecast | Out of scope (research §14). |
statement_key / invoice and estimate client_key secrets |
Not imported. Regenerated — importing another system's public-URL bearer tokens imports its leak surface. Old Harvest links die at cutover; release note item. |
| Avatars | Best-effort download; failures cosmetic. |
| Negative time entries. Harvest corrects an over-logged timesheet with a negative entry offsetting an earlier one (the reference account had a handful, one of them recent and unlocked). | Imported. time_entries.seconds, seconds_without_timer and rounded_seconds were CHECK (… BETWEEN 0 AND …) and are now abs(…) <= limit, the same signed shape invoice_payments.amount_cents took. Skipping was never safe: Harvest nets these entries out and ezacto could not, so a month's total read above Harvest's by the whole of the skipped correction — an overpayment to a contractor on a live payroll run. Budgets and retainer balances keep their non-negative CHECK, because a budget really is a magnitude. Not a gap. |
| Non-positive invoice payments. Harvest records $0 payments settling $0 invoices, and negative payments settling credit notes (the reference account had several of the former and one of the latter). | Imported. invoice_payments.amount_cents was CHECK (… BETWEEN 1 AND …) and is now abs(...) <= limit, the same signed shape invoices.amount_cents has always had; bank_deposits keeps the strict rule, because a bank credit really is positive. Skipping was never safe for this class: invoice state is derived from imported payments (packages/db/src/internal/invoice-import.ts), so an invoice left with no payments landed open, and every settled invoice in the reference account flipped from paid. Not a gap. |
| Two Harvest accounts for one person. Harvest holds sixty users for fifty-eight people: two were created twice, once under a work address and once under a personal one. | The pairs are handed to the loader as an identity map and squashed: the duplicate's row is never inserted, its address joins the survivor as a secondary, and every reference to it is redirected. Rows the survivor can hold only one of — an assignment on a shared project, a rate starting on a shared date, a teammate link — merge into the one already there rather than aborting the load, each recorded as a duplicate_row_merged anomaly beside the duplicate_user_squashed one. Reconciliation cites those counts against its row counts, so sixty source users becoming fifty-eight is accounted for rather than read as loss. One collision is refused instead of merged: a timer running on each of the pair, since a person may hold only one and closing the other would need an ended_time Harvest never recorded. It is refused before the snapshot is admitted, so it costs no rows — stop one of the two timers in Harvest and extract again. |
Per-unit rates finer than a cent. Harvest accepts unit_price at arbitrary precision — mileage categories carry the IRS half-cent rate (0.485/mile), and Harvest's own computed lines can carry repeating decimals (67.1428571). |
Rounded half-even into cents and recorded as a rate_residue anomaly carrying the source literal, the same handling decimal hours get. The line amount is still parsed exactly by the money rule, so a rounded rate can never move an invoice total — reconciliation proves this rather than assuming it. |
8. Milestones
| # | Milestone | Proves |
|---|---|---|
| M1 | auth + extract complete against CONFLICT account, resumable, manifest counts match UI spot-checks |
API client + snapshot format |
| M2 | verify + checksums |
reports budget handling, per-currency grouping |
| M3 | load to local SQLite, invariants pass |
the domain model itself — this is the S3 gate |
| M4 | reconcile zero UNEXPLAINED |
end-to-end losslessness |
| M5 | load to hosted D1 via queue |
D1 constraint handling |
| M6 | sync steady-state for 2+ weeks |
parallel-run viability |
| M7 | retainer/recurring worksheets done, books balance | the manual-gap path |
M3 failing is a domain-model bug first — fix the model, then the loader.
The milestone numbers are not the running order
M6 is numbered before M7 and must not be run before it. Worksheet completions
are bound to the snapshot and context digests they were entered against, so a
later sync rewrites the manifest and voids every one of them — and the new
snapshot cannot be loaded into the existing database, so the worksheets have to
be redone from scratch in a virgin one. Reading this table as a sequence
prescribes exactly the redo it is meant to prevent (issue 288).
The running order is:
sync → verify → load → reconcile → worksheets → ship
No sync between the worksheets and shipping. M6 proves parallel-run
viability and belongs to the rehearsal; the final sync is the one that precedes
the load you actually ship. docs/cutover-runbook.md states the same rule at
the point of use.
If a late sync turns out to be unavoidable, keep the filled worksheet JSON — only the four header digests need regenerating, not the operator's transcribed data.