rates.py — without following a call graph through the rest of the codebase.
System overview
The four-layer architecture
The dependency direction is one-way: HTTP → service → engine. Nothing in the engine knows about HTTP, the database, or the clock.The engine layer
rates.py is the module to read first. Every number MeetCost displays is produced there, so an auditor asking “where did USD 649 come from?” has one file to read rather than a call graph to trace.
The core formula is:
- Hourly staff (
pay_basis="hourly") use the stated hourly rate directly, divided by 60 to get a per-minute figure. Annualising an hourly worker’s pay and dividing it back down reintroduces the 2,080-hour assumption that makes the number wrong for them. - Currency is carried through and never converted. A meeting whose participants hold records in different currencies raises
MixedCurrencyErrorrather than silently summing with a hidden exchange-rate assumption.
permissions.py sits alongside rates.py in the engine layer. It applies redaction rules — per-person breakdown suppression, small-meeting rounding — before a result leaves the engine. The banding function rounds totals to two significant figures for non-admin viewers of small meetings, so a precise total cannot itself be a salary disclosure.
Request paths
Scheduling estimate (§6.3)
One round trip, two queries. This path is called on every edit to the invitee list, so it is designed to fan out to nothing:Post-meeting summary (§6.4)
The provider tells MeetCost a meeting ended; MeetCost goes and reads what happened from the org’s own tenant:The webhook body supplies only the meeting ID. Attendance comes from an authenticated read against the org’s own directory or calendar tenant. A forged webhook cannot inject a fabricated cost — it can only trigger a lookup that will either find a real meeting or return nothing.
Storage schema
Money columns areNumeric(18, 4) — exact on Postgres, float-backed on SQLite, which is one reason SQLite is documented as development-only.
pay_records is versioned by effective_date: rather than updating a row in place, each pay change inserts a new row. The rate engine picks the latest record whose effective_date is on or before the meeting date. This means historical cost calculations remain accurate as pay changes over time.
Configuration zones
Configuration lives in two places deliberately, because the people who change deployment settings and the people who change policy settings are different people.Deployment settings — environment variables
Database URL, encryption key, egress allowlist, OIDC issuer, and SMTP credentials are all environment variables read once at boot. Changing any of them is a deploy.Policy settings — org_config table
Loading factor, nudge thresholds, disclosure policy, and the small-meeting threshold are seeded from environment variables at first boot and then live in the org_config table. A finance lead can change the loading factor from 1.30 to 1.35 by clicking in the admin UI — no platform engineer required. Each change is audited with a before-and-after value.
Boot sequence
The startup order inmain.py is deliberate:
validate_deployment raises (rather than warns) in production for any of:
MEETCOST_ENCRYPTION_KEYis not setMEETCOST_AUTH_MODE=dev(dev auth mode in production)MEETCOST_SECRET_KEYis still the default valuechange-me-in-productionMEETCOST_ENFORCE_EGRESS_ALLOWLIST=falseMEETCOST_AUTH_MODE=oidcwith noMEETCOST_OIDC_ISSUER
The egress guard
egress.py wraps socket.getaddrinfo and socket.socket.connect at the process level. A hostname is checked at resolution time, where the name is still available; the resulting IP addresses are remembered so a subsequent connect to a bare IP can be matched back to the hostname that produced it.
The allowlist is derived entirely from operator configuration — there is no hardcoded hostname anywhere in egress.py. Hosts appear in the allowlist only because an operator configured a URL that resolves to them: the database host, the OIDC issuer, the SMTP host, and any explicitly listed MEETCOST_ALLOWED_EGRESS_HOSTS. If a directory sync adapter attempts to reach a host that is not in the list, EgressBlocked is raised and surfaces as a 502 with a clear message.
Code layout reference
Extension points
Three protocols inadapters/base.py define the adapter interface, documented fully in Adapters:
DirectoryProvider— read people from the org’s own tenant (Google Workspace, Microsoft Entra ID)AttendanceCollector— read who actually attended a completed meetingHRISProvider— read pay data from an HR system (BambooHR example ships inadapters/hris_bamboohr.py)
What is deliberately absent
These are not gaps — they are conscious choices with documented rationale. No in-process background scheduler. Digest delivery and attendance polling are triggered by the org’s own scheduler (cron, Kubernetes CronJob, etc.). A thread that dies silently at 3am produces a report nobody notices is missing. External scheduling makes failures visible. No caching layer. Scheduling estimates are two indexed queries. Adding Redis would add a place where decrypted pay data could rest outside the encrypted database — the very thing the architecture is designed to prevent. No message queue. Webhook handling uses FastAPI background tasks. The ingest endpoint is idempotent on(provider, provider_meeting_id), so if a deployment outgrows background tasks, a queue can be put in front without any application changes.
No frontend build toolchain. The admin UI is server-rendered HTML with vanilla JavaScript. The browser clients are plain files. There is nothing here that warrants a Node.js build pipeline.