Audience: engineers. This is the decisions log — the why behind GoBuild's load-bearing
architecture choices. It ports and structures the raw notes in docs/DECISIONS.md and
docs/ARCHITECTURE.md into ADRs, and cross-checks each one against the code as it stands
today. Where the running system has drifted from the written decision, it's flagged with a
⚠️ Divergence callout — treat those as the highest-signal part of this page.
House terms: a Job is the construction project (jobs); a Client is the homeowner
(clients). See the Data model reference and
Glossary.
Status key: 🟢 in effect · 🟡 partially in effect / mid-migration · ❌ superseded
| ADR | Decision | Status |
|---|---|---|
| 001 | DeepSeek models for text generation | 🟡 |
| 002 | Documenso (self-hosted) for e-signature | 🟢 |
| 003 | Resend for transactional email (not SMTP) | 🟢 |
| 004 | Pooled multi-tenancy by default + credential wall-off | 🟢 (inverts #10) |
| 005 | Native Reports module replaces Metabase | 🟢 |
| 006 | No cross-origin framing (same-origin hub-fold only) | 🟢 |
| 007 | OpenProject is the scheduling backend of record | 🟢 |
| 008 | The hub owns all cost/budget data | 🟢 |
| 009 | Field-ops is the product wedge | 🟢 |
Status: 🟡 Partially — models chosen and live, but still on the China-hosted direct API; the planned US-hosted migration has NOT happened.
Context. GoBuild needs an LLM for structured extraction (daily logs, estimates), client-update
drafting, and NL queries. DeepSeek's models are cheap, OpenAI-compatible (function calling + strict
JSON-schema output), and the founder's preference. But (verified June 2026) DeepSeek's public API
api.deepseek.com is hosted in China, and its privacy policy states inputs may be used to
train/improve their models and that the service is "not intended to process sensitive personal
data." GoBuild carries contracts, financials, and homeowner PII — a real liability and a likely
dealbreaker for builders who care about client data. DeepSeek's API is also text-only (no
vision, no audio), so receipts/plans and voice memos need separate providers regardless.
Decision. Keep DeepSeek models, but write the AI layer so the endpoint is a one-line
base-URL swap. docs/DECISIONS.md #1 recorded: use the direct API during build/early stage
(accepting China hosting + training-on-inputs), migrate to a US-hosted inference provider
(Fireworks / Together / DeepInfra, no-retention terms) once growing. Pair with Deepgram
Nova-3 for voice→text and Gemini 2.5 Flash-Lite for vision.
Consequences.
audio → Deepgram → DeepSeek (JSON schema) → DailyLog and image → Gemini → BudgetLine.⚠️ Divergence (docs ↔ code).
docs/ARCHITECTURE.md §4describes this decision as already
resolved to "DeepSeek models on US-hosted inference." The code contradicts it.
app/config.py:88-91still points at the China endpoint:deepseek_base_url: str = "https://api.deepseek.com" # comment: "direct API for now; base URL swaps to US host later, one line"So the accurate state is
DECISIONS.md #1(direct API for now), notARCHITECTURE.md §4
(US-hosted). The migration is still open. The swap remains a one-line change, so the
engineering pre-work held up — only the operational cutover is outstanding.
Status: 🟢 In effect. Supersedes the original "click-to-approve for v1, e-sign later" plan.
Context. docs/DECISIONS.md #7 originally deferred true e-signature: "click-to-approve for
v1; e-sign later." Change orders, contracts, and lien waivers ultimately need a real,
cryptographically-signed audit trail — not just a portal button click.
Decision. Ship Documenso, self-hosted per the same pattern as OpenProject — its own
container + its own Postgres database, addressed by the hub over the private network. The signer
is public at sign.gobuild.ca; the hub holds the request registry and status, Documenso holds
the crypto cert.
Consequences.
docker-compose.prod.yml:77 runs documenso/documenso:v2.11.0; config inapp/config.py:240-246 (documenso_url, documenso_api_token, documenso_webhook_secret);app/models/signature.py; consumers across sales.py, money.py, cost.py,lien_waivers.py, proposal_portal.py, sub_portal.py, client_portal.py.NEXT_PRIVATE_UPLOAD_BUCKET: documenso).X-Documenso-Secret header). Ifdocumenso_webhook_secret is unset the webhook receiver is inert and signature completionStatus: 🟢 In effect.
Context. GoBuild sends client updates, invoice/payment links, and notifications. Options were
raw SMTP vs a transactional email API. SMTP means running deliverability (SPF/DKIM/DMARC tuning,
bounce handling, suppression) ourselves.
Decision. Use Resend for all product email — transactional send, batch (100/call),
delivery/open/bounce webhooks, and inbound parsing. Standard SPF + DKIM + DMARC per sending domain.
Consequences.
app/services/outbound_email.py is the single send path; config resend_api_key /resend_from / resend_inbound_secret (app/config.py:118-128); inbound + delivery events viaapp/routers/webhooks.py.email in the CredentialResolver,app/services/credentials.py:38-40) — pooled orgs send through their own key, not a shared one.NEXT_PRIVATE_SMTP_FROM_ADDRESS) — that's an internalStatus: 🟢 In effect — but this INVERTS the original topology decision (DECISIONS.md #10).
Context. DECISIONS.md #10 and ARCHITECTURE.md §6 originally decided
whole-stack-per-client (hub + OpenProject + DB duplicated per Client) as the default, with a
shared/pooled hub "held in reserve for scale only." The tell that hedged the bet: every hub
table carries organization_id from day one, so consolidation into a shared hub was always
possible without a schema rewrite.
Decision (as it actually shipped). Pooled is now the default. An Organization has an
isolation column whose server_default is pooled (app/models/base.py:67-73,
app/models/organization.py:37-40): pooled = a row in the shared multi-tenant hub DB
(Starter/Pro tiers); siloed = sole tenant of its own droplet/DB (Enterprise). One control plane
administers both through the same signed Admin API.
Because pooled orgs share a hub, tenant isolation moves from physical to credential + row
isolation, enforced in one place — the CredentialResolver (app/services/credentials.py):
tenant_credentials → legacy org.settings JSON → global.env. The last two steps apply only to siloed orgs. A pooled org gets its OWN keys only —get_settings().<key> at a call site.Consequences.
ARCHITECTURE.md §6 warned about when it held pooling "in reserve." Accepted in exchange for farMANAGED set: openproject, chatwoot, documenso, ai —credentials.py:62-68) intentionally use a shared platform account. For OpenProject / Chatwoot /⚠️ Divergence (decision reversed).
DECISIONS.md#10 says whole-stack-per-client is the
decided default and pooling is the reserve. The code says the opposite:pooledis the
server_defaultand the primary provisioning path (control planePOOLED_STEPS), while siloed
droplet boot is still a placeholder (see Control plane). The raw
decisions log is stale on this point and should be rewritten to match.
Status: 🟢 In effect. Metabase retired 2026-06-09.
Context. Reporting/BI was originally served by an embedded Metabase container at
reports.gobuild.ca. That added a container, an embedding secret, and a separate query surface
over the same hub Postgres the app already owns.
Decision. Build reporting natively in the hub — job costing, budget-vs-actual, WIP — rolled
up directly from Shift (labor) and Bill (everything else) against BudgetLine. Retire Metabase.
Consequences.
app/services/reporting.py plus routers reports.py, report_specs.py,report_export.py, report_snapshots.py, report_digest.py, report_audit.py.docker-compose.prod.yml, noreports.gobuild.ca Caddy block (evidence in docs/wiki/infra/ops-runbook.md:88).docs/onboarding_README.md anddocs/CUSTOMER_DROPLET_OPS.md §4 still list MB_EMBEDDING_SECRET_KEY, and a.metabase_dashboard2_backup.json sits in the repo root. Ignore them unless Metabase isStatus: 🟢 In effect — nuanced. The absolute "DENY" rule was deliberately relaxed to SAMEORIGIN.
Context. Clickjacking protection wants X-Frame-Options: DENY / CSP frame-ancestors 'none'.
But GoBuild does legitimately frame its own pages same-origin (the Reports/Finance command
center, dashboard embeds, email-preview iframes, and the website studio's live previews), and it
publishes partner marketing sites on tenant subdomains/custom domains that must stay
embeddable by design. A blanket DENY broke the studio previews (they went blank).
Decision (the "hub-fold" rule). Apply clickjacking headers only to hub/service hosts, and
only as SAMEORIGIN, never DENY. Cross-origin framing of the hub — the actual threat — stays
fully blocked; same-origin self-framing is permitted. Published partner sites are explicitly left
open to framing.
Consequences.
app/main.py:321-355 (_security_headers middleware): sets X-Frame-Options: SAMEORIGIN + CSP frame-ancestors 'self' only when the request is not a partner site.host_served scope, or a non-APP_HOSTS host, or a /p/<slug> //page/<slug> path (app/main.py:345-351). Those get no framing headers — intentional.Status: 🟢 In effect.
Context. Scheduling (work-package dates, dependencies, parent/child hierarchy, time entries)
is a solved, deep problem. Rather than build a scheduler, GoBuild integrates OpenProject CE
(GPLv3, self-hosted, pinned image).
Decision. OpenProject owns schedule + time only, nothing else. The entire integration
surface is Job ↔ Project and ScheduleItem ↔ work_package, tracked in an IdentityMap. Source
of truth is per-entity, never per-record — no entity owned by two systems.
Consequences.
docker-compose.prod.yml:27-28 pins openproject/openproject:15 (never latest —schedule.py).Status: 🟢 In effect.
Context. A natural instinct is to push budgets/costs into OpenProject alongside schedule. But
OpenProject's Budgets/Cost API is a read-only stub (GET only — no create/update/delete of
budgets or cost types).
Decision. Budgets, cost codes, and estimated-vs-actual all live in the hub (BudgetLine).
Labor hours are driven through OpenProject's Time Entries API and pulled back, but cost rates and
budgets are the hub's. Estimates → budget lines → actuals → AI cost queries all sit on hub data.
Consequences.
Status: 🟢 In effect.
Context. DECISIONS.md #3 asked which capability leads the product: field-ops/scheduling or
billing/sales. This reorders the whole build.
Decision. Field-ops/scheduling is the wedge. Billing/sales is easier to build later and
follows. This reorders the delivery phases around field-ops.
Consequences.
app/services/field_capture.py, daily_logs.py, and the AI voice→log pipeline.Lead) was originally a "cut for v1 unless demanded" item (DECISIONS.md #4) but hasThese are the decisions that need revisiting, the undocumented ones, and the migrations still in
flight — surfaced by cross-checking the log against the code:
api.deepseek.com (China, trains-on-inputs) while ARCHITECTURE.md §4 claims it's alreadyDECISIONS.md #10 — it's now backwards. The log still says whole-stack-per-clientpooled. The written decision should be inverted to matchdocumenso_webhook_secret is unset the completionLead/CRM was meant to be cutMB_EMBEDDING_SECRET_KEY in onboarding docs and the.metabase_dashboard2_backup.json at repo root outlive the retired system — delete, or is aQuickBooksMap), the PWA installability work (recent commits), and the showcase/custom-domain