Arc Forge Labs Docs v2026.10.53

Data spine

Status: phase 1 is built: the four tables and the core code that keeps their rules (server/core/links.js, queue.js, outbox.js, review.js). No integration uses them yet. Issue #56 tracks the rest.

The data spine is the set of tables and jobs that every integration uses. Records come in from an integration, get a Flow identity, and go out from Flow's own records. Each part works the same way for every adapter, so a new integration adds an adapter and no new plumbing.

flowchart LR
  api[Integration API] -- poll or webhook --> inbox[Inbox]
  inbox --> mirror[Mirror<br/>adapter tables]
  mirror --> idmap[Identity map]
  idmap --> core[Core records]
  core -- intended write --> outbox[Outbox]
  outbox -- adapter, through its governor --> api
  core -- conflict --> review[Review queue]
  mirror -- conflict --> review
  review -. recheck .-> mirror
  queue[Job queue] -. runs .-> inbox & mirror & idmap & outbox

What exists today

Part Today
Mirror Seazona: orders, invoices, clients, products, users. Xero: contacts and items only.
Identity map None. A Flow customer finds its accounting contact by an exact name match (matchContact).
Core Contacts, price items, Flow invoices and their sent versions, the billing obligation ledger.
Outbox xero_invoice_pushes (drafts) and obligation_writebacks (paid status). Nothing sends a write-back.
Review queue States on ledger and push rows (review_required, ambiguous, mismatch). No single list.
Job queue None. Each step is a route a person calls. The Seazona scheduler is the one background loop.

Phase 1: the tables

Phase 1 adds four core tables (migration 0005_data_spine) and the core code that keeps their rules. It changes no behaviour: no integration uses them until phase 3 (#58). All are in server/db/schema.js, so every installation creates them at start (#40).

Column Type Meaning
id uuid
organisation_id uuid
entity text contact, price_item, obligation, invoice, payment, credit_note
flow_id uuid The Flow record
system text The adapter id (xero, seazona)
external_id text The id in that system
matched_by text created_by_flow, exact_id, imported, name, person
state text active, proposed, superseded
confirmed_by, confirmed_at uuid, timestamptz The person who confirmed a link that was not exact
created_at, updated_at timestamptz
  • One active link per (organisation_id, system, entity, external_id) and per (organisation_id, system, entity, flow_id): two partial unique indexes WHERE state = 'active'.
  • A link with matched_by = 'name' is proposed until a person confirms it. A proposed link opens a review item.
  • A link is never deleted. A replaced link is superseded, so history stays.

background_jobs (job queue, #6)

Column Type Meaning
id uuid
organisation_id uuid
kind text For example seazona.feed, xero.payments, outbox.send
dedupe_key text One queued or running job per (organisation_id, kind, dedupe_key)
payload jsonb
state text queued, running, succeeded, failed, dead
run_after timestamptz Not before this time
attempts, max_attempts integer
locked_by, locked_until text, timestamptz The worker and its lease
result jsonb What the last successful run reported
last_error text Never a credential or a raw response
created_at, updated_at timestamptz
  • A worker claims with SELECT … FOR UPDATE SKIP LOCKED ordered by run_after, and sets a lease. A lease that runs out makes the job claimable again, so a dead process never holds a job.
  • A failed attempt waits longer each time (1 minute, doubling, at most 6 hours). After max_attempts the job is dead and opens a review item.
  • One scheduler row per recurring kind puts the next job in the queue when the last one ends. This replaces per-adapter loops over time; the Seazona scheduler moves onto it in phase 3.
  • No new service: the queue is PostgreSQL only (VISION.md).

outbox (#56)

Column Type Meaning
id uuid
organisation_id uuid
system text The adapter that sends it
action text For example invoice.draft, payment.create
entity, flow_id text, uuid The Flow record the write is about
idempotency_key text Unique. Stored before the call leaves.
request jsonb The exact request
state text pending, sending, ambiguous, sent, failed, cancelled
external_id text The id the system gave the new record
attempts, last_error, sent_at
created_by text Who or what asked for the write
  • A core action writes its outbox row in the same transaction as the core change, so a write is never lost and never made without its record.
  • The adapter sends it through its governor, reads the result back, and records an external_links row with matched_by = 'created_by_flow'.
  • An unknown outcome is ambiguous. The adapter settles it by a lookup before it sends again, as Xero draft pushes do today.
  • xero_invoice_pushes and obligation_writebacks stay. In phase 4 each new write goes through outbox, and those tables keep their evidence role.

review_items (review queue, #59)

Column Type Meaning
id uuid
organisation_id uuid
kind text For example link.proposed, price.difference, invoice.duplicate, job.dead
entity, flow_id text, uuid
systems text[] The systems involved
evidence jsonb Both sides, the field that differs, how Flow found it
money_at_risk_cents bigint Sort order
state text open, resolved, dismissed
seen_count integer How many times the finding was made
resolution, resolved_by, resolved_at, note A closed item must have the first three
  • One open item per (organisation_id, kind, entity, flow_id): a partial unique index. A repeat finding updates the evidence.
  • A review item only alerts. It shows the difference (for example the Seazona price and the Xero price) and never writes to any system. The person fixes it where they choose: in Xero, Seazona, Flow, or any of them. Mark resolved rechecks that one finding: if the difference is gone the item closes; if not, it stays open with the new evidence.

What the code guarantees

Rule Where
Two callers that link the same record at once make one link; the other gets conflict linkRecord, an advisory lock per side, and the partial unique indexes
A name match is never active without a person linkRecord, and the check external_links_name_needs_person
A worker whose lease ran out cannot finish the job another worker took over completeJob and failJob match the worker and the attempt
A job that uses every attempt, or fails with retry: false, is dead and opens a review item failJob, claimJobs
A write rolls back with the Flow change it belongs to queueWrite takes the caller's transaction
A send that did not finish is ambiguous, never sent again blind claimWrites
A sent write links the Flow record to the new external record markSent
A write moves only from the states its step allows move in outbox.js

Tests: test/data-spine.test.js, against PostgreSQL, synthetic data.

prior_links holds the earlier app's Seazona order to Xero invoice links (server/import/at-start.js). Each start adds the ones Flow does not hold, while FLOW_IMPORT_EARLIER_SCHEMA is set. A link a person made there is person; one the earlier app matched by itself is imported. When the pipeline (#58) first makes the job's obligation, it reads the link with priorLinkFor and checks it against the Xero mirror before it counts as evidence. So history links cost no Xero calls.

Organisation on adapter tables (phase 2)

The Seazona tables are installation-wide today. Phase 2 adds organisation_id to each, filled from the installation's one organisation, so the identity map can join them. An adapter migration adds the column; no row is dropped.

Who owns each field

Each fact about a billed job has one owner. Flow compares only the work fields when a job changes, and never writes to another system to settle a difference. The table is FIELD_OWNERS in server/core/ledger.js.

Field Owner Flow's part
Lines (codes, quantities, amounts) Work system Copies them; a person can take new ones into Flow's own draft
Customer (contactId) Work system Resolves the Flow contact from the work system's client
Job status Work system Reads it; a status that moves back holds the job
Invoice text (Reference), due date Flow Owns them
Send Flow Sends Flow's draft to the accounting system
Approval, payment, void Accounting system Reads them back

When a job changes after its invoice

When Flow drafts an invoice it keeps the job facts it used on the obligation (draft_basis: lines, totals, customer, status, time of the report). This is the merge base. When the work system reports a change later, Flow compares three sides, field by field (compareThreeWays):

flowchart LR
  base[Basis<br/>facts at draft] --> cmp{Compare<br/>each work field}
  work[Work system now] --> cmp
  draft[Flow draft now] --> cmp
  cmp -- work system only, draft not sent --> offer[Review + alert<br/>Adopt changes]
  cmp -- both, or invoice sent --> conflict[Review + alert only]
  cmp -- Flow only, or back to basis --> none[Nothing]
Case Result
Only the work system changed, draft not sent Review hold, a job.changed alert. Adopt changes (POST /api/invoicing/jobs/:id/use-new-lines) puts the job's lines (and customer) on Flow's own draft, keeps them as the new basis, and closes the alert.
Both changed differently, or the invoice left Flow (issued, queued, sent, or in the accounting system) Review hold, a job.changed alert only. Adopt changes is refused.
Only Flow changed, or the job changed back to the basis Nothing. A hold from an earlier change is released.

A job whose status moves back from a billable status (observeWithdrawn):

The job Result
Has an invoice Review hold, a job.status_back alert
Is ready, with no invoice The ready mark is cleared and a job.status_back alert opens. It closes when the job is billable again.
Is neither The status is stored

The status is stored, so the same status reported again does nothing, and a hold a person released stays released. An invoice drafted before the basis was kept uses the last report as its basis.

Rules

  • Every table above is append-friendly: a row changes state, and is never deleted by the spine.
  • Every write to another system has an outbox row first.
  • A sync cursor moves only after the page it read is stored.
  • Each job kind has a share of an integration's daily call budget (#45). Low lanes wait first; outbox sends and people's actions wait last.
  • No silent overwrite. When two systems disagree, Flow opens a review item.

Later phases

Phase What Issues
2 Xero mirror of invoices, credit notes and payments with history, imported as a job when Xero is connected (built: docs/XERO.md, "The mirror"); links from an earlier installation imported at start (built: prior_links) #54, #45
3 The pipeline on the job queue, with no writes. Customers, jobs and the matcher are built (docs/JOB_INVOICE_LINKS.md), and drafts by rule (docs/INVOICING_RULES.md) #58
4 Writes through the outbox, each behind its own switch #39
5 Screens: review queue, accounting usage, price differences, duplicates #2, #46, #47