Order-Automation Integration Playbook — Design
Status: APPROVED as debt-consolidation; PR1 (auth/encryption forks) shipped via #1733. PR2 (impl move + factory dedup) and PR3 (contract test) deferred. Branch: claude/modest-swirles-88f861 PR base: staging
Reader note: the original “Proposal” and “Phasing” sections below describe the doc’s ORIGINAL framing (self-host Nango first, new manifest, fast-onboarding claim). All three were CUT at the /autoplan review gate — see “GSTACK REVIEW REPORT” → “REVIEW RESOLUTION” near the end of this file for the approved scope, and “PR1 EXECUTION LOG” for what actually shipped vs what the review predicted. The proposal sections are kept for historical context, not as current direction.
Problem
Section titled “Problem”Adding or operating an order-automation ERP connector is not a repeatable process.
The write path (IERPService) lives inside apps/ts-temporal-worker where only
Temporal can reach it; the read path lives in apps/dagster; credentials resolve
per-ERP through erp_auth.py (Python) and ERPAuthService (TS) against Infisical.
There is no single contract, no single credential layer, and no checklist. Every new
ERP re-derives the wiring, and the same write logic can’t be reused by ops-mcp or the
webapp without copy-paste.
Make order automation a repeatable playbook across the connectors we already have: one action contract, one credential layer, one checklist. Adding an ERP becomes “implement 3 reads + 1 write, register creds in Nango, fill the manifest.”
Non-goals (explicitly out of scope):
- AR/AP, accounting, invoicing, ecommerce connectors (deferred platform expansion).
- Replacing Dagster/DLT (the read runtime) or Temporal (the orchestration runtime).
- Nango’s syncs/actions runtime (enterprise-only on self-host; no templates for our niche ERPs anyway).
- The customer-data-workbench (separate, deferred design).
The order-automation surface (the entire contract)
Section titled “The order-automation surface (the entire contract)”Every order-automation ERP needs exactly this — nothing more for v1:
| Half | Operations | Runtime | Destination |
|---|---|---|---|
| Read / Sync | customers, products, shipping_addresses |
Dagster + DLT (Python) | R2 Iceberg → Typesense |
| Write / Action | createOrder (+ existing approveQuote, updateOrder, filterInvalidItems) |
IERPService (TS) |
the ERP API |
| Auth | credential storage + proxy | Nango (self-hosted) | — |
In production only createOrder hits the ERP directly; all reads are served from
Typesense (faster, more configurable). The playbook formalizes that split.
Proposal
Section titled “Proposal”1. Promote the write contract into a shared package (DRY)
Section titled “1. Promote the write contract into a shared package (DRY)”getERPService() + the per-ERP IERPService implementations move out of
apps/ts-temporal-worker/src/services/ into packages/integrations (where the
IERPService interface already lives). Then every surface is a thin caller:
- Temporal
createOrderActivity→getERPService(conn).createOrder(input) - ops-mcp order tool → same
- webapp API route → same
The package is the home of the logic; MCP/Temporal/webapp are transports. No surface owns write logic.
2. One credential layer: Nango self-hosted (Auth + Proxy)
Section titled “2. One credential layer: Nango self-hosted (Auth + Proxy)”Self-host Nango on the existing GCP footprint. Free tier = Auth + Proxy only (Functions/syncs/actions runtime is enterprise — we don’t use it; no templates exist for our niche ERPs regardless). Nango becomes:
- The single credential vault for every order-automation connection (replaces
per-ERP Infisical paths +
erp_auth.py+ERPAuthService). - An authenticated proxy for outbound ERP HTTP — connector code never handles secrets (satisfies the “never handle secrets” rule structurally).
- Later (not v1): the embeddable Connect UI for customer self-serve onboarding.
Self-host components: Postgres (credential store), Redis (cache), Server (Node),
Runner (Node — deployed but idle, no Functions). Elasticsearch optional → skip.
NANGO_ENCRYPTION_KEY (base64 32-byte) in 1Password, sourced via op. No key
rotation support — escrow deliberately. The Nango Postgres becomes the single source
of truth for credentials → backups + blast-radius treatment.
Proxy is HTTP-only. Eclipse E4 reads are MSSQL via pyodbc, not HTTP — Nango is the
vault for Eclipse creds (getConnection() → feed pyodbc) but not the transport.
HTTP ERPs (P21, WhereFour, Business Edge, NetSuite) use full proxy.
3. The playbook (the deliverable)
Section titled “3. The playbook (the deliverable)”To add an order-automation ERP:
- Register the ERP in the connector manifest (auth mode, capabilities, Nango ref).
- Implement 3 Dagster sync methods:
sync_customers,sync_products,sync_shipping_addresses(the existing WhereFour orchestrator is the reference). - Implement
IERPService(createOrderat minimum) inpackages/integrations. - Register credentials in Nango.
- Fill the connector manifest entry; CI verifies the 4 above exist.
Conformance scorecard (what we already have)
Section titled “Conformance scorecard (what we already have)”| ERP | reads (3) | createOrder | creds in Nango | gaps |
|---|---|---|---|---|
| Prophet21 | ✓ | ✓ | migrate from Infisical | OData v3/v4 complexity (existing) |
| WhereFour | ✓ | ✓ | currently Nango-managed | reference impl |
| Eclipse E4 | partial | ✓ (SOAP) | vault-only (MSSQL not proxied) | reads are pyodbc |
| Business Edge | planned | planned | DevKey via proxy | net-new (see leverage-catalog) |
| NetSuite | partial | target | OAuth via Nango | OAuth lifecycle = Nango’s strong case |
Phasing
Section titled “Phasing”- Nango self-host (Auth+Proxy) for all connections — pure consolidation, no behavior change. Kills per-ERP credential code.
- Promote
getERPService+ impls topackages/integrations— rewire Temporal, ops-mcp, webapp as thin callers. - Write the manifest + CI conformance check — the playbook becomes enforceable.
- Backfill each existing connector to conformance as it’s next touched.
Stack facts
Section titled “Stack facts”Monorepo (pnpm). IERPService interface in packages/integrations/src/services/.
Factory currently in apps/ts-temporal-worker/src/services/erp-service-factory.ts.
Dagster sync orchestrators in apps/dagster/erp_pipeline/implementations/. Reads →
R2 Iceberg → Typesense. Nango self-hosted on GCP. PR base = staging.
GSTACK REVIEW REPORT
Section titled “GSTACK REVIEW REPORT”/autoplan — CEO + Eng + DX (Design skipped: no UI). Dual voices: codex + Claude subagent each phase. DX emphasized.
Factual corrections (the doc was wrong)
Section titled “Factual corrections (the doc was wrong)”- Impls live in
apps/webapp/src/services/erp/implementations/, NOT ts-temporal-worker. Temporal imports them via a#webapp/*esbuild alias hack (TODO inerp-service-factory.tsliterally says “Move ERP service implementations to @repo/integrations”). erp-auth.ts,encryption.ts,CredentialService,IERPServiceALREADY exist inpackages/integrations— but as divergent dead copies of the live webapp versions.- Nango is already in use (WhereFour on Nango Cloud); Connect UI work is in-flight on another branch. The doc proposed self-host “as if greenfield.”
- A connector registry already exists:
packages/integrations/src/registry/registry.ts. Dagster capabilities is a second source. The proposed MANIFEST would be a third.
Dual-voice consensus
Section titled “Dual-voice consensus”| Dimension | Claude | Codex | Consensus |
|---|---|---|---|
| Connector velocity = the bottleneck? | NO (customer acq + data quirks) | NO (1-2 ERPs/yr) | CONFIRMED: premise weak |
| Self-host Nango justified? | NO (already on Cloud; SPOF) | NO (vault+proxy only; debt) | CONFIRMED: defer |
| Promote write package = real win? | YES (live #webapp hack + TODO) | YES if 2+ consumers | CONFIRMED: the keeper |
| Extraction is drop-in? | NO (divergent auth/enc forks) | NO (import drag) | CONFIRMED: reconcile first |
| 5-step checklist real? | aspirational | days-weeks not hours | CONFIRMED: right-size |
| New manifest? | rots, redundant | 3rd source of truth | CONFIRMED: cut, reuse registry |
| Conformance CI value | type+contract test | dumb existence only | CONFIRMED: existence + 1 contract test |
CRITICAL finding (Eng — production landmine)
Section titled “CRITICAL finding (Eng — production landmine)”packages/integrations/src/auth/erp-auth.ts is a divergent copy of the LIVE apps/webapp/src/services/erp-auth.ts. Live P21 does decrypt(connection.password); the package version does CredentialService().getCredentials() (Infisical). Moving Prophet21Service to import the package version silently reroutes P21 auth → production order-submission break. createOrderActivity (erp-writes.ts:91) is a hot path. Same fork exists for encryption.ts (warn-vs-throw) and the getERPService factory (webapp + worker = duplicate copies).
Decision Audit Trail
Section titled “Decision Audit Trail”| # | Phase | Decision | Class | Principle | Rationale |
|---|---|---|---|---|---|
| 1 | CEO | Reframe: this is debt-consolidation (kill forks + #webapp hack), not a connector-velocity platform | PREMISE | P6 | Both: velocity isn’t the bottleneck; don’t oversell |
| 2 | CEO | Defer self-host Nango | USER CHALLENGE | P3/P4 | Both: already on Nango Cloud + Infisical works; self-host = non-rotatable-key SPOF for just vault+proxy |
| 3 | Eng | Reconcile auth/encryption/factory forks FIRST (no-behavior-change PR, verified vs staging P21+WhereFour) | AUTO | P1/security | Both: silent auth reroute = prod break |
| 4 | Eng | Strangler migration (copy+rewire impls → package factory → repoint temporal, verify createOrder → repoint webapp → remove #webapp alias LAST). Not big-bang | AUTO | P5 | Both gave near-identical sequence |
| 5 | Eng | Validate new transitive deps via check-external-deps.mjs (fuzzysort, jsonwebtoken, @repo/observability) | AUTO | P1 | else worker ERR_MODULE_NOT_FOUND on boot |
| 6 | Eng/DX | Keep extracted package RUNTIME-THIN (ops-mcp is a CF Worker; integrations pulls @repo/db/ioredis/AWS) | AUTO | P5 | Both: fat package = worse coupling |
| 7 | DX | CUT the new MANIFEST; reuse registry.ts |
AUTO | P4 | Both: redundant 3rd source, fills twice |
| 8 | DX | Right-size claim: saves hours-to-days, not an order of magnitude; new ERP still days-weeks (Dagster reads dominate) | AUTO | P6 | Both: SOP says 4-8 weeks |
| 9 | DX | Conformance CI = existence check + ONE shared contract test both consumers run | AUTO | P3 | stronger = paperwork theater |
| 10 | Eng | Trim IERPService order-automation contract (createItem/update/delete are dead weight, non-optional) | AUTO | P5 | else conformance forces stub methods |
Cross-phase themes (high confidence)
Section titled “Cross-phase themes (high confidence)”- T1: The keeper is the package extraction; everything else is speculative. All phases.
- T2: Self-host Nango is premature. CEO (SPOF/debt) + DX (skip it). Already on Cloud.
- T3: Don’t add new sources of truth (manifest) on top of existing ones (registry.ts, Dagster capabilities). DX both.
REVIEW RESOLUTION (approved direction)
Section titled “REVIEW RESOLUTION (approved direction)”User decisions at the /autoplan gate:
-
Scope → CONSOLIDATE + THIN PACKAGE + CONTRACT TEST. Drop the self-host Nango migration and the new manifest from this work. The approved deliverable:
- PR 1 (no behavior change): reconcile the divergent forks — one
ERPAuthService, oneencryption.ts, onegetERPServicefactory. Point the LIVE webapp at the consolidated versions. Verify P21 + WhereFour auth/createOrder against staging before merge. This removes the silent-auth-reroute landmine. - PR 2 (strangler): move the
IERPServiceimpls into a runtime-thin@repo/integrations(must stay importable by ops-mcp, a Cloudflare Worker — no@repo/db/ioredis/AWS in the hot path). AddgetERPServiceas a package export. Repoint the Temporal factory first, verifycreateOrder; then webapp; remove the#webapp/*esbuild alias LAST. Validate new transitive deps (fuzzysort,jsonwebtoken,@repo/observability) viacheck-external-deps.mjs. - PR 3 (guardrail): one shared contract test suite both webapp + ts-temporal run,
asserting each registered ERP satisfies the order-automation contract. Conformance
CI = existence check, not a heavy gate. Reuse
registry.ts; no new manifest. - Trim the order-automation contract:
createItem/updateItem/deleteItemare dead weight (non-optional today) — make optional or drop for this surface.
- PR 1 (no behavior change): reconcile the divergent forks — one
-
Nango self-host → DEFERRED. Stay on Nango Cloud (WhereFour) + Infisical. Revisit self-host at a real trigger: actually needing Nango’s syncs/actions runtime, or shipping customer self-serve onboarding (the Connect-UI work already in flight). Rationale: skipping the runtime, self-host buys only vault+proxy while adding a non-rotatable-key single-point-of-failure for all ERP creds.
-
Framing corrected: this is debt consolidation (kill the duplicate forks + the
#webapphack), not a connector-velocity platform play. Net-new ERP onboarding stays days-to-weeks (Dagster reads dominate); this saves hours-to-days on the write side and removes a production landmine.
Status: design APPROVED as debt-consolidation (PR1→PR2→PR3); self-host Nango and manifest CUT; new-ERP “fast onboarding” claim withdrawn.
PR1 EXECUTION LOG (what changed from the review prediction)
Section titled “PR1 EXECUTION LOG (what changed from the review prediction)”PR1 shipped as #1733
on claude/modest-swirles-88f861. Three commits, -160 lines of duplicated
code, +106 lines of tests + comments (net -54). Pre-push hook (full monorepo
lint + build) clean.
| Commit | What |
|---|---|
3660b6788 |
encryption fork unified (canonical 100k, frozen-ciphertext regression test) |
c19b82dec |
ERPAuthService fork unified (Infisical-first for P21, parity-verified) |
6569fd354 |
review fixes (JSON-parse guard + name all 5 PBKDF2 forks) |
Facts that emerged during execution (update the doc, not just the resolution)
Section titled “Facts that emerged during execution (update the doc, not just the resolution)”-
The encryption fork was a LANDMINE, not just duplication. Webapp’s
encryption.tsderived keys at 100,000 PBKDF2-SHA512 iterations; the package’s was at 210,000. Different iterations → different derived keys → ciphertext from one cannot be decrypted by the other. The review described it as “warn-vs-throw”; the reality was production-outage-on-cutover if anyone consolidated naively. Empirically verified: 100k round-trips, 210k throws on 100k ciphertext. Every stored ciphertext is at 100k (nothing calls the packageencrypt()to persist —CredentialServicewrites plaintext JSON to Infisical). So 100k was the only safe canonical choice. -
All 7 staging P21 connections are Infisical-backed (
secrets_provider='infisical'), including active Genfit Playground and P21 Testing. They ALSO carry inline ciphertext, but the LIVE webappERPAuthServiceonly ever read the inline column — Infisical was already populated but unused for P21 auth. Staging parity check via theinfisicalCLI (againstinfisical.ordermatic.co) confirmed inline-decrypted password ≡ Infisical-stored password for all 7. The auth-source flip from inline → Infisical was therefore zero-data-change in staging. -
Infisical IS now a runtime dependency for P21 auth. This wasn’t an accident — it’s the package version’s behavior (
CredentialService→getFromInfisicalwhensecrets_provider='infisical'). User accepted this explicitly at the gate. Caveat for PR2: any failure of Infisical now blocks P21 token issuance at every cache-miss (5-min TTL × N connections). If that blast radius becomes uncomfortable, the right fix is a CredentialService inline-fallback when Infisical is unreachable ANDconnection.passwordis set — but defer that until there’s actual operational pain. -
The JSON-parse trap was a real latent bug (caught by /code-review, not the original /autoplan). The inline-fallback path in
CredentialServiceran unconditionalJSON.parse(decrypted), which would have silently broken any future inline P21 password that happened to be valid JSON: all-digit (“8675309” → number), boolean (“true”), or an object missingpassword({}→ empty-password header). Fixed in6569fd354with a strict-shape guard. Pinned with 5 regression tests. -
Five hand-rolled PBKDF2 sites at 100k, not one. Named in the
encryption.tscomment so the next iteration-count bump doesn’t leave them stranded:apps/dagster/erp_pipeline/sources/erp_auth.py,apps/temporal-worker/activities/erp_credentials.py, three TS scripts inapps/webapp/scripts/andpackages/db/scripts/. Any bump needs (a) a re-encryption migration AND (b) coordinated bumps across all 6 sites. -
packages/integrations/src/prophet21/transaction-api.tsis dead code — no callers anywhere in the monorepo (only re-exported throughpackages/integrations/src/index.ts). It imports the package’sERPAuthService. PR2 should delete it.
What this means for PR2 (the impl move)
Section titled “What this means for PR2 (the impl move)”The review predicted PR2 would need to “reconcile the auth/encryption/factory forks FIRST.” PR1 did the auth + encryption half. PR2’s remaining scope:
- Copy
Prophet21Service,WherefourService,EclipseE4Service,NetSuiteService,DemoServicefromapps/webapp/src/services/erp/implementations/into@repo/integrations. Import rewrites for the auth/encryption forks are now trivial — both modules already live in@repo/integrations/auth. - Add
getERPServiceas a@repo/integrationsexport. Repoint Temporal first, verifycreateOrderagainst staging P21 (Genfit Playground) and WhereFour (the active inline connection). Then repoint webapp. - Remove the
#webapp/*esbuild alias LAST, after both consumers are green. - Validate new transitive deps (
fuzzysort,jsonwebtoken,@repo/observability) viaapps/ts-temporal-worker/scripts/check-external-deps.mjs— without this, the workerERR_MODULE_NOT_FOUNDon boot. - Delete
packages/integrations/src/prophet21/transaction-api.ts(dead). - Test mocks: 3 webapp test files use
vi.mock('@/services/erp-auth')— the re-export preserves binding identity, so they keep working. Don’t move them to the package side without re-verifying mock semantics.
PR3 (contract test) and the IERPService trim (drop createItem/update/delete
from the non-optional surface) are unchanged from the review resolution.