HALO Knowledge Docs
Generated knowledge spine · research/lanes/05-vertical-gaps.md

07 · Vertical gaps

Money representation, compliance and audit gaps for a creator agency.

Generated from research/lanes/05-vertical-gaps.md · regenerate with /opt/homebrew/opt/node@24/bin/node site/scripts/generate-docs.mjs

All verified. Writing the revised return with the money lane as #1.


Vertical Gaps: What a Creator Agency Structurally Needs That HALO CRM Has None Of

(Revised — money/ledger lane promoted to #1 and argued from the verified float evidence.)

Top 3 gaps, ranked by business risk

1. Money is float64 with no rounding discipline and no journal — the books cannot be proven correct, and are already drifting

Your five verified facts stand; my independent read of the repo matched them. Three things I can add from source that make the case worse than stated:

The FX multiplication is applied to an already-drifted subtraction. convex/referralInvoicing.ts:138Math.max(0, amount - paid) * conversionRate. That is float subtraction of two accumulated floats, then multiplied by a float rate. Float subtraction of near-equal values is where catastrophic cancellation lives: when an invoice is nearly settled, amount - paid can land on 1.8189894035458565e-12 instead of 0, and Math.max(0, ...) does not catch it because it is positive. A fully-paid invoice can display a non-zero outstanding balance in the payout currency. That is a user-visible bug waiting on the right pair of instalments, not a theoretical concern.

The drift is double-counted across two independent code paths. referrals.ts:534 accumulates current.creatorShare += gross * ((entry.percentage ?? 50) / 100) per creator per month, then :546 computes const computed = (gross * percent) / 100 on the summed gross. So the same commission is derived two ways — sum-of-parts and percentage-of-total — and float arithmetic guarantees these two numbers will not always agree. The app displays both. Nobody has an authoritative answer for which is right, because there is no ledger to arbitrate.

The default is silent. entry.percentage ?? 50 appears at invoicing.ts:34, :65, and :250. A creator whose percentage was never explicitly set is silently treated as 50/50 in the money calculation. That is a business-terms assumption buried in a null-coalesce, not a recorded contract term.

Why this ranks first: it is the only gap that silently corrupts the thing the business exists to do, and it is corrupting it today, unprompted, with no bad actor and no dispute required. Every other gap on this list needs something to go wrong first. This one is already wrong. And it compounds — as you noted, commission += over a month's payments, then × FX rate — so the error is drift across a reconciliation period, not one ULP. The agency will find out when a creator or referral partner runs their own numbers and gets a different answer, and at that moment there is nothing to point at.

2. The compliance lane for an adult-adjacent vertical is at absolute zero

Grep across src/ and convex/ for 2257, dmca, takedown, model release, kyc, id verif returns nothing structural. The agency sits under 18 U.S.C. §2257 recordkeeping obligations (identity + age documentation per performer, retained, indexed, producible), plus UK/EU GDPR for creator PII, plus model-release obligations for anything it distributes. contracts is the commercial agreement — not the statutory record. The Drive-backed content store knows taxonomy and thumbnails and knows nothing about who is depicted or what proves consent and age.

Second, not first, because it needs an external trigger. But it is the only gap here whose failure mode is the business ending rather than losing money — and it is the one an owner is least likely to build, because it ships no visible feature.

3. No audit trail on money, in a business where staff touch money records daily

Chatters enter salesTracker.earnings. Admins set adminConfirmed, confirmedCommissionRate, deductionAmount. Someone sets creatorInvoicing.netSales and percentage. updatedAt is one mutable timestamp: it says that a row changed, never what, from what, or by whom. RBAC governs who can act; nothing records who did. One careless or dishonest staff member is an unbounded, undetectable loss, and every creator dispute is the agency's word against theirs.


Lane 1 — Money representation, ledger, and the migration (the three questions)

(a) Correct representation: integer minor units, via dinero.js

RepoStarsSPDXLast pushVerdict
dinerojs/dinero.js6,793MIT2026-08-27Adopt. Immutable money in integer minor units, explicit currency, and allocate() — which distributes a total across ratios and hands out the remainder deterministically, guaranteeing the parts sum to the whole. That is precisely the split primitive this app computes wrong today.
MikeMcl/decimal.js7,249MIT2026-07-13Arbitrary-precision decimal. Correct, but general-purpose — no currency type, no allocation, no protection against adding USD to GBP.

Status change worth flagging: dinero v2 was in alpha for years and a lot of advice still says "don't use v2 yet." That is stale. v2.0.0 shipped stable 2026-03-02; v2.0.2 is current as of 2026-03-13. The functional v2 API is the one to build against.

The recommendation is integer minor units, not decimals. Reasoning: Convex stores v.number() as float64, so a decimal object cannot be persisted natively — you would serialise to string and re-parse on every read, and one forgotten parse reintroduces the bug. Integers ≤ 2^53 are represented exactly in float64. So netSalesCents: v.number() holding 800001 is exact in Convex's existing storage with no schema-type gymnastics. Decimal.js is only needed if percentages require more than 2dp of precision — and commissionPercent should itself become basis points (integer) rather than a float percent, which removes that need.

The rule to enforce: money is an integer count of minor units at rest and in transit; it becomes a float exactly once, at the point of rendering a string to a human, and never travels back.

(b) Is a full double-entry engine proportionate? No.

RepoStarsSPDXLast pushVerdict for this app
formancehq/ledger1,368MIT2026-08-28Best-designed for the problem — numscript expresses multi-party waterfall splits natively. But it is a 130MB Go service with its own Postgres, deployed and monitored separately. For one developer running Convex + Vercel, this doubles the operational surface to serve hundreds of transactions a week. Overkill.
tigerbeetle/tigerbeetle16,924Apache-2.02026-08-25Built for millions of tx/sec with formal verification. Superb engineering, wildly disproportionate. Overkill.
flash-oss/medici358MIT2026-07-29Right shape — in-process double-entry library, not a service. Hard-bound to Mongoose. Read as a reference implementation for the journal schema; do not adopt.
beancount/beancount5,949GPL-2.02026-08-23Accountant-facing plain text, not app-facing. Use as an export target so the bookkeeper works in real double-entry. (beancount/beanquery: 63 · GPL-2.0 · 2026-06-11.)
plaintextaccounting/hledger4,677GPL-3.02026-08-27Same category, same verdict.
firefly-iii/firefly-iii24,448AGPL-3.02026-08-29Personal finance, not multi-party revenue share. Wrong tool.
ledger/ledger6,020NOASSERTION2026-08-28CLI, same category as hledger.

The honest answer: fix representation + add an append-only journal table in Convex. The value of double-entry is not the software — it is the invariant (every entry has a balanced debit and credit; account balances are derived, never stored). You get 90% of that with one table:

ledgerEntries: {
  at, actorId, sourceType, sourceId,   // provenance
  debitAccount, creditAccount,          // e.g. "creator:<id>:payable"
  amountMinor: v.number(),              // integer, always positive
  currency: v.string(),
  fxRateMicros: v.optional(v.number()), // integer, if converted
  reversesEntryId: v.optional(...)      // corrections reverse, never delete
}

Balances become a query, not a stored float. Corrections reverse rather than mutate — which the app's author already reached for independently (paymentLogEntries.voidedAt, paymentLogConfirmations.deletedAt + deletedPayment, and the comment at schema.ts:808, "Corrections void rather than delete — a ledger keeps its history"). He has the right instinct already; it is applied to 2 tables out of 56. Generalising his own pattern is an easier sell than importing someone else's architecture.

Two verified Convex components make this cheap: get-convex/aggregate (31 · Apache-2.0 · 2026-08-28) computes sums over documents efficiently, so balance queries stay fast without a stored-balance cache to drift; get-convex/convex-helpers (489 · Apache-2.0 · 2026-08-27) ships customFunctions.ts for the mutation wrapper. Low star counts — these are first-party Convex components, not community projects; judge them on that, not popularity.

Escalate to Formance only if the waterfall genuinely outgrows this. It will not soon.

(c) The migration path — practical, one developer

There is no migration infrastructure in the repo today (ls convex/ | grep -i migrat is empty). Two verified options: get-convex/migrations (20 · Apache-2.0 · 2026-08-27), the first-party component for tracking stateful migrations, or migrations.ts inside convex-helpers. Use one; do not hand-roll.

The hard truth about migration: **Math.round(existingFloat * 100) is the only available conversion, and it is a decision, not a recovery.** The pre-drift true value is not recoverable from the stored float. Rounding to the nearest cent is almost certainly right for values entered by a human, and it freezes the drift rather than removing it. That is fine, and it should be stated openly rather than papered over.

The sequence, ordered so nothing is ever half-migrated:

  1. Add, don't replace. Introduce netSalesMinor, paidMinor, amountMinor, commissionBasisPoints alongside the existing float fields. Both populated. No reads change yet. Zero risk.
  2. Backfill with a tracked migration: Math.round(value * 100). Snapshot every before/after pair into a one-off migrationAudit table so the conversion itself is reviewable.
  3. Run both in parallel and diff. Add a scheduled Convex cron that recomputes each invoice/commission both ways and logs any disagreement above 1 minor unit. Let it run for one full reconciliation cycle. This is the step that proves the fix works — and it will surface exactly which historical rows are already wrong, which is information the business needs regardless.
  4. Flip reads, module by module, in dependency order: invoicing.tsreferralInvoicing.tsreferrals.tspayroll.tspaymentLog.ts. Each module's split arithmetic becomes dinero.allocate().
  5. Drop the float fields only after a clean cycle. Keep the migrationAudit table permanently.

Estimated honestly: step 1 is an afternoon; steps 2–3 a day; step 4 is the real work — roughly 5 call sites across 4 modules for the arithmetic, plus every display path that currently assumes a float. Call it a focused week, most of it in step 4, with step 3 running in the background and de-risking the whole thing. It is entirely doable solo, and it is strictly cheaper now than after another year of rows accumulate.

Do step 1 and 3 first even if steps 4-5 get deferred. The parallel-diff cron alone converts an invisible problem into a measured one.


Lane 2 — Compliance, ID, provenance, leak detection

RepoStarsSPDXLast pushAddsVerdict
JohannesBuchner/imagehash3,867BSD-2-Clause2026-08-26pHash/dHash/aHashStart here. Hash every asset at ingest, store the hash. Without that index you cannot even retroactively prove a leaked image was yours.
facebook/ThreatExchange1,377NOASSERTION ("Other")2026-08-22PDQ perceptual hash + matching infraThe production-grade hash. Verify the licence file directly before shipping.
idealo/imagededup5,667Apache-2.02025-08-15Batch near-duplicate searchGood for batch "scan 400 leak-site images against our library". No push in ~12 months — stable, not actively maintained.
tesseract-ocr/tesseract76,239Apache-2.02026-08-25OCRBuilding block for reading DOB/expiry off an ID. Not verification.
mindee/doctr6,323Apache-2.02026-08-28Deep-learning document OCRBetter on structured docs. Still only reads — cannot tell you an ID is genuine.
serengil/deepface23,349MIT2026-08-24Face matchingCare: biometrics are GDPR Art. 9 special-category data. Building this without a DPIA creates a new compliance problem while solving another.

Honest finding — two empty lanes. I searched GitHub for DMCA-takedown management and for KYC/ID-verification systems. Both came back genuinely empty: top hits were sub-5-star toys and, in one case, a repo containing nothing but a pasted copy of NiceHash's terms of service. There is no credible OSS for DMCA takedown workflow, and none for real identity verification. That is a finding, not a search failure.

So: ID verification is buy (Persona, Veriff, Sumsub, Yoti — they own the document-fraud models and the liability). DMCA is buy the sending (Rulta, BranditScan, Ceartas — built for this exact vertical) and build the case tracking in-house, because that part is ~4 Convex tables and a status machine, not an algorithm. The only genuinely valuable OSS here is the perceptual hashing that finds leaks in the first place.

Lane 3 — Audit trail

Verified and rejected: openfga/openfga (5,673 · Apache-2.0 · 2026-08-28) and apache/casbin (20,358 · Apache-2.0 · 2026-08-21) do authorization, which HALO already has. pgaudit/pgaudit (1,696 · NOASSERTION · 2026-07-30) needs Postgres, which does not exist here. hyperledger/fabric (16,706 · Apache-2.0 · 2026-08-28) is a blockchain — categorically wrong.

Verdict: not an OSS problem. Convex has no temporal tables and no CDC hook. The answer is ~40 lines: one auditLog table (table, docId, actorId, at, field, before, after, mutationName) plus a withAudit() wrapper built on customFunctions.ts from convex-helpers that diffs the doc before/after and appends. Highest leverage build-not-buy item in this document — a day of work closes gap #3 outright. It also shares the provenance columns with the ledgerEntries table from Lane 1, so building them together is cheaper than either alone.

Lane 4 — Communications

chatwoot/chatwoot (36,285 · NOASSERTION, MIT core + enterprise dir · 2026-08-29) is the serious omnichannel inbox. novuhq/novu (39,680 · NOASSERTION · 2026-08-28) — Convex's scheduler already covers most of this need; low marginal value. knadh/listmonk (23,163 · AGPL-3.0 · 2026-08-25) is bulk newsletter, wrong shape for 1:1 creator comms. baptisteArno/typebot.io (10,297 · NOASSERTION · 2026-08-24) — intake already exists. Real but not urgent; nobody loses the business over a fragmented inbox.

Lane 5 — Workforce / 24-7 shifts

TimefoldAI/timefold-solver (1,770 · Apache-2.0 · 2026-08-28) is the only credible constraint engine (OptaPlanner's successor) and is a JVM service — disproportionate for a React/Convex shop. kimai/kimai (4,950 · AGPL-3.0 · 2026-08-25) and solidtime-io/solidtime (8,886 · AGPL-3.0 · 2026-08-28) both overlap HALO's existing attendance. calcom/cal.diy (47,993 · MIT · 2026-08-08) — note the repo was renamed from calcom/cal.com; flag before anyone hardcodes the old path.

Generic search for shift-scheduling/rostering returned nothing above 48 stars. Thin lane. Chatter coverage is a Convex table plus a gap query, not a solver.

Lane 6 — Business observability

evidence-dev/evidence (6,892 · MIT · 2026-08-26), metabase/metabase (48,982 · NOASSERTION · 2026-08-29), cube-js/cube (20,733 · NOASSERTION · 2026-08-29), lightdash/lightdash (6,102 · NOASSERTION · 2026-08-28), PostHog/posthog (39,441 · NOASSERTION · 2026-08-29), plausible/analytics (28,788 · AGPL-3.0 · 2026-08-27).

Structural blocker: every one expects a SQL warehouse, and HALO has none. Convex is a document store; there is no Postgres to point Metabase at, no dbt layer for Lightdash. The tool choice is the easy part; the Convex→warehouse export is the work. If pursued: export on a cron via dlt-hub/dlt (5,795 · Apache-2.0 · 2026-08-28 — right size, clean licence; meltano/meltano 2,613 · MIT · 2026-08-28 as alternative; airbytehq/airbyte 21,970 · NOASSERTION · 2026-08-29 is over-scaled for one source), then Evidence (only clean licence, Git-native) or Metabase for non-technical self-serve.


Three categories nobody named

A. Key-person and access continuity. socialMediaLogins and secureAreaPasswords store credentials. Nothing models what happens when the chatter holding a creator's platform login leaves: no rotation schedule, no offboarding-triggered revocation, no break-glass, no record of which departed staff still know which passwords. For an agency whose entire asset is access to accounts it does not own, this is live risk today. Infisical/infisical (29,010 · NOASSERTION · 2026-08-29) or bitwarden/server (19,950 · NOASSERTION · 2026-08-28) — both properly-engineered secret managers with rotation and access audit; either beats a bespoke vault table.

B. Contract lifecycle and exclusivity conflict detection. contracts stores documents. Nothing reasons about them: no expiry alerting, no auto-renewal window, no notice-period countdown (a NOTICE_PERIOD_DAYS constant exists at invoicing.ts:7 — the concept exists in exactly one code path), no exclusivity-conflict check when signing a creator already contracted elsewhere, no rate-change effective dates. A lapsed exclusivity term loses the creator and the revenue stream permanently. No OSS worth naming — this is schema plus the Convex crons the app already runs.

C. Disaster recovery that has actually been tested. Everything lives in one Convex deployment plus one Google Drive. No evidence of off-platform backup and — the part that matters — no evidence of a restore drill. get-convex/convex-backend (12,446 · NOASSERTION · 2026-08-29) being open source means self-hosted restore is genuinely possible. The gap is procedural: an untested backup is a rumour. A quarterly export-and-restore-to-scratch drill costs an afternoon.


Buy the SaaS, do not self-host

  • ID/age verification — Persona, Veriff, Sumsub, Yoti. OSS does not exist at credible quality; the search came back empty.
  • DMCA takedown execution — Rulta, BranditScan, Ceartas. Vertical-specific, already handle notice submission to hundreds of hosts.
  • VAT / cross-border withholding / 1099 / self-billing — Avalara, Stripe Tax, Fonoa. Rules change per jurisdiction per year; this is a subscription-to-correctness, never a repo.
  • Error observabilitygetsentry/sentry (44,644 · NOASSERTION · 2026-08-29) is excellent; self-hosting it for a small agency is negative value.

Not a software problem

  • §2257 recordkeeping is a records-custodian designation, a retention policy, and filing discipline first. Software stores records; a lawyer says which and for how long. Building schema before that advice builds the wrong schema.
  • Chatter QA sampling is a rubric and a weekly review habit. Any tool is downstream of deciding what "good" looks like.
  • Platform payout reconciliation — the hard part is that platforms offer no APIs and CSV formats change unannounced. The fix is a monthly ritual with a named owner, backed by a ledger that makes discrepancies visible (Lane 1). No ETL tool solves an absent data source.
  • GDPR subject access / erasure — the blocker is knowing where creator PII lives across 56 tables plus Drive. A data-map on a whiteboard precedes any delete function.

If only three things get done: (1) integer minor units + dinero.allocate(), with the parallel-diff cron from migration step 3 running first — that step alone converts invisible corruption into a measured number; (2) auditLog + withAudit() on convex-helpers customFunctions — one day, closes insider-risk and every future dispute, and shares provenance columns with the ledger table so build them together; (3) perceptual-hash every asset at ingest with imagehash — an afternoon, and it is the prerequisite that makes all future leak detection possible instead of impossible.

Files referenced (all read-only, nothing modified): /Users/shaansisodia/SISO_Workspace/SISO_Agency/clients/halocrm/repo/convex/schema.ts, .../convex/invoicing.ts (lines 7, 34, 65, 250), .../convex/referrals.ts (lines 94, 270, 534, 546), .../convex/referralInvoicing.ts (lines 90, 116, 127, 138), .../convex/paymentLog.ts (line 192), .../package.json.

Canonical source remains research/lanes/05-vertical-gaps.md. This HTML is a generated projection; edit the source, then run generate-docs.mjs.