Status: 🟢 Permit tracking is live · 🟡 AI predictor is live but Gemini-gated ·
⚪ BuildData.ca is built-but-dormant. The hand-entered permit tracker — statuses,
inspections, submittal-document checklists, assisted-filing package — is 🟢 live
against real data for every Job. The AI permit/zoning/document predictor
is 🟡 fully wired but does nothing without aGEMINI_API_KEY. The BuildData.ca
integration is a ⚪ complete, correct client that is dormant by default: it only
lights up when an org pastes a paid RapidAPI key into Settings, it is read-only,
and it never creates or edits a permit — its two model columns (builddata_ref,
source="builddata") are declared but nothing writes them. See
Is BuildData actually used?.
Audience: engineers + permit-literate operators. This is the deep reference for how
GoBuild tracks permits per Job, predicts what a Job needs, and (optionally) enriches
that with Canadian construction data. Companion reading:
Jobs / Command Center (where the Permits folder lives) and the
Integrations catalog (BuildData's connection + wall-off model).
Legend: 🟢 live · 🟡 WIP / config-gated · ⚪ dormant / stub · ❌ not built.
House terms: a unit of work is a Job (jobs); the person you build for is a
Client (clients). Never "Project", never "Customer".
Everything hangs off four tables in app/models/permits.py:
Permit (app/models/permits.py:29) — one permit on one Job. Carries type,authority (the jurisdiction / AHJ), permit_number, status, the three lifecycleapplied_date, issued_date, expiry_date), fee_cents, conditions,est_days (AI-estimated timeline), and portal_url (the municipal e-permit portalPermitInspection (:54) — inspections booked against a permit. type,scheduled_at, result ∈ scheduled | pass | fail | conditional | cancelled,inspector.PermitDocument (:72) — a required drawing/form in the submittal packagekind, status, required,responsible (e.g. "Structural Engineer (P.Eng)"), and a link to the uploadeddocument_id.License (:91) + SubCredential (:107) — the org's own compliance andTwo enums drive the UI and gate every write:
PERMIT_STATUSES = required · applied · under_review · issued · inspections · closed · expired · rejected
PERMIT_TYPES = building · electrical · plumbing · mechanical · demolition · development · occupancy · zoning_variance · other
(app/models/permits.py:22-25)
Two columns are declared for BuildData but never written anywhere in the codebase:
Permit.builddata_ref / source (:45-46) and PermitInspection.builddata_ref (:64).
They are the schema hooks for a future "import this permit from BuildData" flow that
does not exist yet.
The Job's Permits tab is GET /portal/jobs/{job_id}/permits
(app/routers/permits.py:67), rendered by templates/permits.html. It assembles:
permit_svc.job_permits() (app/services/permits.py:28),docs_ready / docs_total (submittal readiness),expiry_days (days to expiry), and is_open / is_active flags off the status enum.compliance_for_job()app/services/permits.py:133). Each item gets an expired / soon flag.All hand-entry, all tenant-scoped (_owned_job / organization_id checks on every
route):
| Action | Route | File:line |
|---|---|---|
| Add a permit | POST /portal/jobs/{job_id}/permits |
app/routers/permits.py:99 |
| Edit a permit | POST /portal/permits/{permit_id} |
:124 |
| Delete a permit | POST /portal/permits/{permit_id}/delete |
:150 |
| Add an inspection | POST /portal/permits/{permit_id}/inspections |
:164 |
| Delete an inspection | POST /portal/inspections/{insp_id}/delete |
:190 |
Every mutation redirects back to …/permits?saved=1 for the toast.
A permit's required-drawings package is tracked as PermitDocument rows with a
pending → received → submitted → approved → na status pipeline:
POST …/documents/typicalapp/routers/permits.py:306) pulls a per-type library fromapp/services/permit_doc_library.py — instant, deterministic, deduped by name.received:POST /portal/permit-documents/{doc_id}/attach (:278).POST …/documents/predict (:332) →predict_documents() (app/services/permits.py:52). Starts from the deterministicA schedule task can be marked "can't start until this permit is issued":
POST /portal/tasks/{task_id}/requires-permit sets ScheduleItem.requires_permit_id
(app/routers/permits.py:204). This is a soft gate — it surfaces blocked tasks in
the Keystone brief (outstanding_for_brief(), app/services/permits.py:181), it does
not hard-block the UI.
There is no universal permit-submission API, so GoBuild prepares the package and
hands off to the city portal (app/services/assisted_filing.py):
POST /portal/permits/{permit_id}/applicationapp/routers/permits.py:383) → build_application_pdf(). Best-effort; failures?saved=err.GET …/package.zip (:400).applied):POST …/file (:415).POST …/attach with extract=1 (:440), gated onget_settings().gemini_api_key (:466).Wired end-to-end, gated on GEMINI_API_KEY. From the Job Permits tab, a button
posts POST /portal/jobs/{job_id}/permits/predict (app/routers/permits.py:351),
which returns addable suggestion cards. The brain is predict_requirements()
(app/services/permits.py:211), which runs a two-tier strategy:
gemini.is_configured(), it callsai_assistant.predict_permits_grounded() (app/services/ai_assistant.py:986),{title, uri} citations.ai_assistant.predict_permits() (:917), a plain Gemini JSON call. This tier iscontextapp/services/permits.py:244-254) — flavour, not grounding.Jurisdiction is resolved from org preferences (country/region) + the parsed city, and
shown to the user via jurisdiction_label() (app/services/permits.py:289). The
service never raises — every failure path returns {"suggestions": [], "grounded": False}, so with no Gemini key the predictor returns an empty card silently.
Sibling AI reads, same gating:
POST /portal/jobs/{job_id}/zoning/check (:367) →zoning_check() (app/services/permits.py:98). Live web-grounded zoning read;likely_unlogged_permits() (:264) surfaces permits a Job likelyGET /portal/permits (app/routers/permits.py:541) is the company-wide tracker —
every permit across every Job in one table, PM/office only. It computes KPIs
(total, open, expiring within 30d, expired, missing_docs) and per-permit
document readiness, then renders templates/permits_dashboard.html.
active_nav="compliance"The dashboard passes active_nav="compliance" to the template
(app/routers/permits.py:590). That value is wrong for this page. The sidebar
(templates/app.html) highlights a hub when active_nav is in that hub's key list:
permits — its key list is['jobs','tasks','documents','templates','schedule','live','field','permits']templates/app.html:110).compliance — key list['team','crews','workload','teamadmin','compliance','fieldaccess']templates/app.html:124).So opening /portal/permits lights up the "Team & HR" hub in the sidebar instead of
"Jobs", even though the omnibox/navmap correctly files this page under a permits
key pointing at /portal/permits (app/services/navmap.py:24). It should pass
active_nav="permits". This is a leftover from when Permits lived under a "Compliance"
pillar; the /portal/compliance hub route legitimately still uses
active_nav="compliance" (app/routers/permits.py:628), which is likely why the stale
value was never noticed on the dashboard. Low-severity, cosmetic — it misroutes the
highlight only, no data or access impact. One-line fix.
BuildData.ca is a Canadian construction-data API (permits, development permits,
inspections, licences, contractors, planning applications) accessed via RapidAPI.
The client is app/integrations/builddata/client.py — small, correct, and complete:
| Function | Returns | File:line |
|---|---|---|
search_permits(city/address) |
building or development permits | :83 |
contractor_profile(name) |
a sub's aggregated permit history | :92 |
inspections(city/address) |
inspection records | :98 |
permit_stats(city) |
city-level counts / types | :104 |
coverage(entity) |
per-city record counts | :109 |
city_from_address(addr) |
best-effort city token parser | :26 |
There is no zoning endpoint — the client docstring says so explicitly
(client.py:9). Zoning in GoBuild is a Gemini feature, not a BuildData one.
Three read-only, lazy-loaded HTMX panels, all dormant-safe (they render a "Connect
BuildData.ca in Settings" prompt when no key is set):
GET …/permits/intel/stats → permit_stats()app/routers/permits.py:490, template permits.html:246).…/permits/intel/comparables → search_permits():523, template permits.html:253).GET /portal/subs/{sub_id}/vet → contractor_profile() + a Geminivet_sub() summary (app/routers/permits.py:801), on the Compliance hub.Built, correct, and dormant by default — almost certainly unused in production today.
builddata_configured is justbool(builddata_api_key) (app/config.py:145). Resolution is per-org:builddata_config() reads the org's Settings key first, then the server .envapp/services/preferences.py:240). Per the wall-off model, builddata is aclient.py:55, :63). This is why every caller caches / gates aggressivelybuilddata_ref / source="builddata" columns existapp/models/permits.py:45-46, :64) but are dead — no writer sets them. The onlycontext string fed to theapp/services/permits.py:249).integrations/catalog.md:87).Bottom line: BuildData is scaffolding-that-works, not scaffolding-that-runs. The
plumbing is real and would light up the moment a key is set — but as shipped it is a
dormant, optional enrichment layer, not load-bearing. The AI predictor, by contrast, is
genuinely wired and does real work the moment a Gemini key is present (a key GoBuild
uses broadly elsewhere), so it is far closer to "on" than BuildData.
app/services/permit_cc.py re-presents the same permitapp/routers/permits.py endpoints.outstanding_for_brief() (app/services/permits.py:181) feedsexpiry_scan() (:154) buckets permits, licenses, andbuilddata_ref / source="builddata" columns be wired to ancity_from_address assumes a", City, PROV" shape and a province allow-list (client.py:23); UScoverage() endpoint get surfaced anywhere?active_nav="compliance" → "permits" on the dashboard — trivial, but doesportal_url deep-links per jurisdiction is