Stratify
Legal and compliance

Facts

docs/compliance/isms/register/facts.md

Source updated 03. Aug. 2026

Stratify — Verified Ground Truth (for ISMS drafting)

Compiled 1 August 2026 directly from the `stratify` repo (`docs/compliance/dsgvo-data-flow.md`, `docs/runbooks/`, `supabase/migrations/`, `apps/web/`, CI config, git history) plus three decisions confirmed by Toby in chat on 2026-08-01 (marked **[CONFIRMED]** below). Every other claim is cited to a repo path. This file is the single reference all other ISMS documents should cite — don't re-derive or restate these facts differently elsewhere.

Companion source corpus (ISO27k Toolkit, BSI, GDPR/DSG legal texts, EDÖB guidance — generic, non-company-specific material) lives at `~/thoughtful-toby/sedAI/compliance/sources/`, built for the sedAI ISMS and reused here rather than re-fetched. See that project's `compliance/README.md` for licence notes.

Entity

  • [CONFIRMED] No separate Stratify legal entity is incorporated yet. wait, what. Advisory

is the controller of record today — matching docs/compliance/dsgvo-data-flow.md:10's own placeholder ("Stratify (wait, what.)"). Track Stratify AG/GmbH incorporation as an open item; re-verify this fact before it goes stale.

  • docs/compliance/regulatory-roadmap-ch-eea.md:86 confirms no Rechtsträger im Handelsregister

exists yet for the eventual FINIG-licensed entity — this is a Phase-C-and-later concern, distinct from the controller-of-record question above, which matters today.

  • README.md:3-4: "Pre-Seed · Zürich · 2026."
  • Contact for data-subject requests: [email protected] (docs/compliance/dsgvo-data-flow.md:104).

Product

Regulated copy-investing platform, currently operating in a pre-license research-publication posture (docs/compliance/signalgeber-classification.md, regulatory-roadmap-ch-eea.md:50-62): non-personalized signals across seven fixed strategy mandates (Pelosi Tracker, Liquid Private Equity, AI Arms Race, Deutsche Credit, Europe Defense, Super-Investors, Obesity — README.md:5,42-48; catalog source of truth packages/shared/src/mandates.ts), distributed to subscribers (web + mobile + email + push), partners (B2B REST/webhook/MCP), and pilots (verified strategy authors). Phase C adds an FMA Liechtenstein VVG license, KYC, Stripe Connect payouts, and MiFID-II suitability on the same backend. Mandate count and names have changed at least twice in this repo's history (git log --oneline -- packages/shared/src/mandates.ts) — re-verify against mandates.ts directly before citing a specific count or name set, don't trust a cached read of README.md from outside this worktree.

Infrastructure — current state (production is live, self-hosted)

Unlike a target-architecture writeup, this is real, running infrastructure — treat every control judgment below as "does this exist," not "will this exist."

  • Production cut over from Vercel/Supabase-Cloud to self-hosted Docker on Hetzner on

2026-06-03 (docs/runbooks/hetzner-cutover.md:1-6, docs/runbooks/production-cutover.md:1-9, docs/runbooks/db-recovery.md:6-9,92-95). Vercel is deactivated.

  • Host: single Hetzner box, 128.140.8.187, public IP firewalled to Tailscale-only access

(docs/runbooks/deploy.md:3-19), region "Hetzner nbg1" (docs/runbooks/hetzner-cutover.md:77).

  • Deploy mechanism: pull-based systemd timer (stratify-deploy.timer, polls every 2 min) — not

a push-based CI/CD deploy (docs/runbooks/deploy.md:1-24; the old GitHub Actions deploy workflow "is gone. It never once succeeded").

  • Stale documents, do not treat as current: docs/compliance/dsgvo-data-flow.md (lists Vercel

as hosting processor), docs/compliance/pentest-rfp.md (scoped against "Vercel EU"/"Supabase EU"), infra/hetzner/README.md, top-level README.md hosting/tech-stack sections, docs/secrets.md (says "Vercel project envs in production"). All predate or were not updated after the 2026-06-03 cutover. The ISMS register treats the Hetzner runbooks as source of truth for hosting and flags the above as needing a documentation refresh — a real, current gap (GOV-13 candidate).

  • Database: self-hosted Supabase EU (Postgres + Auth/GoTrue + Realtime + Storage) on the same

Hetzner box, not Supabase Cloud (docs/runbooks/db-recovery.md:6-9).

  • Web: Next.js 15, Mobile: Expo SDK 52 (README.md:113-121), both still reference Vercel as the

historical deploy target in parts of the docs — deploy today runs through the Hetzner timer/Docker path described above.

Database & access control

  • RLS is real and enforced on ≥23 tables including subscribers, strategy_mandates,

signals, signal_deliveries, partner_api_keys, audit_log, waitlist, pilot_kyb_checks, partner_kyb_checks, platform_settings, compliance_gate_signoffs (grep across supabase/migrations/*.sql).

  • Core self-access pattern: auth.uid() = user_id (supabase/migrations/0002_rls.sql:19-30,

repeated in 0005_roles.sql, 0007_partner_self_serve.sql, 0011_signal_drafts.sql, 0014_onboarding.sql, 0015_pilot_strategies.sql).

  • Public/world-readable by design: strategy_mandates (0002_rls.sql:17), live pilots

(0002_rls.sql:33-34).

  • Deny-by-default on sensitive tables: audit_log has RLS enabled with **no SELECT policy at

all** (0002_rls.sql:49-50) — reads only via the Supabase service-role key, which structurally bypasses RLS. Same pattern for signal_deliveries (0002_rls.sql:41).

  • No separate Postgres bypass role — the "admin path" is the service-role API key

(getSupabaseService(), used in apps/web/app/(admin)/subscribers/actions.ts:25,60,105), gated at the application layer, not by a distinct DB role.

  • RBAC: AppRole = 'subscriber' | 'pilot' | 'compliance' | 'founder' | 'admin'

(apps/web/lib/auth/roles.ts:4, resolved per-user from subscriber_roles). Centralized guards in apps/web/lib/admin/guard.ts:1-47 (requireAdminRead, requireAdminWrite, requireAdminOnly, requireComplianceActor). All elevated writes call recordAudit().

  • No impersonation/view-as feature exists (grep -i impersonat across apps/web → zero

matches) — this is a factual difference from sedAI's ISMS, don't carry that assumption over.

Authentication

  • Web sign-in uses 6-digit email OTP codes, not magic links — delegated entirely to Supabase

GoTrue's built-in flow (apps/web/app/sign-in/sign-in-form.tsx:72,106-110). Password reset and invite also use codes (apps/web/app/reset-password/reset-password-form.tsx:104).

  • Full rationale in docs/adr/0002-email-code-auth-instead-of-links.md:1-93: magic links were

completely broken in production (Traefik 503 on /auth/v1; mobile deep-link also broken) — zero successful magic-link logins were ever recorded. Codes shipped 2026-07-20 as the fix.

  • GoTrue config (infra/hetzner/supabase/docker-compose.stratify.override.yml:38-42):

GOTRUE_MAILER_OTP_EXP=600 (10 min — deliberately reduced from GoTrue's 24h default because "a 10^6 keyspace against a per-IP-only verify limiter is brute-forceable when the target address is known"), GOTRUE_MAILER_OTP_LENGTH=6, GOTRUE_RATE_LIMIT_VERIFY=30.

  • Known, acknowledged, unfixed gap: email-change is still link-based and still unreachable

(docs/adr/0002-email-code-auth-instead-of-links.md:61-69). A password-based fallback also exists alongside codes (line 50) — not pure passwordless.

  • No MFA is documented for user or admin/founder/compliance-role accounts.

Audit log

  • Chain-hash mechanism: SHA-256(prev_hash || canonical(payload)) over a deterministic,

key-sorted JSON event (apps/web/lib/audit/chain.ts:34-39,60-67).

  • verifyChain() re-walks the table in pages of 1000, reports the first divergence

(chain.ts:101-137).

  • Append-only is enforced at the database layer, not just app convention: a Postgres trigger

function audit_log_no_mutate() raises on BEFORE UPDATE/BEFORE DELETE (supabase/migrations/0001_init.sql:124-154). Combined with the RLS deny-all-SELECT policy above, this is a genuinely implemented, non-bypassable-by-app-bug control.

  • Dedicated incident runbook for chain corruption exists: docs/runbooks/db-recovery.md:71-82

(§3.3, "P0 Compliance" — forensic snapshot, service-role lockdown, counsel notification within 24h, DPO note re: GDPR Art. 33).

Secrets management

  • Policy: no secrets in .env files. Dev = macOS Keychain (security add-generic-password,

sourced via ~/.keychain-secrets.sh); prod = env vars injected into the Hetzner Docker containers (docs/secrets.md:1-90, though its "Vercel project envs in production" line at docs/secrets.md:6-7 is stale — see hosting note above).

  • .env.example:34-51 names required secret vars (values never committed):

STRATIFY_SUPABASE_SERVICE_ROLE, STRATIFY_STRIPE_SECRET_KEY, STRATIFY_STRIPE_WEBHOOK_SECRET, AWS_SES_ACCESS_KEY_ID/AWS_SES_SECRET_ACCESS_KEY/AWS_SES_SMTP_PASSWORD, STRATIFY_VAPID_PRIVATE_KEY, STRATIFY_SENTRY_AUTH_TOKEN, STRATIFY_DATABASE_URL, STRATIFY_AI_GATEWAY_API_KEY, STRATIFY_EXPO_ACCESS_TOKEN, STRATIFY_CRON_SECRET, STRATIFY_PARTNER_WEBHOOK_SECRET, STRATIFY_SUPABASE_WEBHOOK_SECRET.

  • Rotation table exists (docs/secrets.md:76-83, e.g. "engineer leaves → rotate every secret they

could have read") — aspirational given single-engineer reality below, but documented.

Third parties / sub-processors (real, verified against code)

ServicePurposeRegionData path
Supabase (self-hosted on Hetzner)DB, Auth, Realtime, StorageEU (Hetzner nbg1)All app data
AWS SES (eu-central-1)Transactional email — replaced Resend, confirmed via apps/web/lib/email/ses.ts:1-24 + no resend package in apps/web/package.json. dsgvo-data-flow.md's processor table still names Resend — stale, needs correction.EUEmail addresses, message content
Stripe Payments Europe LtdSubscriptions (Connect deferred to Phase C)IrelandPayment data (Stratify has no card/IBAN access)
PostHogAnalytics, autocapture: false (apps/web/lib/posthog/client.ts:9,15)EU (eu.i.posthog.com)Event data, explicit identify() only
SentryError telemetry, server-side only — no client/browser Sentry init found (apps/web/instrumentation.ts:1-18, no sentry.client.config.ts) — front-end JS errors may not be captured, flagged as a visibility gapEUError payloads, PII sanitization enabled per dsgvo-data-flow.md:126
Vercel AI Gateway → Anthropic (claude-sonnet-4)German signal-drafting assistDraft signal content may reach prompts
Apple Push (APNs) / Expo Push APIiOS push routingUSDevice push tokens
Google FCMAndroid push routingUS/EUDevice push tokens
web-push (VAPID)Browser push, self-hosted protocol, not a SaaS vendorPush subscription endpoints

Not present: no SMS provider (no Twilio-equivalent in apps/web/package.json or apps/mobile/package.json), no additional analytics/AI vendor beyond the above.

Data categories, retention, legal basis, subject rights

Already comprehensively mapped in `docs/compliance/dsgvo-data-flow.md` (v2.1, 2026-05-19, Owner: Antonios) — sections 2 (Datenkategorien), 3 (Rechtsgrundlagen Art. 6 DSGVO), 6 (Drittlandsübermittlungen), 7 (Subjects-Rechte Art. 15–22), 10 (AVV/DPA links). This ISMS register cites that document rather than re-deriving it — see PRIV-01/PRIV-02. Two corrections needed when that document is next revised: (1) hosting processor is Hetzner self-hosted, not Vercel; (2) transactional-email processor is AWS SES eu-central-1, not Resend.

Data subject rights — actually implemented, not just documented

  • Export: apps/web/lib/admin/gdpr-export.ts:14-42buildGdprExport(), read-only, pulls

subscribers, user_active_mandate, onboarding_profile, subscriber_roles, web_push_subscriptions, up to 500 audit_log rows, by userId.

  • Erasure: apps/web/app/(admin)/subscribers/actions.ts:85-160

(eraseSubscriberData), gated by requireComplianceActor(). Hard-deletes web_push_subscriptions, onboarding_profile, user_active_mandate, subscriber_roles; anonymizes the subscribers row in place (email → erased+<id8>@stratify.invalid, name nulled, status → banned, Stripe ID + referral attribution cleared); optionally hard-deletes the Supabase Auth user. The erasure event itself is written to audit_log as gdpr.erasure.completed — the audit trail of the deletion is deliberately retained.

  • Both actions are admin/compliance-triggered, not self-service by the subscriber — a real

Art. 15/17 process exists, but there is no /account self-service export or delete button yet (matches dsgvo-data-flow.md:104,108's own "mid-term"/"Phase B" framing for self-service).

CI/CD and secure-SDLC

  • Active CI is GitHub Actions (.github/workflows/dos-ci.yml:1-95): static (lint+typecheck),

test, secret-scan (gitleaks on the PR diff), build, gated behind one required check (verify). `.github/README.md` claiming "no GitHub Actions, use Jenkins" is stale — dated 2026-05-21, superseded by dos-ci.yml (added 2026-07-04, confirmed current in docs/adr/0001-adopt-development-operating-standard.md:12-14).

  • Jenkinsfile still exists (typecheck/lint/test/build/OpenAPI-lint + mobile EAS build on tag) with

deploy stages explicitly commented out — unclear from the repo alone whether a live controller still runs it, or it's vestigial from before the GitHub Actions gate existed.

  • No dependency/SCA scanning in either pipeline — no pnpm audit, no Dependabot config, no

Renovate config (confirmed absent). This is a real, current gap, unlike sedAI's equivalent control which had pip-audit/pnpm-audit already wired.

  • Local pre-commit hooks mirror the CI secret scan (.pre-commit-config.yaml:1-31: gitleaks,

detect-private-key, large-file check) — bypassable via --no-verify, self-documented as such.

  • CODEOWNERS exists but is fully commented out / inert (CODEOWNERS:1-16) — "At Tier 1 (solo)

this file routes nothing."

  • No branch-protection/ruleset config is a repo file (GitHub-side setting, not verifiable from

repo contents) — CONTRIBUTING.md:14-19,56-63 *describes* one (PR required, verify check, linear history, no force-push, 0 required human approvals at "Tier 1").

Team / access — real, current gap

  • git log --format='%an <%ae>' | sort -u → exactly one human identity (Tobias Temmen, two

email variants) plus one AI coding agent identity across all 181 commits. No second human committer in git history.

  • CONTRIBUTING.md:37-42: "Human approval is optional at Tier 1 (you are the accountable human of

record)" — the mandated review is an AI-agent /code-review run in a fresh context, not a second human reviewer.

  • [CONFIRMED] Antonios Stergatos (co-founder, Product & Compliance) owns the FMA license +

DSGVO workstream and is named Owner: Antonios on dsgvo-data-flow.md — but does not appear in git history. Philipp Sprenger (co-founder, Structuring & Sales) likewise does not touch code. Consistent with a pre-seed stage where compliance/business co-founders haven't touched the codebase directly — this is a real segregation-of-duties gap (Annex A 5.3/8.4), document it honestly rather than claim a control that doesn't exist yet.

  • [CONFIRMED] Role assignment for this ISMS (GOV-03): Toby = ISMS Owner + Security Officer

(accountable for the technical control surface — matches sole-committer reality above); Antonios = DPO / Privacy Owner (matches his existing dsgvo-data-flow.md ownership and README-stated role). Philipp is not assigned an ISMS role.

Penetration testing

  • docs/compliance/pentest-rfp.md:1-108 (dated 2026-05-19) is **RFP/vendor-shortlist stage only —

no test has been executed. Status line: "Vendor-Shortlist + RFP-Brief. Sende vor Public Launch." Scope, priorities (RLS-bypass and audit-log tamper-resistance flagged P0), a 4-vendor EU shortlist, timeline template, and €25k budget cap are all drafted. No findings doc, no re-test log, nothing indicating the RFP was sent.** Note this document's infra-scope section still says "Vercel EU" — needs updating to Hetzner before it's sent to any vendor.

Backup / disaster recovery

  • docs/runbooks/db-recovery.md:1-121 (status: "Operativ. Quartalsweiser Test verpflichtend").

Mechanism: nightly pg_dump/psql against the Hetzner stratify-db container (not Supabase Cloud PITR, which is demoted to a legacy fallback), via scripts/hetzner-backup-postgres.sh, 14-dump retention, nightly 05:30 UTC cron. RPO ≤ 24h, RTO ≤ 60 min (stated SLOs, line 57).

  • Restore has never been tested. Setup checklist item unchecked (line 27: "einmal manuell

getestet"); decision log's last row (line 117) reads "TBD | Erster vollständiger Hetzner-Restore-Drill abgeschlossen." A real, current, honestly-documented gap.

Logging / monitoring

  • Structured via Sentry (server-only, tracesSampleRate: 0.1) + PostHog EU (see sub-processor

table). No log-retention configuration, alerting-rule definitions, or on-call process documented in-repo — appears managed outside the repo (dashboard config), which is itself an ISMS evidence gap worth naming rather than assuming exists.

Existing security controls already operating (real, not aspirational)

ControlMechanismBypassable?
RLS on personal-data tablesauth.uid() = user_id, deny-by-default on audit_log/signal_deliveriesNo — service-role key is the only bypass, app-gated
Audit-log append-onlyPostgres BEFORE UPDATE/DELETE trigger raising an exceptionNo — DB-enforced, not app convention
Audit-log chain integritySHA-256 chain-hash + verifyChain() replayDetects tampering after the fact; doesn't prevent a compromised service-role key from writing a valid next link
CI gate (dos-ci.yml)lint, typecheck, tests, gitleaks secret scan, build — required verify checkNot verifiable from repo whether branch protection is actually applied server-side
Local pre-commit hooksgitleaks, detect-private-key, large-file checkYes, --no-verify — local-only
Nightly DB backuppg_dump cron, 14-dump retentionn/a
GDPR export + erasureReal code path, admin/compliance-triggeredn/a
Auth rate-limitingGOTRUE_RATE_LIMIT_VERIFY=30, 10-min OTP expiryn/a

No SECURITY.md exists in the repo.

Open items this file does NOT resolve

  • Whether Stratify AG/GmbH gets incorporated, and when — re-verify entity status before treating

"wait, what. Advisory is controller" as current.

  • Whether the stale hosting/vendor references in dsgvo-data-flow.md, pentest-rfp.md,

infra/hetzner/README.md, docs/secrets.md, and top-level README.md get corrected — tracked as a GOV-13 nonconformity candidate, not fixed by this ISMS effort itself.

  • Whether a second human reviewer/committer joins before or after this documentation set is

approved.

  • Whether the pentest RFP is actually sent, and to whom, once the infra-scope section is corrected.