Our own audit. We ran /production-audit on Foundry's own public repo. Check it against the source.
All reports →Audit report · v0.2.1
Foundry
Release recommendation
Safe to ship
Static review + runtime pass
No risk survived verification; 60 improvements to schedule, led by the work owed before the first owned report.
Why this verdict
Static review of commit 8328077 plus a runtime pass against a local build (Playwright and axe across 19 routes, local load tests, and live unauthenticated GETs of public pages); signed-in surfaces, real devices and Supabase dashboard state were not exercised. Eight findings were proposed as risks or rated high, and each was sent to a skeptic on a different model or settled by a runtime test; all were downgraded to improvements, with the reasoning recorded on each. The mobile gate failed at 8328077 and was fixed in this release before publication, then re-probed: 19 of 19 routes reflow at 320 to 430 and in landscape, and the deck works on 9 of 9 dots. Both required gates remain at risk, not met; neither caps the verdict.
Scorecard
Mobile: Required by the release policy. At 8328077 the gate failed: deck dots opened the wrong slide on 7 of 9, text-only 200% clipped the hero's lead and Install CTA, and two skill pages overflowed at 320. Fixed in this release before publication and re-probed with a scripted matrix: 0 overflow and 0 clipped text on 19 of 19 routes at 320, 360, 393, 430 and landscape; 9 of 9 dots; the install command reachable by keyboard. At risk, not met: at 200% text on a 393 phone five routes still scroll sideways, and the software keyboard, iOS Safari and Android Chrome rows were not run on real devices.
Instrumentation: Required by the release policy. All eight planned events are captured on their planned identities, and client and server identities join through the email alias and the reports-layout identify (code-traced). At risk: the plan has no guardrail or owner line, nothing records a dashboard or an owner, a failed send leaves no event, and sign_in_failed breaks the identity rule. The live funnel was not observed.
What's solid (12)
Report access control
Owner-or-admin is enforced in the data layer itself (lib/reports.ts canView, getReportFor, getPublicReport); generateMetadata goes through the same gate, a non-owner and a missing slug are the same 404, the slug guard blocks traversal, and /reports content is force-dynamic so no per-user page is cached.
Fail-closed auth
With the Supabase env unset, the action, the confirm route, middleware and the report pages all deny and log once; identity comes from getUser (a server-validated JWT), never cookie contents, and ADMIN_EMAILS unset means no admin.
Origin pinning
The magic-link target comes from a pinned SITE_ORIGIN before any forwarded header, confirm and sign-out redirect only to fixed paths, and there is no open redirect.
Rendering sinks
react-markdown runs without raw HTML, renders only allowlisted repo files, and the only dangerouslySetInnerHTML is constant JSON-LD; the sent-email echo renders as escaped text.
Security headers
CSP, frame-ancestors none, nosniff, a two-year HSTS and a Permissions-Policy are live on both hosts and match next.config.ts; no x-powered-by, no browser source maps, no debug endpoints.
Install channel protection
main carries an active ruleset requiring a pull request and the green verify check, with no bypass actors, deletion blocked and force-push blocked; CI uses pull_request, never pull_request_target, and holds no secrets.
Version marker
make validate fails when any SKILL.md frontmatter, version-check URL or SECURITY.md line drifts from VERSION, and when SKILL_SLUGS and skills/ disagree; a missing VERSION serves 0.0.0, which can never tell an install it is behind.
Keep-alive endpoint
/api/health uses a cookie-free client with a 10 second bound, counts only a parseable timestamp as healthy, returns 503 on every other path, sanitizes what it logs, and serves a cached verdict inside 60 seconds.
Analytics never block a flow
Server capture is env-gated, never awaited and flushed immediately for auto-stopping machines; browser calls are guarded; skill_version_check carries no identity and captures only allowlisted values.
Runtime file reads
Every file the site reads at runtime is copied by the Dockerfile, and every read degrades without a 500: a bad report is skipped with a log, a missing doc renders a placeholder, a missing SKILL.md is a 404.
Accessibility floor
A skip link to main on every route, a visible focus ring outside any CSS layer that wins over outline-none, reduced-motion coverage for every CSS animation, correct aria-current and aria-pressed, and axe clean on 8 of 9 routes.
Design tokens
Colors live only in @theme tokens matching DESIGN.md, no functional text falls under 11px, corners stay at 4px or less, and the hero carries the only tracked-caps kicker.
Risks to weigh (0)
Findings with a path to harm in production. These are what the verdict weighs.
No risks surfaced. Nothing here has a path to harm in production.
Improvements (60)
Safe today. Ways to make the app more robust, observable, and consistent over time. These do not affect the verdict.
SEC-01mediumThe documented way to publish an owned report is to commit it to this repository, which is public, so the owner gate cannot protect it.security · skills/production-audit/SKILL.md:147
Proposed high. A skeptic on a different model confirmed every fact (public repo, documented step, no validate guard, no owned blob in history across 3 commits and 4 blobs) and rated it medium: the harm is hard to recover but needs the maintainer to follow the documented step for a client who does not exist yet, so high is not earned. Matches the 2026-08-14 self-audit's SEC-01.
SEC-02mediumInstalled skills tell the agent to read reference files from the mutable `main` branch at run time, and the version check prints the server's response body into the agent's context.security · skills/document/SKILL.md:15
CON-01mediumA mail scanner that prefetches links consumes the single-use magic-link token before the person clicks.concurrency · app/auth/confirm/route.ts:64 · GET
OPS-01mediumNo flow has an alert with a named receiver, Fly stdout is the only log sink, and there is no rollback runbook.operability · fly.toml:41
OPS-02mediumA failed magic-link send leaves no log line and no event, and no sign-in event carries the host it started or finished on.operability · app/unlock/actions.ts:40 · requestMagicLink
TEST-02mediumThe invariants script is not shipped with the installed skill, so an adopter's run has schema-only validation while SKILL.md says make validate enforces the rules.testing-confidence · Makefile:39 · sync-bundled
TEST-03mediumTwo shipped skills disagree on whether a not-assessed required gate holds the release.testing-confidence · skills/foundry/SKILL.md:85
TEST-04mediumNo application code has a test, and the security controls sit behind seams a test cannot reach.testing-confidence · lib/supabase.ts:1
SHIP-01mediumThe deploy builds whatever is in the maintainer's working tree, never runs make validate, and stamps no revision, so nobody can say which commit is live.release-safety · Dockerfile:16
51 more · hygiene and polish›
SEC-03lowThe two anonymous endpoints with external side effects have no throttle at any layer: the magic-link action sends mail, and /api/version turns each valid GET into a PostHog event.security · app/unlock/actions.ts:35 · requestMagicLink
Proposed medium risk by reliability and performance. A different-model skeptic confirmed no layer throttles the action (Next's server-action Origin check accepts a scripted matching Origin) but found no victim today: both committed reports are public with no owner, so a blocked sign-in costs nobody access. Low improvement until the first owned report.
SEC-04lowSupabase session cookies are script-readable and not marked Secure (the @supabase/ssr defaults), and the CSP allows inline script plus an unused PostHog assets host.security · next.config.ts:24
CON-02lowSign-out ignores the result of signOut, and when the access token has expired and the refresh fails on a transport error, auth-js returns early without clearing the session.concurrency · app/auth/signout/route.ts:16 · POST
CON-03lowThe /api/health throttle is not single-flight: concurrent requests that arrive before the first verdict each run their own database query.concurrency · app/api/health/route.ts:90 · GET
CON-04lowNo deploymentId is set, so a page loaded before a deploy and submitted after it sends a server action ID the new build does not know.concurrency · next.config.ts:29
REL-01lowThe magic-link redirect was pinned to foundry-skills.fly.dev while the canonical host is foundry.thehorizonfoundry.com, so a sign-in started on the custom domain failed or finished on the wrong host.reliability · fly.toml:17
Proposed medium risk by infra and reliability. A different-model skeptic confirmed the mechanism (pinned origin, PKCE host-only verifier, both hosts live, no cross-host redirect) and downgraded it: both committed reports are meta.public with no owner (a grep for owner in reports/ returns nothing), so a failed sign-in costs a visitor an error page and no data or access.
REL-02lowThe cookie-reading Supabase clients carry no fetch timeout, and a signed-in report page makes four sequential getUser calls because getSessionUser is not memoized per request.reliability · lib/supabase.ts:40 · getSupabaseServer
Proposed medium risk. A different-model skeptic confirmed no timeout exists in supabase-js 2.110.2, auth-js or @supabase/ssr 0.12.0, but found signed-out visitors make no Supabase call, the only real signed-in user is the admin, and the hang needs a narrow outage shape (accept and never answer). Low, as REL-04 in the 2026-08-14 self-audit.
REL-03lowSign-in errors collapse into two untruthful messages: every send error reads "Try again in a minute" and every confirm error reads "expired".reliability · app/unlock/actions.ts:40 · requestMagicLink
REL-04lowgetSessionUser discards the getUser error, so a Supabase outage or a paused project renders a signed-in owner as signed out, with nothing logged.reliability · lib/supabase.ts:166 · getSessionUser
REL-05low`make install` reports success and exits 0 when ~/.claude/skills does not exist.reliability · Makefile:18 · install
REL-06lowThe runtime report guard checks three fields, but the readers also touch meta.date and findings.reliability · lib/reports.ts:44 · readReport
A11Y-01lowThe overview deck's dot targets overlapped, so a tap or click on a dot's centre opened the next slide on 7 of 9 dots.accessibility · components/SlideDeck.tsx:197
A11Y-02lowThe deck's Prev and Next become disabled at either end, which drops keyboard focus to the page body.accessibility · components/SlideDeck.tsx:210
A11Y-03low/behind skips from h1 to h3 on content slides, and its page heading and tabs sit outside main.accessibility · app/behind/layout.tsx:18
A11Y-04lowText-only enlargement clipped the hero and still scrolls five routes sideways.accessibility · app/page.tsx:117
A11Y-05lowInline code in rendered docs had no wrap rule, so two skill pages scrolled sideways at 320 (1.4.10).accessibility · app/globals.css:312
A11Y-06lowFocused controls hide under the sticky header at phone width, and the site sets no scroll-padding-top (2.4.11).accessibility · components/SiteHeader.tsx:21
A11Y-07lowSeverity chip text sits under 4.5:1 on its own tint (1.4.3).accessibility · components/report-ui.tsx:52
A11Y-08lowScorecard jump buttons scroll without moving focus, and the smooth scroll ignores reduced motion.accessibility · components/ReportScorecard.tsx:36
A11Y-09lowThe two copy buttons share one accessible name, and "Copied" is only an aria-label change, not a status message.accessibility · components/InstallBlock.tsx:131
A11Y-10lowThe sign-in field's border is 1.54:1 against its fill (1.4.11), the field is not marked invalid on error, and its 14px text will probably make iOS zoom on focus.accessibility · app/unlock/page.tsx:86
A11Y-11lowScrolling code blocks and narrow-width tables cannot be reached by keyboard (2.1.1).accessibility · app/globals.css:327
UI-01lowThe hero terminal shows /foundry check printing SAFE TO SHIP, but foundry assesses nothing itself and the site elsewhere promises a scope-qualified verdict.ui · components/Terminal.tsx:57
UI-02lowThree different predicates decide which public report is the self-audit, and copy elsewhere hardcodes one report's numbers.ui · app/page.tsx:104
UI-03lowReport surfaces blur failed, missing and stale states.ui · components/ReportView.tsx:221
UI-04lowThe sign-in error uses the critical severity hue outside the six places DESIGN.md licenses it.ui · app/unlock/page.tsx:93
INF-01lowThe site ran next 16.3.1 (and sharp 0.35.3), inside the ranges of nine Next.js advisories including three critical RCEs, with an image optimizer the app never uses.infra · package.json:19
npm rates the range critical. A skeptic ran the standalone build locally and probed it: /_next/image serves webp only (an AVIF Accept header returned webp), a remote URL returns 400 (no remotePatterns, the SSRF precondition), there is no next/og or Pages Router or catch-all route, and production runs node server.js on Linux. All nine advisories unreachable; low.
INF-02low.dockerignore excludes .env.local but not .env, .env.production or .fly.env, and Next copies .env and .env.production into the standalone output.infra · .dockerignore:9
INF-03lowA non-literal path.join(process.cwd(), name) makes the file tracer pull the whole project root into the standalone bundle.infra · lib/docs.ts:21 · readDoc
OPS-03lowThe install and privacy disclosures overstate their own bounds: the skills make more network calls and write more files than the one each disclosure names, and the version check is not silent on failure.operability · README.md:35
OPS-04lowThe event plan is a prose bullet with no guardrail and no owner-and-cadence line, which the suite's own instrumentation skill requires, and three code comments point to an Instrumentation section that does not exist.operability · CLAUDE.md:25
OPS-05lowsign_in_failed is keyed on a random id without disabling person profiles, and every failure is recorded as "expired".operability · app/auth/confirm/route.ts:58
OPS-06lowThe error page says "The error was logged", but client errors are logged only to the visitor's own console.operability · app/error.tsx:33
TEST-01lowThe report validators accept verdicts the suite's own rules forbid, and they passed the 2026-08-14 self-audit, whose verification stats did not match its findings.testing-confidence · scripts/validate-report-invariants.mjs:41
Proposed medium risk. A different-model skeptic re-ran three synthetic cases (all pass both validators) and recounted the 08-14 report (35 not-verified and 4 downgraded against stats of confirmed 1 and downgraded 5), but found no harm path today: the validators gate only maintainer-committed reports and the published page explains its own count. Low improvement.
TEST-05lowCI never builds the Docker image, and the gate scripts have no must-fail fixtures; one skips a check with a note.testing-confidence · .github/workflows/ci.yml:31
TEST-06lowThree pieces of shipped skill guidance are wrong: the mobile overflow probe passes falsely under mobile emulation, the mobile hit-area advice has no non-overlap condition, and instrumentation says server events are not batched.testing-confidence · skills/mobile/SKILL.md:38
SHIP-02lowPublic main is the install channel, so content merged after a release ships to adopters under the last release's version label, and an existing install cannot be recalled.release-safety · README.md:12
Proposed medium risk. A different-model skeptic found main protected by an active ruleset (PR plus green verify, no bypass, no force push), the version label a documented release-time convention, and the four guidance defects real but low impact (TEST-06), so no broken flow: low improvement.
SHIP-03lowThe changelog check is not a required status check, required checks are not strict, and three comments still say no ruleset exists.release-safety · .github/workflows/ci.yml:41
SHIP-04lowRelease steps 1, 3 and 4 have no mechanical check, the Unreleased changelog block carried two Changed headings, and v0.1.0 has a tag but no GitHub Release.release-safety · CLAUDE.md:51
PERF-01low/skills/[slug] and /example/[slug] render on every request although their inputs are fixed files baked into the image.performance-capacity · app/skills/[slug]/page.tsx:31
Proposed medium risk. A different-model skeptic re-measured the 26x cost ratio locally and confirmed nothing rate-limits the routes, but the effect is temporary, visible and fully recoverable when the flood stops, with no data at stake, so it is low.
PERF-02lowposthog-js loads eagerly in every first-load bundle, and the serif and mono cuts preload on every route.performance-capacity · instrumentation-client.ts:1
PERF-03lowEach /reports view parses every report file at least twice, and the cost grows with every report published.performance-capacity · lib/reports.ts:111 · listPublicReports
PERF-04lowScale-to-zero puts the recorded 8 second cold start within 2 seconds of the version check's 10 second timeout.performance-capacity · fly.toml:33
DATA-01lowSupabase project state (the ping function, its anon grant, the Site URL and the redirect allowlist) lives only in the dashboard, a code comment and closed TODOs.data-migration-safety · app/api/health/route.ts:40
DATA-02lowEmails are collected in Supabase Auth and PostHog with no privacy notice, no stated retention and no deletion path, including addresses typed by someone else.data-migration-safety · app/unlock/actions.ts:46
DATA-03lowReplacing the public self-audit deleted its shareable URL with no redirect, and the 2026-07-16 one already 404s.data-migration-safety · next.config.ts:29
CON-05informationalThe version check's daily stamp is check-then-act (touch runs after curl), and CopyButton's reset timer is neither cleared on a second click nor on unmount.concurrency · skills/production-audit/SKILL.md:220
REL-07informationalThe GitHub stars fetch has no timeout, and there is no app/global-error.tsx.reliability · components/InstallBlock.tsx:26 · getStars
A11Y-12informationalThe footer and Behind-the-Build navigation landmarks have no names, and several small controls miss the site's own 44px standard while passing 2.5.8 on spacing.accessibility · components/SiteFooter.tsx:26
UI-05informationalSmall drift from DESIGN.md: the two nav components mark the active tab in two different non-bone colors, the install block stacks bordered panels below lg, and the terminal and scorecard use raw durations and an ungated hover lift.ui · components/NavLink.tsx:38
INF-04informationalThe runner runs as root, the base image is pinned by tag not digest, and CI actions are pinned by tag with no permissions block.infra · Dockerfile:25
Prioritised remediation plan
- 01
Before the first owned report
Keep owned reports out of git, throttle and de-quota the sign-in email, make the magic link survive a scanner, tell the truth in sign-in errors, and ship the privacy page. Each is latent today because nobody has an owned report.
SEC-01, SEC-03, CON-01, REL-03, DATA-02
- 02
Make the report contract enforce itself
Add the missing invariants with must-fail fixtures, ship the script with the skill, and settle one rule for a not-assessed required gate.
TEST-01, TEST-02, TEST-03
- 03
Know when the site is broken and what is live
An external uptime check with a receiver, logged and captured send failures, a deploy that refuses a dirty tree and stamps its commit, and an owned event plan.
OPS-01, OPS-02, SHIP-01, OPS-04
- 04
Correct the shipped guidance and disclosures
Fix the three wrong pieces of skill advice, make the disclosures match the calls and files the skills actually make, and pin what the skills fetch.
TEST-06, OPS-03, SEC-02
- 05
A test that would catch a broken gate
One post-build smoke test against the image with Supabase unset, plus a private fixture, before any unit test framework.
TEST-04, TEST-05
- 06
Accessibility and mobile remainder
Deck focus and headings, large-text overflow, focus under the sticky header, chip contrast, copy-button names, the sign-in field.
A11Y-02, A11Y-03, A11Y-04, A11Y-06, A11Y-07, A11Y-08, A11Y-09, A11Y-10, A11Y-11, A11Y-12
- 07
Delivery and robustness
Static rendering for the content routes, a lighter first load, a memoized report index, the Supabase client timeout, and build-context hygiene.
PERF-01, PERF-02, PERF-03, PERF-04, REL-02, REL-04, INF-02, INF-03
Quick wins: low regression risk
Add .env, .env.* and .fly.env to .dockerignore
INF-02Blast radius: None at runtime; a build that relied on a local .env would stop seeing it, which is the point.
Set images.unoptimized true
INF-01Blast radius: Nothing uses next/image; a future next/image would serve unoptimized files.
Wrap getSessionUser in React cache()
REL-02Blast radius: Signed-in /reports pages only; one getUser per request instead of three or four.
mkdir -p before make install's loop and fail on ln errors
REL-05Blast radius: Source installs only.
Name the footer and Behind-the-Build navs
A11Y-12Blast radius: Screen-reader landmark names only.
Add $process_person_profile false to sign_in_failed
OPS-05Blast radius: PostHog person counts only.
Require the changelog check on main
SHIP-03Blast radius: A pull request with a red changelog check can no longer merge.
Requires deeper investigation
Where owned reports live
A private store read at runtime (Supabase Storage with an owner policy, or a private bucket) changes the deploy and the report pipeline; decide it before onboarding.
SEC-01
Deploy from CI
Moving fly deploy to CI on green main gives artifact identity, a revision stamp and a deploymentId in one change.
SHIP-01, CON-04
Not assessed: runtime-only checks skipped
Supabase dashboard state
To verify: Supabase dashboard: Authentication > SMTP Settings, Rate Limits, Email Templates > Magic Link, URL Configuration, Sessions; then list public-schema grants in the SQL editor.
A real sign-in on the canonical host
To verify: After deploy, request a link on /unlock on the canonical host, open it on the same device, and confirm /reports shows the signed-in state there.
Signed-in /reports and sign-out
To verify: Sign in as an admin and run the accessibility probe and a keyboard walk on /reports and /reports/[slug].
Mobile rows that need a real device
To verify: Walk the core flows on an iPhone and a Pixel-class phone at default and 200% text size; focus the /unlock field and confirm the field and Send stay visible.
Fly machines, concurrency and cold start
To verify: fly machines list, fly config show, and curl -w %{time_starttransfer} on / after ten minutes idle.
The live analytics funnel
To verify: In PostHog, confirm magic_link_requested joins sign_in_completed under one person, list saved insights for both funnels, and check the billing limit.
Mechanical sweeps
- dependency-auditfindings19 advisories at 8328077 (1 critical, 13 high). Production-relevant: next 16.3.1 (nine advisories, none reachable here, see INF-01) and its sharp 0.35.3; the rest are build or dev tooling, or browser-side posthog-js transitives with no attacker input. After the release-branch bump to next 16.3.8, `npm audit --omit=dev` lists 4 (fflate, dompurify, source-map-js, baseline-browser-mapping), none in a request path.
- secret-scancleanNo gitleaks or trufflehog installed, so a pattern grep is the fallback. No key, token, private key or service-role reference in tracked files or history; only .env.example is tracked; the fly.toml phc key is a public browser key by design.
- typecheckclean
- lintclean
- othercleanBuild passes; make validate passes (112 TODOS-tool tests, skill surfaces, brand gates, report schema and invariants, version markers). TEST-01 records what the invariants check misses.