Status: 🟡 Live but partially enforced · Audience: GoBuild staff (sales, ops, eng).
The entitlement model is real and clean (app/services/entitlements.py), but only 3 of 8 gateable features actually block a route. The rest are nav-only (hidden link, reachable by URL) or un-enforced. Two entitlement labels advertise things that do not exist in code — see Vaporware in the labels.
GoBuild is sold à la carte: a Client can buy only Marketing, or only Jobs, or any mix. Each Command Center is meant to be a sellable module. This page is the single reference for how "the Client paid for X" turns into "the code lets them use X".
Status badges used below: 🟢 Live · 🟡 WIP / config-gated · ⚪ Dormant · ❌ Dead-end / vaporware
One source of truth: app/services/entitlements.py. Resolution order for "can this org use feature key?":
override (org.entitlements[key]) → else the tier default (TIER_FEATURES[org.tier])
has_feature(org, key) (app/services/entitlements.py:58-68):
key is not in the FEATURES dict → always True. Core screens (Jobs, documents, invoices, schedule…) are not gateable, so a typo'd key can never hide a core module (app/services/entitlements.py:61).org is None (public / legacy / unauthenticated context) → always True, never gated (app/services/entitlements.py:63).entitlements JSON → that boolean wins (app/services/entitlements.py:65-67).tier_grants(org.tier, key) (app/services/entitlements.py:68, 52-55).organizations.tier (enum starter | pro | enterprise, app/models/base.py:58-64; column at app/models/organization.py:33-36). The package sold. Drives both the default feature set and the default isolation mode. Default: starter.organizations.entitlements JSON, overrides only (feature_key -> bool). Empty dict {} = pure tier defaults (app/models/organization.py:45-47). This is not the full feature set — it only records deltas from the tier.TIER_FEATURES, app/services/entitlements.py:35-40)| Tier | Default features granted |
|---|---|
| starter | (none) — set(). A Starter org gets only the non-gateable core (Jobs, Reports, invoices, documents…). |
| pro | sales, marketing, ai_bar, procurement, quickbooks, hr |
| enterprise | everything in Pro plus payroll, safety |
À-la-carte reality: the "buy only Marketing" story is delivered by per-org overrides, not by tiers. Tiers are coarse bundles; the
entitlementsJSON is how you cut a bespoke package (e.g. a Starter org with{"marketing": true}and nothing else).
Where an entitlement is actually consulted (app/services/entitlements.py:9-10):
has_feature is registered as a Jinja global (app/portal/templating.py:40-41) and gates sidebar links in templates/app.html.require_feature(key) FastAPI dependency (app/services/entitlements.py:71-85): authenticated portal user whose org has key, else a friendly redirect (login if signed out, "/portal?denied=feature" if the tier lacks it).entitlements via the Admin API (see §4).The FEATURES dict (app/services/entitlements.py:23-32) defines the 8 gateable keys and their human labels (the labels shown in the god-mode editor). Below, each key is mapped to what actually gates it in code — and how strongly.
Enforcement strength:
🟢 Route-enforced (require_feature blocks direct URLs) · 🟡 Nav-only (link hidden, but the route is reachable by typing the URL) · ⚪ Un-enforced (key exists, nothing reads it)
| Key | Label (god-mode) | Unlocks (Command Center / feature) | Enforcement | Evidence |
|---|---|---|---|---|
sales |
Sales / CRM (leads, proposals, estimator, nurture, booking) | Sales Command Center — leads, proposals, contacts, booking, messages, nurture, estimator | 🟡 Nav-only — sidebar link gated; no require_feature on sales_cc.py / sales.py (role-gated only) |
nav templates/app.html:112; router app/routers/sales_cc.py:30-32 has no feature guard |
marketing |
Marketing (website, blog, email studio) | Marketing tools: website builder, blog, email studio | 🟢 Route-enforced on the tools | guards: app/routers/marketing.py:31, app/routers/website.py:31, app/routers/blog_admin.py:33; nav templates/app.html:116 |
ai_bar |
Keystone AI assistant | The Keystone AI bar / assistant | ⚪ Un-enforced — no route guard, no nav gate | app/routers/ai_bar.py:30 (prefix /portal/ai, role-gated only); no has_feature reference anywhere for this key |
procurement |
Procurement / purchase orders | Purchase orders, procurement flows | 🟢 Route-enforced | app/routers/procurement.py:37 |
quickbooks |
QuickBooks / Xero sync | QuickBooks Online connect + sync | ⚪ Un-enforced — no guard on quickbooks.py; Xero half of the label is vaporware (see §5) |
app/routers/quickbooks.py:26 (no require_feature) |
hr |
Team & HR | Team & HR Command Center — team, crews, workload, compliance | 🟡 Nav-only | nav templates/app.html:122; app/routers/people.py:32 role-gated only |
payroll |
Payroll | Payroll module | 🟢 Route-enforced | app/routers/payroll.py:27 |
safety |
Safety / TRIR | Safety / TRIR module | ⚪ Un-enforced — no guard on safety.py, no nav gate |
app/routers/safety.py:25 |
Several sellable-looking modules have no entitlement key and are gated only by role:
/portal/sales, app/routers/sales_cc.py:30-32) and Marketing Command Center dashboard (/portal/marketing, app/routers/marketing_cc.py:43-45) are role-gated only. The sales / marketing keys hide the nav link and (for marketing) the sub-tools, but the CC landing pages themselves have no require_feature.app/routers/ads_build.py:25-26, prefix /portal/marketing) and Ad-account linking (app/routers/ads.py:24-25) — no entitlement key at all. Ads live under the marketing prefix but are not covered by require_feature("marketing").app/routers/social.py:30-32, prefix /portal/marketing/social) — no entitlement key, role-gated only.Net effect: the only features a paying/non-paying boundary actually enforces on the server are marketing (tools), procurement, and payroll. Everything else is a hidden link or fully open by URL.
Entitlements answer "did the Client buy this module?". Roles answer "is this user allowed to touch it?". They are independent and stack — a route can require both.
Roles (UserRole, app/models/base.py:51-55): owner, pm, field, office.
require_portal_role(*roles) (app/portal/deps.py:72-90):
owner is ALWAYS allowed — the account owner can never lock themselves out (app/portal/deps.py:75-80).app/portal/deps.py:76). This is how owner-only actions are expressed (e.g. Google-Ads account linking: app/routers/ads.py:25 → require_portal_role(UserRole.owner))."/portal?denied=1"; feature-denied users to "/portal?denied=feature" — different query flags so the UI can show the right message.role ≠ field (the field-worker firewall)The whole upper nav block — Sales, Marketing, Reports & Finance, Team & HR — is wrapped in {% if user.role.value != 'field' %} (templates/app.html:111). Field users see only Jobs. This is a role gate, not an entitlement gate: even if the org owns every module, a field user's nav is Jobs-only. Entitlement checks (has_feature) are nested inside that role check.
Typical guarded router (app/routers/procurement.py:37):
dependencies=[
Depends(require_portal_role(UserRole.pm, UserRole.office)), # role gate
Depends(require_feature("procurement")), # entitlement gate
]
A user passes only if their role is allowed and their org owns the feature. Owner bypasses the role half but not the entitlement half — an owner of a Starter org still cannot reach a Pro-only feature that is route-enforced.
Entitlements are not self-serve — there is no in-app toggle for the Client. They are changed by GoBuild staff via the control plane's signed Admin API (require_cp guarded).
PATCH /orgs/{org_id} (app/routers/admin_api.py:430-478)Accepts a JSON body that can set tier / isolation / status and shallow-merge entitlements / settings:
{"entitlements": {"marketing": true}} → merged into the org's overrides.{"entitlements": {"marketing": false}} → explicit false override beats the tier default.{"entitlements": {"marketing": null}} → null removes the override key (app/routers/admin_api.py:467-469), so resolution falls back to tier_grants.{"tier": "pro"} → switches the default feature set for every key without an explicit override.The merge is shallow (app/routers/admin_api.py:466-472) and non-dict values are rejected (422). The endpoint returns {before, after, changed} so the control plane writes the authoritative audit entry (app/routers/admin_api.py:433-434, 478).
New pooled orgs are provisioned with a tier at creation (app/routers/admin_api.py:484-519, default starter) and an empty entitlements {} (app/models/organization.py:47).
Practical recipe for "sell only Marketing": create/keep the org on
starter, thenPATCH{"entitlements": {"marketing": true}}. The Starter tier grants nothing by default, so this yields Marketing-only — for the route-enforced marketing tools. Remember the Sales CC landing, Ads, and Social are not server-enforced, so they may still be reachable by URL.
The god-mode editor shows the FEATURES labels to whoever is packaging a sale. Some labels promise more than the code delivers. Flag these when quoting a Client.
The quickbooks key is labelled "QuickBooks / Xero sync" (app/services/entitlements.py:28), implying two accounting integrations at parity. Reality:
QuickBooksConnection model, connect/disconnect (app/routers/quickbooks.py:1-150).app/. The only Xero artifacts are config placeholders (xero_client_id, xero_client_secret, xero_redirect_uri, xero_configured — app/config.py:215-224) and a comment describing intended two-way sync. The tenant_credential model just lists "xero" as a possible provider string (app/models/tenant_credential.py:1,25). No code ever performs a Xero sync. Selling "Xero" today ships nothing.app/routers/ads_build.py:187-221). But pushing to Google is explicitly deferred: "Pushing to Google (mutations) is Phase 4 #3 and needs the developer token's Basic access — the 'Launch' button stays disabled until then" (app/routers/ads_build.py:4-6).app/routers/ads.py) is ⚪ dormant — the Google-Ads OAuth flow is "Dormant until the OAuth creds are set in .env … campaign management (Phases 4-5) also needs the developer token's Basic access" (app/routers/ads.py:3-6). Bing/Microsoft is scaffolded but not wired (app/routers/ads.py:6).social_publish.publish_post contains real Facebook + Instagram publish calls (app/services/social_publish.py:51-88, via app/integrations/social/meta). But it no-ops unless the org has a connected Meta account and Meta OAuth is configured: if not (s.meta_configured and acct and acct.status == "connected" and acct.token_enc): … (app/services/social_publish.py:54). The module header is candid: "Publishing to Meta is stubbed until an account is connected … everything else is live" (app/routers/social.py:4-5).| Feature | Gateable key? | Route-enforced? | Nav-gated? | Built? |
|---|---|---|---|---|
| Sales CC | sales |
❌ | 🟡 | 🟢 |
| Marketing tools (website/blog/email) | marketing |
🟢 | 🟡 | 🟢 |
| Marketing CC landing | marketing |
❌ | 🟡 | 🟢 |
| AI bar | ai_bar |
❌ | ❌ | 🟢 |
| Procurement | procurement |
🟢 | — | 🟢 |
| QuickBooks | quickbooks |
❌ | — | 🟢 |
| Xero | quickbooks (shared label) |
❌ | — | ❌ vaporware |
| Team & HR | hr |
❌ | 🟡 | 🟢 |
| Payroll | payroll |
🟢 | — | 🟢 |
| Safety / TRIR | safety |
❌ | ❌ | 🟢 |
| Ads / PMax | (none) | ❌ | — | 🟡 draft-only |
| Social | (none) | ❌ | — | 🟡 publish dormant |
Grounded in app/services/entitlements.py, app/models/organization.py, app/models/base.py, app/portal/deps.py, app/routers/admin_api.py, and the router set as of 2026-07-08. Edit this page and app/services/entitlements.py (matrix) together.