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:86confirms noRechtsträ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
systemdtimer (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), livepilots
(0002_rls.sql:33-34).
- Deny-by-default on sensitive tables:
audit_loghas 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 impersonatacrossapps/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
.envfiles. 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-51names 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)
| Service | Purpose | Region | Data path |
|---|---|---|---|
| Supabase (self-hosted on Hetzner) | DB, Auth, Realtime, Storage | EU (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. | EU | Email addresses, message content |
| Stripe Payments Europe Ltd | Subscriptions (Connect deferred to Phase C) | Ireland | Payment data (Stratify has no card/IBAN access) |
| PostHog | Analytics, autocapture: false (apps/web/lib/posthog/client.ts:9,15) | EU (eu.i.posthog.com) | Event data, explicit identify() only |
| Sentry | Error 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 gap | EU | Error payloads, PII sanitization enabled per dsgvo-data-flow.md:126 |
Vercel AI Gateway → Anthropic (claude-sonnet-4) | German signal-drafting assist | — | Draft signal content may reach prompts |
| Apple Push (APNs) / Expo Push API | iOS push routing | US | Device push tokens |
| Google FCM | Android push routing | US/EU | Device push tokens |
| web-push (VAPID) | Browser push, self-hosted protocol, not a SaaS vendor | — | Push 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-42—buildGdprExport(), 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).
Jenkinsfilestill 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)
| Control | Mechanism | Bypassable? |
|---|---|---|
| RLS on personal-data tables | auth.uid() = user_id, deny-by-default on audit_log/signal_deliveries | No — service-role key is the only bypass, app-gated |
| Audit-log append-only | Postgres BEFORE UPDATE/DELETE trigger raising an exception | No — DB-enforced, not app convention |
| Audit-log chain integrity | SHA-256 chain-hash + verifyChain() replay | Detects 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 check | Not verifiable from repo whether branch protection is actually applied server-side |
| Local pre-commit hooks | gitleaks, detect-private-key, large-file check | Yes, --no-verify — local-only |
| Nightly DB backup | pg_dump cron, 14-dump retention | n/a |
| GDPR export + erasure | Real code path, admin/compliance-triggered | n/a |
| Auth rate-limiting | GOTRUE_RATE_LIMIT_VERIFY=30, 10-min OTP expiry | n/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.