Platform Manual
Architecture, workflows, and user guide
18
Active projects
351
Tasks tracked
7
Fleet components
~$47
Per month (Azure)
BFAM Builders · Odin Research · Version 2.0 · July 2026

Contents

1. Platform overview 2. Architecture — the fleet 3. Data model 4. URLs and access 5. Workflows 6. Portfolio dashboard 7. Managing projects 8. Permitting workflow 9. Mural decision tree 10. Swimlane board 11. Quoting tool 12. Client access & Construction Updates 13. Admin guide 14. Deployment 15. Developer guide
01Platform overview

Fulcrum is a project and permitting management platform built by Odin Research for BFAM Builders. It manages the full lifecycle of QSR exterior remodel projects — from initial permitting through construction completion — and is deployed at:

https://bfam-fulcrum.centralus.cloudapp.azure.com

What Fulcrum replaces

WasNow
Permitting_Workflow_Tracker.xlsxLive task checklists per project
Email Transfer.xlsxSite details, contacts, activity log
Teams threadsStructured notes + automated notifications
Manual status updatesReal-time dashboard
Shared SharePoint spreadsheetClient read-only access (Construction Updates)
Manual quote reformattingBuilt-in quoting tool

Project types

TypeDescriptionPermits
RereLights and minor changesElectrical (sometimes), Mural (sometimes)
RemodelFull exterior, 8-day crewBuilding (stamped plans), Electrical, Mural
02Architecture — the fleet

Fulcrum is a monorepo where each component is named after a Rebel Alliance ship. Each ship has a single responsibility.

Ghost
Core API — the command center
FastAPI · Python · 16 routers · Pydantic v2
Tantive
Database — carries the cargo
PostgreSQL · SQLAlchemy · Alembic · 16 tables
Falcon
Integrations — connects everything
Teams · Smartsheet · Graph API · Key Vault
Phantom
Background jobs — runs quietly
Azure Functions · scheduled triggers
Radiant
Reporting — delivers information
Excel export · Jinja2 email · weekly reports
Razor
Frontend — what everyone sees
HTML · CSS · JS · 11 pages
Sentinel
Auth — controls who boards
Azure AD · MSAL · internal roles + Client B2B guests
03Data model
TableDescriptionKey fields
sitesPhysical locationsstore_number, address, jurisdiction, portal_url, portal_username
usersBFAM team membersemail, display_name, role_id, azure_oid, is_active
rolesInternal role lookupname (Admin, PM, Permitting, Site Supervisor)
projectsActive jobssite_id, project_type, status, is_archived, coordinator_name, site_supervisor_id
permitsPermit tracksproject_id, permit_type, status, permit_number
tasksChecklist itemspermit_id, name, status, sequence, assigned_to_id, due_date
task_dependenciesTask locking logictask_id, depends_on_task_id, required_status
task_status_historyImmutable change logtask_id, old_status, new_status, changed_by_id, changed_at
contactsAHJ and other contacts (construction personnel only)site_id, project_id, name, phone, email, contact_type
notesActivity log (construction personnel only)project_id, task_id, author_id, body, category, visible_to_client, created_at
photosProject photosproject_id, category, caption, visible_to_client, uploaded_by_id
daily_logsSite daily logsproject_id, log_date, notes, weather_conditions, created_by
quotesProject quotesproject_id, created_by, total, line_items (JSONB)
client_organizationsExternal client companies (e.g. a franchisee group)name
client_usersClient-side people (Azure AD B2B guests)client_organization_id, name, email, azure_oid
client_project_accessWhich org can see which projectclient_organization_id, project_id

Roles & identities

Every signed-in identity is either internal (a BFAM team member, one of the four roles below) or Client (an external contact tied to a client_organization via client_project_access grants — not a role on the users table at all). GET /users/me returns a discriminated shape (kind: "internal" or "client") that every Razor page branches on to decide what to render.

RoleCan do
AdminEverything — manage users, roles, client orgs/access grants, edit all data, reveal credentials
PMCreate projects, assign Site Supervisors, edit projects/tasks/permits/notes
PermittingCreate projects, grant client org project access, edit tasks/permits/notes
Site SupervisorFull read/write on tasks, notes, photos, daily logs, contacts
ClientExternal guest — read-only project.html (permit status, no Activity Log/AHJ Contacts) plus Construction Updates (client-visible photos, daily logs, quotes) for only the projects their organization has been granted
All four internal roles are full read/write on everything above — there is no internal read-only tier. The old viewer role (BFAM's own read-only accounts) was migrated to Site Supervisor, a real increase in access.

Task statuses

not_started in_progress blocked ext_blocked complete locked rejected na
04URLs and access
URLWhoWhat
/All internal usersPortfolio dashboard — all projects
/project.html?id=XInternal (full); Client (read-only, no Activity Log/AHJ Contacts)Project detail — permit tracks, tasks
/updates?project_id=XInternal (full); Client (client-visible only)Construction Updates — photos, daily logs, quotes
/photos.html?project_id=XInternal (full); Client (client-visible only)Full photo gallery
/board.htmlAll internal usersSwimlane task board
/quote.htmlInternal, gated per-quote by projectQuoting tool
/adminAdmin onlyUser management, site editing, templates
/docsAll internal usersThis manual
All pages require Microsoft login. BFAM team members sign in with their existing @bfambuilders.com accounts. Client contacts sign in as Azure AD B2B guests (their own company email, invited into the BFAM tenant) — there is no separate password or login page.
The old unauthenticated /checkers portal is retired. Client access now goes through /admin's Clients tab (Admin only) — see "Adding a client contact" below. There are two separate steps, in order, and skipping the first one is the most common mistake: creating the Fulcrum-side row does not by itself let anyone sign in.

BFAM team

NameEmailRole
Chad Buttreychadwick@bfambuilders.comAdmin
Ted Petersonted.peterson@bfambuilders.comAdmin
Jaci Maysjaci.mays@bfambuilders.comAdmin
Joshjosh@bfambuilders.comPM
Dan Fentondan.fenton@bfambuilders.comPermitting
Ronaldronald@bfambuilders.comSite Supervisor
Meadowmeadow@bfambuilders.comSite Supervisor
Juliejulie@bfambuilders.comSite Supervisor

Checkers corporate

Kevin Sorensen (sorensenk@checkers.com) and Russell Bernstine (bernstiner@checkers.com) previously had internal viewer accounts against the old /checkers portal. That portal and those accounts are retired — the migration deactivates both rather than auto-converting them, since a real Client identity needs an organization and explicit per-project grants first. A "Checkers Corporate" client_organizations row now exists (Admin → Clients) with per-project grants; add each contact as a client_users row under it once their Azure AD B2B guest invite has been accepted (see "Adding a client contact" below) — Russell hadn't been added there as of this writing.

05Workflows

New project workflow

1
Create the project
Click "+ New project" on the dashboard. Enter site details, type, coordinator, and permit tracks.
2
Tasks created automatically
Fulcrum reads JSON templates and creates the full task checklist. Tasks start as Not started or Locked.
3
Assign coordinator and tasks
Click "Unassigned" on the project detail page to assign from the user dropdown.
4
Work through the checklist
Update task statuses as work progresses. Each change is logged with timestamp and user.
5
Permits issued
Teams notification fires automatically when permits are complete.
6
Construction handoff
Project moves to Construction. Pre-construction logistics tasks created. CF handoff is manual.
7
Archive on completion
Archive the project from dashboard or detail page. Data is preserved, never deleted.

Adding a client contact

Two separate systems, in this order. Doing only step 4 (the common mistake) creates a row that can never sign in; doing only step 1 leaves them able to reach the tenant but with no client_users row, so Fulcrum 403s them as "not authorized."
1
Invite the guest in Azure AD (outside Fulcrum)
Entra admin center (entra.microsoft.com) → Identity → Users → All users → New user → Invite external user. Enter their real email (their own company address — this does not have to be @checkers.com specifically, any external address works) and send.
2
They accept the invite
The invite email lands in their inbox; they click through to accept. Until this happens they don't exist as a guest in BFAM's tenant yet, and signing in fails at Azure's login screen with AADSTS90072 ("does not exist in tenant ... needs to be added as an external user"), before Fulcrum is ever reached.
3
Create (or find) the client organization
Admin → Clients. Add a client_organizations row per external company (e.g. "Checkers Corporate") — one org can hold multiple contacts.
4
Add the client user
Under that org, "+ Add user" with their name and the exact email Azure will send as the login token's preferred_username claim. For a plain guest invite that's usually just their real email; for some B2B flows it's the mangled tenant UPN instead (e.g. name_company.com#EXT#@<tenant>.onmicrosoft.com) — if in doubt, have them attempt to sign in once and check what email the 403 error names, then edit the row to match (name/email are both editable inline, blur to save).
5
Grant project access
Same org card, "Project access" — check whichever project(s) apply (there's a Select all for granting everything at once) and click "+ Grant access to selected." A Client identity sees only what's granted here — nothing is inferred from anything else.
6
Verify before considering them onboarded
Each granted project row has a "View as →" link — opens that project exactly as any client of that org would see it, no login required. Use this to confirm the grant looks right before telling the contact their access is ready.

Automated workflows (Phantom)

TriggerAction
Every hourSmartsheet sync — pulls construction dates from Checkers
Monday 7am ChicagoWeekly blocker report emailed to team
Daily 8am ChicagoEscalation — Teams ping for tasks blocked 3+ days
Task → BlockedImmediate Teams notification
All permits issuedTeams notification to #construction-handoff
06Portfolio dashboard

The main view at / shows all active projects at a glance.

ElementDescription
Summary cardsTotal projects, permits complete, in progress, mural pending
Filter buttonsAll / Rere only / Remodel only / Complete / In progress
Progress bars% of tasks complete per permit track
Status badgesGreen=complete, Blue=in progress, Amber=ext blocked, Red=blocked, Gray=not started
+ New projectOpens new project modal
Board linkNavigates to swimlane board
Docs linkOpens this manual
Show archivedToggle archived projects
Dark modeSun/moon toggle — persists across all pages
Sign outSigns out of Microsoft account
07Managing projects

Project detail page

PanelWhat you can do
Project headerSee name, type, status, address. Click coordinator to assign.
Permit tracksCollapsible checklists. Click status dropdown to update. Hover task for × remove. Click permit number to edit.
+ Add taskAdd ad hoc tasks below each permit track.
Activity logAll notes newest-first, filterable by category. Add note at bottom, with a "Share with client" checkbox. Construction personnel only — never shown to a Client identity.
AHJ & ContactsJurisdiction contacts. + Add contact to add new ones. Construction personnel only — never shown to a Client identity.
Construction updatesLink out to the /updates page for this project — photos, daily logs, and quotes now live there instead of on this page.
Archive buttonNext to status badge. Confirms before archiving. Hidden for a Client identity.
A Client identity sees this same page, read-only: permit tracks and status, no edit controls, no Activity Log panel, no AHJ & Contacts panel. Those two are construction-team tools and are removed from the DOM entirely for a Client, not just hidden.

Archiving vs deleting

Projects are never hard-deleted. Archive hides them from the dashboard but preserves all data. Toggle "Show archived" to restore. Use the Unarchive button on an archived project to make it active again.

08Permitting workflow

Building permit — 14 tasks

#Task
1Identify AHJ + GIS lookup
2Look up requirements on city/county website
3Check for online portal, create account if available
4Submit via email/mail if no portal
5Discuss registration, plan submittal, additional requirements with AHJ
6Pull all required submittals and workflow
7Request required docs (insurance cert, bond, tax office, NOC, LOA)
8Complete contractor registration
9Submit registration (license, COI, BTR, bond)
10Coordinate sub-contractor registration
11Complete and submit permit application
12Coordinate with electrical and other sub trades
13Complete and hold all submission documents
14Pay fees and respond to all plan review comments
Entering a permit number: click the permit number area in the track header (shows "Add permit #" if empty). Type the number and press Enter.
09Mural approval decision tree

The mural permit follows a decision tree — options are tried in order, stopping when one is approved. Locked options unlock automatically when the previous is rejected.

OptionDescriptionUnlocks when
1Mural on both sidesAlways available first
2Mural on one sideOption 1 rejected
3Chevron paintedOption 2 rejected
4Parallelogram paintedOption 3 rejected
Jurisdiction-specific notes are in the activity log for each project — check these before submitting to know what each AHJ has accepted or rejected historically.
10Swimlane board

The board at /board.html shows all actionable tasks across all projects in five columns: Not started, In progress, Ext. blocked, Blocked, Complete.

  • Filter by person using the dropdown — shows one coordinator's full workload
  • Filter by project type (Rere/Remodel)
  • Locked and N/A tasks are hidden — only actionable tasks shown
  • Click any card to open the project detail page
  • Overdue tasks show in red
Use the person filter before weekly meetings — shows exactly what each coordinator is working on and what's blocked.
11Quoting tool

The quoting tool at /quote.html replaces the manual process of reformatting IAM quotes into BFAM format.

Creating a quote

1
Select a project
Pick an existing project or enter a new site name.
2
Add line items
Description, category, quantity, unit, unit cost, markup %. Total calculates automatically.
3
Import from IAM (optional)
Paste tab-separated IAM quote data into the import area. Line items populate automatically.
4
Review summary
Subtotal, markup amount, grand total, and margin % shown in the summary panel.
5
Export or save
Copy to clipboard (TSV for Excel), download as CSV, or save to the project in Fulcrum.

Saved quotes appear in the Quotes panel on the /updates Construction Updates page for the project. Click any saved quote to reopen and edit it.

12Client access & Construction Updates

Fulcrum has no separate client login. External contacts (a franchisee's ops team, a corporate contact like Checkers) are invited as Azure AD B2B guests into the same tenant BFAM already uses — they sign in with their own company email, same Microsoft sign-in flow as everyone else. A guest is only ever a Client identity: it belongs to a client_organizations row, and can only see the specific projects that organization has been granted via client_project_access — no organization-wide or portfolio-wide view.

What a Client sees

/project.html?id=X — read-only

  • Project name, type, status, address
  • Permit tracks and task-level status
  • Link to Construction Updates

/updates?project_id=X

  • Photos marked visible_to_client
  • Daily logs (read-only)
  • Quotes (read-only)

What a Client never sees

  • Activity Log (internal notes) — construction personnel only, on any page, not even a filtered view
  • AHJ & Contacts
  • Portal credentials, any other project's data, or any edit/create control
Requesting a project that isn't in that org's client_project_access grants returns a plain 404, the same as a project that doesn't exist — a Client should never be able to tell the difference between "not yours" and "doesn't exist."
The old unauthenticated /checkers portal (open to anyone with the URL, no login) is retired in favor of this. See the warning under section 4 for how to grant a new org access today.
13Admin guide

Admin page at /admin — accessible to Admin role only.

Users section

  • Lists all users with name, email, role, and status
  • Change role via dropdown — saves immediately
  • Deactivate blocks login without deleting data
  • Delete permanently removes the user and their allowlist entry
  • Add user form creates DB record and adds to allowlist
  • You cannot deactivate or delete yourself

Sites section

Click any field to edit inline — address, jurisdiction, portal URL, portal username. Changes save on Enter or blur.

Templates section

View the JSON task templates that define permitting checklists. Read-only in the UI. To modify templates, edit the JSON files in templates/ on the server.

After editing sentinel/users.json manually on the server, always validate it before restarting Ghost: python3 -c "from sentinel.middleware import validate_users_json; validate_users_json()"

Adding portal passwords to Key Vault

cd ~/fulcrum && source venv/bin/activate PYTHONPATH=/home/fulcrum/fulcrum python3 -c " from falcon.keyvault import set_secret set_secret('portal-pw-STORENUMBER', 'the-password') "
14Deployment
Production: https://bfam-fulcrum.centralus.cloudapp.azure.com — Azure VM, Central US

Infrastructure

ServiceDetailsCost/mo
Azure VMfulcrum-vm-01, Standard_F1als_v7, Ubuntu 24.04~$44
PostgreSQLLocal on VM (not managed service)$0
SSLLet's Encrypt via Certbot — auto-renews$0
DNSbfam-fulcrum.centralus.cloudapp.azure.com$0
Total~$44/mo

Deploy an update

# On your WSL machine — push changes git add . && git commit -m "your message" && git push # On the Azure VM — pull, migrate, restart ssh fulcrum@20.29.87.140 cd ~/fulcrum && git pull source venv/bin/activate PYTHONPATH=/home/fulcrum/fulcrum alembic -c tantive/alembic.ini upgrade head sudo systemctl restart fulcrum
Always run alembic upgrade head before restarting if the update includes new migrations — check git log --stat for new files under tantive/alembic/versions/. Take a pg_dump backup first for any migration that drops a column or deactivates data, not just an additive one.

Useful commands on the VM

# Check Ghost status sudo systemctl status fulcrum # View live logs sudo journalctl -u fulcrum -f # Restart Ghost sudo systemctl restart fulcrum # Restart Nginx sudo systemctl restart nginx # Check PostgreSQL sudo systemctl status postgresql # Database backup pg_dump -U fulcrum -h 127.0.0.1 fulcrum > backup_$(date +%Y%m%d).sql

Environment variables

All config lives in ~/fulcrum/.env on the Azure VM. Never commit this file. Key variables:

DATABASE_URL=postgresql+psycopg2://fulcrum:fulcrum@127.0.0.1:5432/fulcrum APP_ENV=production AZURE_TENANT_ID=07949940-2dd4-4dae-939b-59b0aad66c4a AZURE_CLIENT_ID=a40edb8e-bb0e-4e00-b190-e7ee2eee1c55
15Developer guide

Repository

github.com/tedbp19/fulcrum — private. Main branch is production.

Local development

cd ~/fulcrum source venv/bin/activate # activate Python env # Start PostgreSQL (WSL quirk) sudo -u postgres /usr/lib/postgresql/16/bin/pg_ctl \ -D /var/lib/postgresql/16/main \ -o "-c config_file=/etc/postgresql/16/main/postgresql.conf" \ -l /var/log/postgresql/postgresql-16-main.log start # Start Ghost uvicorn ghost.main:app --reload --host 0.0.0.0 --port 8000
For local dev, set APP_ENV=development and comment out AZURE_TENANT_ID in .env to enable the auth bypass. Never deploy with these settings.

Database migrations

cd ~/fulcrum # Generate migration after model changes PYTHONPATH=/home/tedbp/fulcrum alembic -c tantive/alembic.ini \ revision --autogenerate -m "description" # Apply migrations PYTHONPATH=/home/tedbp/fulcrum alembic -c tantive/alembic.ini upgrade head
After generating a migration, check for the PostgreSQL enum create_type=False issue — autogenerate doesn't handle shared enum types correctly. See existing migrations for the pattern.

Adding a permit template

Drop a new JSON file in templates/ following the existing format. Run scripts/migrate/04_seed_tasks.py to instantiate tasks for existing projects.

GitHub repository structure

fulcrum/ ├── ghost/ FastAPI backend (main.py, routers/, schemas/) ├── tantive/ Database (models/, alembic/, database.py) ├── falcon/ Integrations (teams.py, graph.py, keyvault.py) ├── phantom/ Azure Functions background jobs ├── radiant/ Reporting (blockers.py, excel_export.py) ├── razor/ Frontend HTML/CSS/JS + assets/auth.js ├── sentinel/ Auth (auth.py, middleware.py, users.json) ├── templates/ JSON permit task templates ├── scripts/ Migration scripts (run in order 01-11) ├── infra/ Nginx + systemd configs └── data/ Local data files (gitignored)