Test environment

Architecture

How the system works

Sudory is an event-sourced compliance platform. Sensors emit immutable readings; a pure projector grades them on read; aggregations fold outcomes into control verdicts and risks. Every framework dashboard is a query, never a cache.

  • sensors
  • event store
  • reference data
  • projector
  • projection
  • aggregations
  • frameworks

Sensors

Producers of evidence

Anything that emits a fact about a tenant lives here. DNS sensors run on Vercel and probe the internet. The MFA scanner runs in-database, computing facts from auth.mfa_factors and account_user_roles in the same transaction as the trigger that fires it. External integrations live on Fly.io with vault-stored OAuth credentials. Manual overrides at account_policies are the human-curated input lane.

  • /DNS sensors -> Vercel /api/scan/<check> per-sensor endpoints
  • /MFA scanner -> SQL trigger on accounts.require_mfa + daily pg_cron
  • /GitHub MFA (Fly.io) -> provider plugin reads the OAuth-scoped GitHub API
  • /Manual overrides -> account_policies.custom_config and applicability flags

Event store

readings, the append-only log

Every sensor scan writes one row. (account_id, anon_id) attribution, asset (hostname, "sudory", "github:<org>", ...), check_name, facts jsonb, recorded_at. Writes go through the create_reading function so the contract has one chokepoint. Nothing is mutated; new readings simply land.

  • /create_reading(asset, check_name, facts, account_id?, anon_id?, recorded_at?)
  • /service_role-only write path; PostgREST locked down
  • /Indexed on (asset, check_name, recorded_at desc) for latest-per-(asset, check) lookups

Reference data

policies + account_policies

Policies are the static catalog: framework -> chapter -> standard -> control -> assertion. Assertions carry a config that names the check and the fact_path the grader reads. Account_policies layer per-tenant overrides on top of the catalog: shallow merge into config at evaluation time, plus an applicability flag for "N/A on this account".

  • /policies.config -> check_name, fact_path, min/max, levels, ...
  • /account_policies.custom_config -> per-tenant override merged at eval
  • /policy_inherits_from -> jurisdictional law inherits from base concepts

Projector

evaluate() and the per-type graders

evaluate(check_name, facts, account_id, recorded_at) is a pure function: pick every active assertion whose check_name matches, merge any account override, dispatch to _evaluate_balance / _evaluate_level / _evaluate_freshness / _evaluate_flow with (facts, recorded_at, config). Each per-type grader returns pass / fail / warn / skip plus a message. No state, no side effects.

  • /_evaluate_balance -> numeric / boolean / array length within (min, max)
  • /_evaluate_level -> ranked enum at or above min_level
  • /_evaluate_freshness -> recorded_at within max_days
  • /_evaluate_flow -> AND/OR over multiple sub-checks

Projection

outcomes (live view)

The outcomes table is actually a view: SELECT FROM readings r CROSS JOIN LATERAL evaluate(r.check_name, r.facts, r.account_id, r.recorded_at). Every read computes from current readings + current policies + current overrides. There is no cache to invalidate. This kept us honest after override-flip staleness bit us in production.

  • /security_invoker = true -> RLS on readings cascades through
  • /No INSERT path; outcomes are derived, never written
  • /Bounded compute: per-account scoped queries push account_id into the LATERAL

Aggregations

control verdicts, risks, evidence

Three SQL functions read the outcomes view and fold it into higher-level views the dashboard consumes. compute_control_verdict walks the inheritance closure of a control and reduces per-asset outcomes to a single pass / fail / warn / manual / N/A verdict. get_account_risks finds the nearest control ancestor for each failing assertion and dedups cascade roots. get_control_evidence is the drill-down list per assertion, latest-per-asset.

  • /compute_control_verdict(account_id, control_id) -> pass | fail | warn | manual | N/A
  • /get_account_risks(account_id) -> ranked failing controls + framework mapping
  • /get_control_evidence(account_id, control_id) -> per-asset latest outcome

API surface

auth-gated read endpoints

Every public read endpoint enforces a session and verifies membership (or RLS pushes that down for us). The CLI hits the same endpoints as the dashboard. /api/scan/<sensor> is the exception: anonymous DNS scanning is the only intentionally-public write path, and even there the asset is constrained to a real hostname.

  • //api/accounts/[slug]/frameworks -> framework-tree verdicts
  • //api/accounts/[slug]/risks -> risks register feed
  • //api/accounts/[slug]/mfa-status -> org enforcement + per-user MFA enrollment

One more thing

Outcomes is a projection, not a cache

Earlier versions stored outcomes in a table and refreshed it on scan completion. Override edits, policy seeds, and even sensor refactors all caused staleness, because the cache had too many invalidation paths to track. Moving outcomes to a live view eliminated the bug class by construction: the framework dashboard cannot show stale data because the dashboard query computes from current readings + current policies + current overrides every time.