Audience: engineers adding to the hub. Every claim below is grounded in a
file:lineyou can open. When the code and this page disagree, the code wins — fix the page.
The hub is a FastAPI monolith, one process per provisioned client stack. It talks only to its own Postgres and its own OpenProject backend; end users never see OpenProject. See ARCHITECTURE.md (repo docs/ARCHITECTURE.md) for the data model and the whole-stack-per-client topology.
| Layer | Path | Responsibility |
|---|---|---|
| App entrypoint | app/main.py |
Builds the FastAPI app, registers every router, mounts the middleware stack (tenant, host-routing, security headers, CSRF). |
| Routers | app/routers/*.py |
HTTP surface. One file per feature area (jobs.py, schedule.py, money.py, …). Parse/validate the request, enforce auth + role, call a service, return a response/schema. Keep business logic thin here. |
| Portal | app/portal/ |
The server-rendered back-office UI shell. router.py (the big one — dashboard + most HTML pages), deps.py (cookie auth + role guards), templating.py (the shared Jinja instance). |
| Services | app/services/*.py |
Business logic. Pure-ish functions that take a Session + args and do the work (entitlements.py, workflows.py, invoices.py, change_order.py, openproject_sync.py, …). This is where new logic should live. |
| Integrations | app/integrations/<vendor>/ |
Third-party clients — OpenProject, Chatwoot, QuickBooks, Documenso, Deepgram, Gemini, Resend, Twilio, Stripe, etc. Each is a thin adapter with an is_configured() gate (see §6). |
| Models | app/models/*.py |
SQLAlchemy 2.0 ORM tables. Every tenant-owned table carries organization_id. app/models/__init__.py imports every model so Base.metadata is complete (alembic autogenerate depends on this — migrations/env.py:13). |
| Schemas | app/schemas/*.py |
Pydantic request/response shapes for the JSON API. |
| Tenancy | app/tenancy/ |
The single source of truth for "which org is this request for" — context.py (ContextVar), middleware.py (pure-ASGI resolver), resolver.py (slug → Organization). |
| Content | app/content/ |
Product copy authored in Python, not templates (e.g. tips.py:9 PAGE_TIPS — one contextual tip per page). |
| Auth | app/auth/ |
security.py (JWT mint/verify, password hashing), deps.py (API bearer auth + role gate), router.py. |
| Config | app/config.py |
One Settings object per process, loaded from .env. |
| DB plumbing | app/database.py |
Engine + SessionLocal + the get_db dependency. |
| Migrations | migrations/ + alembic.ini |
Alembic. script_location = migrations (alembic.ini:4); 141 revisions in migrations/versions/. |
Rule of thumb for a new feature: router (HTTP/auth) → service (logic) → model (data). If a router file grows business logic, extract it into a service.
A request flows through the middleware stack in app/main.py (registered bottom-up, so they run outermost-first) before hitting a route:
TenantMiddleware (app/main.py:125, defined app/tenancy/middleware.py:16) — pure-ASGI, runs before routing. Parses the Host header to a tenant slug (acme.app.gobuild.ca → acme, resolver.py:23) and publishes it two ways: scope["tenant_slug"] and a ContextVar (context.py:14). It's pure-ASGI on purpose — a BaseHTTPMiddleware would run the endpoint in a detached context and the ContextVar wouldn't propagate (middleware.py:1-8). No DB hit here; slug → Organization is resolved lazily downstream (resolver.py:66).host_site_router (app/main.py:129) — if the request arrived on a partner's published site host (a {label}.gobuild.ca subdomain or custom domain), rewrite the path onto the /p/{slug}/… routes. Hub/service hosts pass straight through with no DB hit._security_headers (app/main.py:322, clickjacking protection on hub hosts only, framing left open for partner sites), _csrf_origin_guard (app/main.py:362, Origin/Referer check for any state-changing request carrying the session cookie), _static_cache_headers (app/main.py:311).db: Session = Depends(get_db) and an auth dependency.get_current_user (app/auth/deps.py:29). It decodes the JWT, loads the user, and re-checks the tenant — a token is only valid on its own org's host (deps.py:47), blocking cross-tenant cookie/token replay in a pooled hub.current_portal_user (app/portal/deps.py:22) with the identical tenant guard (deps.py:45).organization_id.get_db (app/database.py:26) commits on success, rolls back on exception, always closes. Handlers do not manage transactions manually. Exceptions are re-raised so the top-level handler logs them.unhandled_exception_handler (app/main.py:194), which logs every error and returns a clean 500 — never a silent swallow. Portal role guards raise PortalRedirect (app/portal/deps.py:63), converted to a friendly 303 by portal_redirect_handler (app/main.py:202).Transaction convention: use db.commit(), never db.flush() as a stand-in — flush-without-commit left idle-in-transaction locks in prior projects (app/database.py:1-6).
The portal is server-rendered Jinja2 + Alpine.js + HTMX — not a SPA.
app/portal/templating.py:8 builds a single Jinja2Templates pointing at the repo-root /templates directory (125 .html files). Every portal route renders through this instance: templates.TemplateResponse(request, "login.html", {...}) (e.g. app/portal/router.py:80). There is one templates directory, not a per-router one.templating.py):
has_feature(org, key) (:41) — hide what the org's tier doesn't include: {% if has_feature(user.organization, 'marketing') %}…{% endif %}.is_mobile(request) (:36) — UA sniff to offer the field PWA.DEMO_MODE (:13) — pitch-mode overlay, home instance only.ASSET_V (:19) — cache-bust token for hub.css, derived from file mtime so a rebuild busts the browser cache.templates/app.html:139) is an x-data root; 83 of 125 templates use x-data for local panes (drawers, menus, tabs, inline editors) with @click.outside / @keydown.escape. HTMX handles partial reloads. There is no client build step — components are x-data islands.app/content/tips.py holds PAGE_TIPS (:9) — curated, construction-flavored guidance keyed by page. Templates render the string; the copy is versioned as code.To add a page: write a template under /templates, add a route in app/portal/router.py that renders it via the shared templates instance, guard it (§4), and add any contextual copy to app/content/.
app/models/<feature>.py with organization_id, and make sure it's imported by app/models/__init__.py so alembic sees it.alembic revision --autogenerate -m "add <feature>", review the generated SQL, commit it. Never hand-edit the DB.app/services/<feature>.py as functions taking (db, ...). Scope every query by organization_id.app/schemas/<feature>.py for JSON routes.app/routers/<feature>.py:
router = APIRouter(prefix="/api/<feature>", tags=["<feature>"]) (pattern: app/routers/jobs.py:20).user: User = Depends(require_roles(UserRole.pm, UserRole.office)) (app/routers/jobs.py:33; factory at app/auth/deps.py). Owner is always allowed.user.organization_id (app/routers/jobs.py:26, :51).FEATURES in app/services/entitlements.py:23 and the tier sets at :35. Gate the router with dependencies=[Depends(require_feature("<key>"))] (entitlements.py:71) and hide the nav with has_feature in templates. Non-gateable keys are always on, so a typo'd key never hides a core screen (entitlements.py:58-68).main.py — import the router and app.include_router(...). Order matters where literal paths must beat dynamic ones — e.g. lead_admin_router is registered before sales_router so /leads/import wins over /leads/{lead_id} (app/main.py:233-235).require_portal_role(...) (app/portal/deps.py:72) instead of the API's require_roles..env. All settings come from app/config.py (Settings, pydantic-settings). Each per-client instance has its own .env and thus its own secrets, DB, and OpenProject backend. Read settings via the cached get_settings() singleton (config.py:353) — never os.environ directly. Optional integrations default to empty strings and stay dormant until keyed.get_settings() raises if SECRET_KEY is unset/default/too short in prod (config.py:359) rather than silently signing forgeable JWTs.script_location = migrations (alembic.ini:4); target_metadata = Base.metadata (migrations/env.py:23). Every schema change is a reviewed revision. 141 revisions and counting.organization_id. Every tenant-owned table carries it; every query filters by it (app/auth/deps.py:1-6). Even though whole-stack-per-client means one org per DB today, this keeps the code correct if we ever consolidate tenants — and the auth-layer tenant guard (deps.py:47) makes cross-tenant replay fail closed regardless.app/main.py:194); get_db re-raises after rollback (database.py:36). Surfacing beats silence.These are patterns already in the tree. Some are defensible; the point is to know which is which and not add more of the bad kind.
except … : pass that swallows a real side-effectThe worst offenders swallow a failure with no log, no counter, no flag — the side-effect just silently doesn't happen.
app/services/chatwoot_sync.py:176-177 and :180-181 do except ChatwootError: pass. If updating a contact's attributes or labels fails, the CRM silently drifts out of sync with no trace. The outer except Exception: db.rollback() (:182, :204) also swallows without logging.app/services/workflows.py:311-315 (run_due): a failing enrollment is caught with except Exception: db.rollback() and the loop moves on. The returned count hides how many silently failed to advance. Same shape in trigger_event.Do instead: log at minimum (logger.warning("…: %s", exc)), and where correctness matters, surface a count of failures or flag to an admin queue (the OpenProject reconciliation model in ARCHITECTURE.md §2 (repo docs/ARCHITECTURE.md) — "flag drift, never silently overwrite" — is the standard to copy).
Some swallows are correct: a best-effort side-effect must not break the primary transaction. E-sign has several, and they're explicitly annotated:
app/services/esign_service.py:256, :359 — # a notification must never break signing.app/services/esign_service.py:463 — # filing must never block the signature.app/services/esign_service.py:478 — # never let conversion failure block the signature.These are defensible because the intent is documented at the catch site and the swallowed work is genuinely optional. The rule: if you swallow, (a) write the one-line reason as a comment, and (b) log it. A bare except: pass with no comment is always a smell.
is_configured() guards on optional integrationsEvery optional vendor client exposes an is_configured() gate — quickbooks/client.py:39, gcal/client.py:25, ai/gemini.py:25, chatwoot/client.py:20, esign/documenso.py:22, push/webpush_client.py:21, builddata/client.py:38. The convention is: check is_configured() before you call the vendor, so an un-keyed instance degrades to a no-op instead of throwing. chatwoot_sync.backfill does this right (:188, :211). Calling a client without the guard on an instance that hasn't set the key is the anti-pattern — it turns a dormant feature into a runtime error.
OpenProject is a scheduling backend the user never sees — so an OpenProject outage should degrade, not error. Two conflicting patterns exist:
app/integrations/openproject/adapter.py:592 — log_time returns None on OpenProjectError; the hub stays the source of truth for hours. The OpenProjectClient itself never swallows — it raises a typed OpenProjectError (client.py:76) so callers can choose how to degrade.app/routers/schedule.py:74 and :101, and app/routers/jobs.py:76, catch OpenProjectError and re-raise HTTPException(502). Worse, schedule.py:73 commits the hub row first, then pushes to OpenProject — so on a 502 the write succeeded but the user sees a total failure (partial success surfaced as error). jobs.py:1-4 documents the intended contract ("job creation never depends on OpenProject being reachable"), and create_job honors it (no OP call) — but provision_job and the schedule writes don't. When you add a route that touches an optional backend, prefer the adapter.py:592 shape: persist hub state, attempt the sync, and report a soft "sync pending" rather than a 502.Several webhook receivers are inert until a secret is configured (e.g. the Documenso and Chatwoot inbound webhooks — config.py:246, :257 note "receiver inert" when unset). That's fine by design, but an inert handler that returns 200 with no log looks identical to a working one. If you add a handler that no-ops under some config, log the reason so it's greppable — "silent 200" is how broken plumbing hides.
# noqa: BLE001 liberally, implying ruff — but what's the canonical ruff/black/mypy config and is it enforced in CI or only locally? Is BLE001 (blind-except) treated as a real signal or rubber-stamped?X-Tenant dev override (middleware.py:37) suggests a tenant-aware test harness — is it documented?except …: pass without a logged reason, or is it left to code review? Should the Chatwoot/workflows silent swallows be treated as bugs to fix now?adapter.py:592) shape the official standard, and should the schedule.py/jobs.py 502s be reclassified as bugs? What's the intended UX when an optional sync fails after the hub write commits?alembic revision --autogenerate the mandated path, are downgrade functions required, and is there a rule against editing an already-shipped revision? With 141 revisions and per-client droplets, how is fleet-wide migration ordering guaranteed?file:line" house rule extend to PR descriptions?app/portal/router.py size: it's ~250 KB in one file. Is there an agreed threshold or plan to split it, and a convention for when a portal feature graduates to its own router module?entitlements.py:12 says the matrix "mirrors docs/god-mode/PLAN.md §2 — edit both together." Is that consistency checked anywhere, or purely by discipline?