Mega-Site · Systems Runbook · Working Draft

Carbotura Mega-Site — How It's Built

A reference for the people who operate and change carbotura.com. Not a marketing tour and not a task list — this is what the machinery is, where the traps are, and which of them have already caused an outage. Read §00 for what changed recently, §02 before touching a database, and §06 before any deploy.

Version 1.0 Updated 2026-08-13 Status Working Draft — Internal Companion Deal Room Runbook

The three runbooks

Each covers a different surface. Start here for the site as a whole.

  • This one — the mega-site: architecture, data stores, routing, forms, deploys, canon. Reference.
  • Finance Portal — the investor platform: access levels, the gate, deal-room tiers, compliance rules.
  • Deal Room — task-oriented: how to actually add a document, create a room, send a link, run the access review.

§ 00New Features

Newest first. What changed, and what it means for you when you next touch this system.

2026-08-19 — Investor Qualification Questionnaire + Deal Room real downloads

Two investor-facing additions, both detailed in the Finance Portal runbook: a public, ungated 10-question form at /finance/qualify/ that deterministically scores and tiers every investor lead before it reaches Shannon or the Architect; and a real, working self-serve download for the Deal Room — the pre-existing "Allow download" toggles never actually did anything (the only Content-Disposition header in the system sat on a route the viewer fetches via XHR, invisible to the browser), and downloads are now server-side watermarked and logged.

2026-08-18 — Magenta/Mustard/Lime retired (CRBT-TOKENS v1)

Brand register overhaul: Magenta, Mustard, and Lime retired as named decorative/brand-accent colors (Mustard and Lime specifically for red-green colour-vision-deficiency grounds — both collapse toward khaki and become indistinguishable from Signal Orange under deuteranopia/protanopia). Verified against the live canonical source at brand.carbotura.com/tokens.css, fetched directly rather than guessed. Replacements, by register: brand/tokens.css's shared cross-register decorative rotation (33 marketing pages) → Signal Orange / semantic-warning / civic-blue; the finance portal (Bedrock register — Capital & Compliance) → Bedrock blue/button-fill (#0061C9), not Signal Orange, since Signal is register-agnostic and Bedrock has its own accent.

What this changes for you: --magenta/--mustard/--lime keep their CSS custom-property NAMES everywhere (renaming ~240 call sites site-wide is a separate follow-up) but now resolve to spec-compliant hex values. Two real pre-existing bugs surfaced and fixed along the way: finance/assets/finance.css's .alert.danger/.tag.magenta and finance/gate.html's .gate-error were both borrowing the brand-accent color for an error state, which was never actually red — decoupled into a genuine --danger/semantic-error token so an error reads as an error regardless of the brand accent.

Files that keep amber/mustard-family colors on purpose — this is not unfinished work. Small, incidental status/urgency indicators continue to use an amber hue, consistent with the new spec's own semantic/warning tokens (which are amber too) — retiring the Mustard decorative brand swatch doesn't retire amber-as-a-warning-color everywhere: admin/* (8 files, "pending" status), agents/portal.css (medium-urgency indicator), communities/cip/index.html (the "engaged" CIP pipeline stage), and finance/ppm/ppm-cornerstone-complete.html / finance/ppm/condensed/index.html (whose --mustard already resolves to Signal Orange's exact hex under the old variable name — a naming inconsistency, not a color bug). The distinguishing test used throughout: small/incidental UI = low collision risk, deferred; large-area fills (buttons, prominent CTAs) = real risk, fixed regardless of whether the usage was "semantic" or "decorative."
Deliberately NOT touched, flagged as its own follow-up: brand/audiences.html's SP-03/04/05 audience-segment data assigns Magenta/Mustard/Lime as specific segments' strategic "primary" brand color with rationale text (e.g. "Conversion Magenta" for Cornerstone Preferred Investors) — deciding each segment's new signature color is a content/strategy call, not a token-value fix, and needs Architect/Brand-session input. finance/cln/index.html's bespoke lime-green deck accent (#7DD957, not a literal match to any retired hex, embedded in ~40+ places including the CSS class name .kicker-lime itself) was also left alone — recoloring an entire live investor deck needs its own explicit decision. assets/css/site-tokens.css (linked by 23 pages) is a separately-discovered, explicitly-labeled stale local mirror of brand.carbotura.com's own tokens, meant to be swapped for a live CDN import once that site went live — which it now has. Swapping to a live cross-origin dependency on a separately-deployed Cloudflare project is an architecture decision, not folded into this token-value migration.
Not yet swept — retired hexes confirmed still live outside this change set. A decimal-RGB sweep (the same grep trap that bit this migration twice — rgba(132,204,22,…) is #84CC16 in disguise) found more instances in files this pass never touched: finance/assets/finance.js, finance/guide.js, admin/index.html, admin/js/tabs/pages.js, finance/portal/index.html, finance/portal/rounds/cln/data-room/index.html, finance/portal/rounds/cornerstone/index.html, communities/water-starved/index.html, assets/components/risk-retirement.html, platform/index.html, all of technical-assessment/*, technology/index.html, technology/overview/index.html, and vendors/index.html. None were in scope for this pass (brand center + finance portal) — needs its own sweep, and each hit needs the same semantic-vs-decorative-vs-large-fill triage this entry describes before blindly replacing.

2026-08-18 — ih.carbotura.com temporarily code-gated

Architect direction: put a maintenance gate on the ih.carbotura.com vanity domain "for now." Same code-gate shape as the Cornerstone PPM (functions/ppm/cornerstone.js) — GET without a valid cookie shows a form, POST with the correct code sets an HttpOnly cookie and redirects through to the real destination. Lives in functions/_middleware.js, in the existing IH-subdomain block.

What this changes for you: only the ih.carbotura.com vanity hostname is gated — carbotura.com/finance/intelligence/ itself is untouched. The code reads from env.IH_GATE_CODE with a hardcoded fallback so it works without an Architect-set secret first.

Cleanup owed, don't let this rot. The fallback code lives in functions/_middleware.js as a plaintext literal (IH_GATE_FALLBACK_CODE) — flagged by functions-reviewer. Once the Architect sets IH_GATE_CODE on both Pages projects, delete the constant itself, not just the reliance on it, or it sits in git history doing nothing. When the gate is lifted entirely, revert the ih.carbotura.com block back to the plain 301.
"The gate isn't showing" is almost always a client-cached redirect, not a server bug. This domain 301'd permanently to /finance/intelligence/ for months before the gate was added, and browsers cache permanent redirects aggressively — a browser that visited before will reuse its own cached redirect target and never even hit our server again, silently skipping the gate. Confirmed twice via curl (cf-cache-status: DYNAMIC, never HIT) and a fresh incognito-equivalent browser session — the server is correct on every request that actually reaches it. First move on any "gate not working" report for a domain with redirect history: test in a private/incognito window before touching code.

2026-08-13 — Public forms hardened; submission queue added

Every public form endpoint now runs through a shared guard (functions/_lib/form-guard.js): length caps, email validation, per-IP and per-email rate limits, deterministic risk scoring, and a review queue. See §04.

What this changes for you: a submission that scores at or above the risk threshold is held — nothing is written to Attio, Teams or email, and it waits in Form Queue for a human. Approving delivers it to Attio; rejecting deletes it. Reviewers get an email when something is held.

Three silent data-loss bugs were found and fixed in the same pass: /api/vendors/inquiry never existed (every vendor inquiry 404'd and was lost), the investor-briefings notify signup was rejected server-side on every submission while showing success, and /api/webinar/remind was publicly callable and non-idempotent.

Also closed: an open phishing relay. /api/webinar/register would send a DKIM-signed email from [email protected] to any address, with attacker-controlled content, because first_name was interpolated raw into the HTML body and the recipient was submitter-supplied. If you add an email template anywhere, escape every submitter-controlled value.

2026-08-13 — Deal Room sessions now last 90 days

Viewer sessions went from 24 hours to 90 days, and manual grant links from 1 hour to 7 days. Expiry no longer removes access — revocation does. That makes the 90-day access review in the Deal Room runbook §K the control that matters. Nothing automates it.

2026-08-13 — Stale Release-2 corpus mirror killed

A D1 copy of the governance corpus, seeded once in May at Release 2 and never updated, was deleted along with its admin browser. The Corpus Scan is now labelled Legacy Scan (R2 patterns) because it checks a frozen May pattern list — a clean result is not canon compliance. See §07.

2026-08-12 — Site Directory

/admin/site-directory/ lists every page and every domain. The page list reads pages-manifest.json live so it cannot go stale; the domain list is hand-maintained and must be updated alongside the governance SITE_DIRECTORY.md.

§ 01The shape of it

One Cloudflare Pages project serves five audiences off one codebase. There is no separate app per audience — the gating is what separates them, which is why the gating is worth understanding before you change anything.

REQUEST PATH Visitor any carbotura.com host _middleware.js vanity redirects · env detect · feature flags routes to Public marketing no gate /finance/ investor platform OTP + NDA /admin/ CF Access /agents/ PIN /deep-tech/ · /technical-assessment/ NDA document sets — access code Pages Functions functions/ — all server logic BACKING SERVICES D1carbotura-cip KVRATE_KV, flags R2documents AttioCRM Resendall email Anthropicguide, screening SEPARATE PROPERTIES {slug}.carbotura.com — community sites, own repo revcon.carbotura.com — own build session ksa.carbotura.com — Saudi pod, own corpus
One Pages project, five gated surfaces, one set of Functions behind them. The three properties bottom-left are not this repo — changing them is someone else's session.

What lives where in the repo

PathWhat it is
/, /technology/, /materials/Public marketing. No gate. Canon-governed copy.
/finance/Investor platform — thesis, PPM, data room, portal, URVS. Gated.
/admin/Back office. CF Access. Separate page per tool.
/agents/Sales-agent portal. PIN auth, own Worker (workers/agents.js).
functions/All server logic. _lib/ holds shared helpers — look here before writing a new one.
workers/Standalone Workers. Not the Pages project — see §02.
schema/D1 migration .sql files. Historical record; applying them is manual.

§ 02Data stores

There are seventeen D1 databases on the account and several have confusingly similar names. Getting this wrong is not theoretical — it broke a feature on first use.

TrapWhich database is the real one

The Pages project's env.DB binding is set in the Cloudflare dashboard, not in any file in this repo. It points at:

EnvironmentDatabaseID
Productioncarbotura-cip2e1a85d6-ec73-4ecc-aad4-7cdcb3395e90
Devcarbotura-cip-devbac40628-cb51-41c2-9892-61d2be5966e4
The repo's wrangler.toml names a different database and will mislead you. That file configures only the standalone contact Worker (workers/contact.js), whose carbotura-contacts binding is correct for that Worker — it writes contact-form rows there. It is not the Pages config. On 2026-08-10 a migration was applied to carbotura-contacts because that file named it; the table landed in a database nothing reads, and the feature failed the first time a human used it. A scope warning now sits at the top of that file.
Before any migration, verify the target by querying for a known table. SELECT COUNT(*) FROM sqlite_master WHERE name='deal_rooms' returns 1 on the right database and 0 on the wrong one. This takes five seconds and would have caught the incident above.

What's in carbotura-cip

Everything the Pages Functions touch. The main groups:

  • Deal Roomdeal_rooms, dr_share_links, dr_viewer_sessions, dr_otp_codes, dr_manual_grants, dr_share_views, plus documents and templates.
  • Formsform_submissions (the unified queue), contact_submissions, engagement_requests.
  • Webinarswebinar_events, webinar_registrations.
  • Communities / CIP — the live community roster the map and directory read.
  • Auditaudit_events, written by admin actions across the site.

Other stores: KV for rate limiting (RATE_KV) and feature flags; R2 for deal-room documents and PDFs.

§ 03Domains & routing

Several hostnames resolve to this one project. All of the interesting logic is in functions/_middleware.js, which runs before anything else.

Hosts

HostBehaviour
carbotura.comProduction, canonical. www is an equivalent alias.
dev.carbotura.comDev preview, dev branch.
admin.carbotura.comRoutes to /admin/*. Any other path bounces to the apex.
staging.carbotura.comRetired 2026-07-03. 301s to the apex so old deal-room links survive.
pitch / clnServed directly (path rewrite) as the CLN deck.
ih · cornerstone · masterplan · cbond · cip301 to their canonical paths.
Share links must use the public apex. Minting a link while on admin.carbotura.com would otherwise bake the admin hostname into a URL you send a counterparty. The Deal Room maps admin→apex explicitly when building share URLs; copy that pattern for anything similar.

§ 04Public forms pipeline

Eight public endpoints accept unauthenticated input. Every one of them writes somewhere that matters — the CRM, a staff inbox, a Teams channel. They all run the same guard.

Submission 8 public forms Turnstile contact · webinar only (see below) Guard caps · email · rate limit · risk score form_submissions EVERY submission logged here score? < 50 — clean flows through as before ≥ 50 — HELD nothing written downstream Attio · Teams · staff email the normal downstream Reviewer emailed link only, no payload /admin/queue/ approve → Attio · reject → deleted HOLD TRIGGERS URL or HTML in a name / org field bidi or zero-width characters malformed email · homoglyph spoofing
Every submission is logged; only flagged ones are gated. A clean municipal or investor lead never waits on a human.

AThe guard — functions/_lib/form-guard.js

Import from here rather than re-implementing. It gives you capStr, escapeHtml, isEmail, rlOver, verifyTurnstile, scoreSubmission, recordSubmission and notifyHeld.

Scoring is deterministic and local. No network call, no AI. An outage anywhere else must never mean people can't contact Carbotura. AI screening exists, but only on the review path in the queue.

Homoglyph detection deliberately ignores genuinely non-Latin names. It only fires on predominantly-Latin text with a few foreign glyphs swapped in — "Wells Fаrgo" with a Cyrillic а. A wholly Greek or Arabic name scores zero, because Greek, Italian, KSA and Indian counterparties are real and flagging them is worse than the spam.

BThe queue — /admin/queue/

master_admin+ only, because the rows hold unvetted third-party content. Approve delivers to Attio and keeps the record; Reject deletes the row (the audit log keeps who/what/why); Spam keeps it, so repeat probing stays visible.

Approve is atomic in the way that matters: if the Attio call fails, the row is not marked approved — it stays queued and you get the error, rather than reading as handled while nothing reached the CRM.

CTurnstile — and why it's only on two endpoints

Live on /api/contact (fail-open) and /api/webinar/register (fail-closed). The webinar one is fail-closed because it mails a submitter-chosen address; contact is fail-open because silently blocking every municipal lead on a config error is worse than the spam.

required:false does not do what you probably think. It fails open on a missing secret, not a missing token. With the secret set — it is — a request carrying no token returns 400 regardless. So enabling verification on an endpoint whose page has no widget breaks that form outright. Add the widget to the client first, confirm it produces a token, then turn on server verification. The five unguarded endpoints each carry an in-file comment saying exactly this.

§ 05Admin surfaces

Each admin tool is its own page under /admin/, sharing chrome via AdminShell.init() and auth via CF Access. New tools should follow that pattern rather than becoming another tab in admin/index.html, which is already ~3,900 lines.

SurfacePurposeTier
/admin/Main console — analytics, sales, content, config, QArole-gated tabs
/admin/ → Config → Register MapBrand-register migration (added 2026-08-25). Assign a register per page (section default + per-page override; 94 pages / 11 sections) and per file (the 19 referencing --cb-*). Records decisions only — never edits a stylesheet. Measurements regenerate via scripts/build-register-map.js; decisions live in KV under a register: prefix (same namespace as the gating flags — the prefix prevents collision); brand colours are read live from brand.carbotura.com, never stored.master_admin+ writes
/admin/queue/Form submission reviewmaster_admin+
/admin/dr/Deal Room — rooms, documents, share linksmaster_admin+
/admin/site-directory/Every page and domainany admin
/admin/communities/Community roster and economicsany admin
/admin/users/Admin users and per-portal accessmaster_admin+
/admin/webinars/Events, registrants, recordingsoperator+
Admin pages cannot be tested by opening the file locally. AdminShell.init() is the first statement of the main script block and /admin/admin-shell.js is an absolute path that won't resolve on file:// — so the whole block throws before its constants initialise, and every later function sits in the temporal dead zone. It looks exactly like a broken page but isn't. Test admin surfaces on dev.

§ 06Deploy pipeline

All work happens on dev. Promotion to main is an explicit Architect decision, never automatic.

The sequence

  1. Commit to dev, staging paths explicitly — git add {path}, never -A.
  2. Push. Cloudflare Pages deploys to dev.carbotura.com in roughly 20–30s.
  3. Verify on dev. Bot-fight mode blocks bare curl — send a browser User-Agent.
  4. On Architect instruction: git checkout main && git merge dev --ff-only && git push.
  5. Verify the same thing on carbotura.com.
  6. Log it in _handoff/NOTES.md.
Edge cache can lie to you during verification. Plain URLs sometimes serve pre-deploy HTML for a short window while a cache-busted fetch shows the new build — and /page/index.html can be stale while /page/ is fresh. Before concluding a deploy failed, retry with ?cb=$RANDOM and use canonical paths.

The two gates

deploy-gate — run when the change set contains .html or .json. Checks canon compliance, mailto: links, JSON validity, pages-manifest.json staleness, Vortex chrome on new pages, KV/cache gotchas and D1 datetime sanity.

functions-reviewer — run when a change touches functions/, admin/ or workers/ and involves auth, permissions, D1, KV, or request handling. Checks seven recurring bug classes.

Add one more check they don't cover. Both gates review logic and diffs; neither parses the resulting inline script. A malformed escape inside a single-quoted JS string took down the whole Deal Room admin page on 2026-08-10 and passed both. Extract inline scripts and run node --check on them before pushing any HTML with a large inline block.

Scripted checks (added 2026-08-25) — run before any push. node scripts/check-css.js verifies every stylesheet parses as written, not merely that it renders in one browser — a comment containing var(--font-*/ once closed a header comment 25 lines early and shipped twice; Chrome resynchronised so every check passed while stricter parsers dropped all 63 tokens. Mandatory on any .css change, including comment-only edits. node scripts/build-sitemap.js --check and node scripts/build-register-map.js --check gate the two generated artifacts; the sitemap generator reads FEATURE_FLAGS KV (enabled_prod) at build time and fails closed — its first version ignored flags and advertised 22 URLs that 404 on production (--no-flags overrides, loudly). node scripts/canon-sync.js --check guards the three canon mirrors: CLAUDE.md, WORKFLOW.md, AGENTS.md — the two Designer files were archived 2026-08-25 with the Claude Projects Designer itself.

Block-deploy conditions

  • Vortex topbar/footer SVG missing on a new public page (admin pages use AdminShell instead).
  • Any mailto: link anywhere.
  • A JSON file that fails to parse.
  • pages-manifest.json not regenerated after pages were added, removed or renamed — run node scripts/build-pages-manifest.js.

§ 07Canon & corpus

Public copy is governed by the Carbotura corpus, which lives in the Carbotura-governance repo and is owned by the Corpus Zone session. This repo consumes canon; it never defines it.

Where canon actually lives

  • Governance repo_global/corpus/. The source of truth. Corpus Zone is its sole writer.
  • The carbotura-* skills — the live working copy used when writing or reviewing copy. Updated in the same session as any canon change.
  • deploy-gate — enforces canon on every HTML deploy.
The "Legacy Scan (R2 patterns)" button is not canon compliance. Its pattern list was frozen at Release 2 in May 2026; canon is at Release 43. It never checks the CSA cascade, EcoGraph retirement, the molecular-conversion ban, CRU→CRSRU, or the Option A/B retirement — and it still flags a term canon itself retired at R20. A clean result means the page avoids fifteen patterns from May. Use the skills or deploy-gate for real review.
Don't mirror corpus content into this repo. A D1 copy existed, seeded once at Release 2, and drifted 41 releases behind before it was deleted on 2026-08-13. If a canon viewer is built here it must read the governance repo live at request time.

§ 08Known traps

Every one of these has actually happened. They're collected here because each cost real time and none is obvious from reading the code.

TrapWhat happensWhat to do
Wrong D1 database wrangler.toml names carbotura-contacts; Pages actually uses carbotura-cip. A migration lands where nothing reads it. Query for a known table before migrating.
Escapes in inline JS A malformed \' in a single-quoted string kills the entire inline script — every table renders empty and it looks like an API failure. node --check the extracted script before pushing.
esc() in an onclick The browser decodes &#39; before compiling the handler, so an escaped apostrophe still breaks out of the JS string. Use data- attributes and a delegated listener. Remove the sink instead of escaping it.
Turnstile required:false Fails open on a missing secret, not a missing token. Enabling it without a client widget 400s every submission. Widget on the client first, then server verification.
Local admin testing AdminShell is undefined on file://, so the page throws before initialising and looks broken. Test admin pages on dev, not locally.
Edge cache during verify A plain URL serves the old build while a cache-busted one serves the new; .html paths lag canonical ones. Cache-bust and use canonical paths before calling a deploy failed.
Raw values in email templates Submitter-controlled text interpolated into HTML email became an open phishing relay under our own DKIM signature. Escape every submitter value at every HTML sink.
Silent client-side failures Three forms failed for months because the client never checked the response — vendors 404'd, notify signups were rejected, and nobody saw an error. Always check res.ok and surface failures to the user.
Cache rules in _headers are inert A zone-level Browser Cache TTL (4h) rewrites every cacheable response to max-age=14400; only no-store bypasses it and keeps its header. Diagnostic: cf-cache-status: MISS = rewritten, BYPASS = honoured. This is why admin JS deploys land up to 4h late and why the June 2026 /admin/js/* rule never worked — no change to _headers or _routes.json can fix it. Cloudflare → Caching → Configuration → “Respect Existing Headers” (Architect action). The rules already in the file are correct and take effect the moment it flips. Until then, hard-refresh.
Spoofed crawler UAs always 403 Super Bot Fight Mode blocks “definitely automated” traffic, so curl with a GPTBot UA proves nothing about real GPTBot (Verified bots: Allow is set — verified crawlers pass). A spoofed-UA probe cannot distinguish “crawlers blocked” from “spoofing blocked”. Verify with bot analytics, Search Console, or a real browser — never cite a spoofed-UA probe as evidence.
CRLF poisons generated-file compares git autocrlf rewrites generated files to CRLF on checkout while generators emit LF, so a raw compare reports STALE forever after any checkout — a gate that cries wolf until everyone ignores it. Both --check gates strip CR before comparing. Preserve that property in any new generator.