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.
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."
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.
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.
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.
/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.
/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.
What lives where in the repo
| Path | What 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:
| Environment | Database | ID |
|---|---|---|
| Production | carbotura-cip | 2e1a85d6-ec73-4ecc-aad4-7cdcb3395e90 |
| Dev | carbotura-cip-dev | bac40628-cb51-41c2-9892-61d2be5966e4 |
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.
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 Room —
deal_rooms,dr_share_links,dr_viewer_sessions,dr_otp_codes,dr_manual_grants,dr_share_views, plus documents and templates. - Forms —
form_submissions(the unified queue),contact_submissions,engagement_requests. - Webinars —
webinar_events,webinar_registrations. - Communities / CIP — the live community roster the map and directory read.
- Audit —
audit_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
| Host | Behaviour |
|---|---|
carbotura.com | Production, canonical. www is an equivalent alias. |
dev.carbotura.com | Dev preview, dev branch. |
admin.carbotura.com | Routes to /admin/*. Any other path bounces to the apex. |
staging.carbotura.com | Retired 2026-07-03. 301s to the apex so old deal-room links survive. |
pitch / cln | Served directly (path rewrite) as the CLN deck. |
ih · cornerstone · masterplan · cbond · cip | 301 to their canonical paths. |
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.
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.
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.
| Surface | Purpose | Tier |
|---|---|---|
| /admin/ | Main console — analytics, sales, content, config, QA | role-gated tabs |
| /admin/ → Config → Register Map | Brand-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 review | master_admin+ |
| /admin/dr/ | Deal Room — rooms, documents, share links | master_admin+ |
| /admin/site-directory/ | Every page and domain | any admin |
| /admin/communities/ | Community roster and economics | any admin |
| /admin/users/ | Admin users and per-portal access | master_admin+ |
| /admin/webinars/ | Events, registrants, recordings | operator+ |
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
- Commit to
dev, staging paths explicitly —git add {path}, never-A. - Push. Cloudflare Pages deploys to
dev.carbotura.comin roughly 20–30s. - Verify on dev. Bot-fight mode blocks bare curl — send a browser User-Agent.
- On Architect instruction:
git checkout main && git merge dev --ff-only && git push. - Verify the same thing on
carbotura.com. - Log it in
_handoff/NOTES.md.
/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.
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.jsonnot regenerated after pages were added, removed or renamed — runnode 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.
§ 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.
| Trap | What happens | What 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 ' 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. |