Skip to main content
MeetCost is a single self-hosted Python service backed by Postgres, with three thin browser clients that carry no payroll data. The code is organised in four layers with a strict one-way dependency direction: HTTP depends on service, service depends on engine, and nothing in the engine knows about HTTP, the database, or the clock. This means every number the product displays can be traced to a single module — rates.py — without following a call graph through the rest of the codebase.

System overview

The three clients communicate exclusively with the operator-configured backend URL. There is no vendor-operated relay, no telemetry endpoint, and no analytics SDK. The egress allowlist (see below) is enforced in code before any socket can open, and its enforcement is asserted by a test in CI.

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:
Two documented departures from this formula:
  • 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 MixedCurrencyError rather 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:
No per-participant queries, no external calls. The estimate assumes every invitee attends for the full slot; post-meeting actuals (§6.4) replace this with real attendance.

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 are Numeric(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 in main.py is deliberate:
The guard is installed before anything else runs, so there is no window in which an outbound connection could slip out unchecked — not during database initialisation, not during OIDC key fetch, not anywhere. validate_deployment raises (rather than warns) in production for any of:
  • MEETCOST_ENCRYPTION_KEY is not set
  • MEETCOST_AUTH_MODE=dev (dev auth mode in production)
  • MEETCOST_SECRET_KEY is still the default value change-me-in-production
  • MEETCOST_ENFORCE_EGRESS_ALLOWLIST=false
  • MEETCOST_AUTH_MODE=oidc with no MEETCOST_OIDC_ISSUER
A MeetCost that boots without its data-residency guarantees is worse than one that does not boot, because the guarantee is the product.

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 in adapters/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 meeting
  • HRISProvider — read pay data from an HR system (BambooHR example ships in adapters/hris_bamboohr.py)
All three are read-only against the org’s own systems, and all are subject to the egress guard regardless of what they attempt. A new adapter that tries to call an unconfigured host will be blocked at the socket level.

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.