| name | ship |
| user-invocable | true |
| description | Safe production deployment with quality gates, safety audits, and rollback. Deploys to PRODUCTION by default. |
| allowed-tools | ["Bash","Read","Write","Edit","Grep","Glob","Agent","Skill","WebFetch"] |
| model | inherit |
/ship - Production Deployment
Execute safe production deployment directly with comprehensive quality gates and integrated safety audits. All phases run inline — no subagent.
Default behavior: Deploys directly to PRODUCTION
UX-DOWNGRADE GUARD (BEFORE PHASE 1)
If the changeset being shipped introduces a UX downgrade in a third-party integration — a new handoff URL, a new redirect to an external form, a new "user must click through to finish on another site" path — STOP and load ~/.claude/skills/shared/upstream-protocol-investigation.md first.
Run that skill's Steps 1–3 (read the upstream's own client of its own API, capture real network traffic, inspect rendered data-* attributes) and confirm the downgrade is genuinely required by the upstream's protocol — not just based on a stale "Verified YYYY-MM-DD" comment in our codebase. Token cost is unlimited for this verification. User explicitly authorized it 2026-05-09 after the SF311 graffiti incident: commit 0b746a4 shipped a handoff fallback based on a wrong same-day comment; the real fix f27d1e3 came from reading dform's api.js lines 462–520 and seeing the SF.gov form treats valid:true + ref:non-empty as full success without caseid. Auto-submit was always possible — the bandaid was wasting users' time.
If the verification confirms the downgrade is necessary, document the upstream evidence inline in the source (link to the line of upstream client JS that proves it). If it's not necessary, ship the real fix in this session — don't /ship the downgrade.
MANDATORY: FIX ALL ISSUES EVERY RUN (ZERO EXCEPTIONS)
Every /ship invocation MUST execute ALL fix phases in order. NEVER skip a phase for efficiency, even if the code change is small or "just a copy change".
| Phase | Gate | Must reach |
|---|
| -0.4 | Workers Cache safety (tools/workers-cache-check.sh — leak/loop/HSTS classes, wrangler ≥4.69) | exit 0 (BLOCK on rc=2) |
| 0 | Biome lint (full project) | 0 errors, 0 warnings |
| 0 (Stage 1.5) | npm audit | 0 vulnerabilities |
| 1 | Build (repo typecheck/build, using the same TS mode as production) | Exit 0 |
| 1 | Tests (vitest run --changed with timeout) | All pass |
| 1.1 | Frontend-backend API contract | 0 missing routes |
| 1.24 | Supply-chain scanner health (Socket CLI) — runs when the diff touches package.json/any lockfile | ~/.claude/skills/shared/tools/socket-health-check.sh --live. exit 2 = BLOCK (SOCKET_CLI_ACCEPT_RISKS persisted in a shell rc — the scanner is installed and aliased but disarmed account-wide, so it looks protected while blocking nothing). exit 1 = BLOCK (installed + npm aliased to it but no working token: every install fails closed with an error the CLI cannot explain, which is what drives people to disarm it). exit 3 = informational unless the alias exists. This is the DEPENDENCY half of the threat model — npm audit/Dependabot only know published CVEs, and Phase 1.29 security-review only reads OUR diff; neither sees a malicious postinstall, typosquat, obfuscated payload, or new-author takeover in a transitive dep. Fix a blind scanner with socket login, or env -u SOCKET_CLI_API_TOKEN socket config set apiToken <token> — the env -u is required: with SOCKET_CLI_API_TOKEN exported the CLI silently enters read-only mode and config set prints OK while writing nothing (verified 2026-07-23). |
| 1.25 | Security audit + Dependabot alerts | 0 open alerts with available fix |
| 1.26 | Code scanning hygiene | Auto-fix all |
| 1.29 | Semantic security review (security-review skill) — LOOP UNTIL CLEAN | cd into the repo root first (it reviews the cwd git-branch diff; hard-fails if cwd isn't a repo), then invoke the built-in security-review skill (AI dataflow analysis: injection/XSS/SSRF/auth-bypass/secrets/logic flaws — the class the grep gates in 1.45 miss). BLOCKING loop: run → fix EVERY reported finding (confidence ≥8) in source (No-Suppression Rule; no @ts-ignore/eslint-disable) → re-run → repeat until the report is empty. If the skill is unavailable, WARN + fall back to grep checks, don't silently pass. A genuine false positive is documented inline with its reason; everything else is fixed, never deferred. Ship is FORBIDDEN until this returns clean. Loop guard: if the same finding survives 5 fix attempts, STOP and surface it to the user instead of shipping. |
| 1.3 | React scope + env safety | No blockers |
| 1.3a | Undefined/null-render safety (when a React data-rendering component or an API response shape changed) | No render gated on a value that can stay null/undefined (silent-hide); no unguarded .map/property/string/Date/JSON.parse on possibly-undefined API data; no path that renders raw undefined/NaN/Invalid Date/[object Object]; loading/empty states present. Post-deploy, the live rendered DOM contains none of those literals. See ~/.claude/skills/shared/undefined-null-render-safety.md. |
| 1.3b | SPA→SSR global-component parity (when the changeset converts any React-SPA route to SSR, or adds/edits an SSR page under src/worker/ssr/**) | Every component mounted globally in App.tsx/SPA root (support/chat widget, cookie/consent banner, analytics, exit-intent, providers, ?param deep-link handlers) is re-provided on the SSR page — as a shared island mounting the existing component, an SSR equivalent, or a working static fallback. SSR does NOT mount the React tree, so each global silently vanishes from the converted route with no error. Post-deploy, the deep-link must act (e.g. ?support=open opens chat), not just 200. See ~/.claude/skills/debug/references/react-patterns.md #24. Reference incident: exampleapp ?support=open chat dead on all 7 SSR pages (2026-06-28, fix 7a25f67). |
| 1.3c | 🛑 HARDEST RULE — SSR rendered-style verification on EVERY route (when ANY SSR page or any bundled/island/global CSS that an SSR page loads changed) | "Renders real HTML / 200 / chat opens" is NOT proof the page is USABLE. You MUST drive the live browser (:9222 / chrome-devtools MCP) for EVERY SSR route — not a spot-check of one or two — and assert the computed styles, not the source: (1) getComputedStyle(document.body).backgroundColor/backgroundImage equals the intended design token (e.g. the navy gradient), NOT a leaked fallback like rgb(240,240,240); (2) NO large/heading text fails WCAG-AA against its actual computed background (compute the ratio against the rendered bg, e.g. white #fff heading on #f0f0f0 ≈ 1.1:1 = BLOCK); (3) a viewport screenshot is actually eyeballed for invisible/!-contrast text. Root cause this guards: a bundled island/global stylesheet (Tailwind build that transitively imports the app's index.css) loads AFTER the SSR page's inline <style> and overrides element selectors (body{background:var(--color-bg);color:var(--color-text)}) → the SSR design is silently clobbered and headings/text go invisible — with ZERO errors, ZERO console output, a perfect HTML diff, passing CSP, and passing <h1>-count checks. Fix pattern: the SSR layout's body background+color must win (!important, since the leaked rules carry none) OR the island CSS must not emit global body rules. BLOCK the ship until computed-bg + contrast pass on every SSR route. See ~/.claude/skills/debug/references/react-patterns.md #25. Reference incident: 2026-06-28 — support-island.css (added to all 7 SSR pages in the chat fix) leaked body{background:#f0f0f0}, making white headings invisible on light gray site-wide; shipped twice undetected because checks verified "chat opens" not the page background. Fix cd0cdfe. |
| 1.4 | SEO/sitemap consistency | No conflicts |
| 1.4-lastmod | Sitemap lastmod freshness (auto-bump) | When the changeset modifies the content/markup/template of any URL that appears in the sitemap (a page component, SSR template, or copy file mapped to a <loc>), the matching <lastmod> MUST be bumped to today's date BEFORE deploy — wherever the sitemap is authored (static public/sitemap.xml, a worker SITEMAP_XML constant, or a generator). Auto-apply: diff the changed routes against the sitemap's <loc> list, set each touched page's <lastmod> to today (leave untouched pages alone), commit. A stale lastmod tells crawlers "nothing changed" and delays re-indexing of exactly the pages you just improved. Reference incident (2026-06-28, exampleapp): the 7 SSR-converted pages shipped with lastmod still at 2026-03-08/04-01 — fixed in a follow-up commit e9fcee8; this gate makes it automatic. |
| 1.4a | OG/social preview metadata | Public sites have title, description, canonical URL, OG/Twitter tags, and a 1200x630 share image; create them if missing |
| 1.4b | Favicon presence (EVERY HTML site — MANDATORY) | Every site /ship deploys must serve a favicon. Verify the served <head> carries a <link rel="icon"> (or a /favicon.ico//favicon.svg//favicon.png route returning image/*). Check the live URL when public; when the page is auth-gated (CF Access 302 → can't curl the HTML), verify presence in the repo's rendered head template (grep -rniE 'rel=.icon|favicon' src/) AND on the deployed page via the logged-in :9222/fcdp browser (document.querySelector('link[rel=icon]')). If absent, AUTO-FIX: add a self-contained inline SVG data-URI <link rel="icon"> to the <head> — base64-encode the SVG (data:image/svg+xml;base64,…) for bulletproof cross-browser rendering, pick a glyph that fits the app; if the page sets a CSP, add data: to img-src (safe — data images can't execute). Redeploy, then browser-verify the link is present, decodes to valid SVG (DOMParser, no parsererror), and renders. Skip ONLY for pure non-HTML workers (API/CLI only, no served HTML). Codified 2026-07-13 after adding favicons to seal + fax + diy-fax by hand three times in one session. |
| 1.42 | Deploy session invalidation | Handlers exist |
| 1.45 | Third-party config, XSS, auth guards | No blockers |
| 1.45a | Production auth instance + Worker binding dry-run | No dev auth fingerprints; provider domain complete |
| 1.45b | External municipal form fallback regression + structured-location shape (backend-agnostic) | Changed category/form submit paths have fallback tests AND, when ANY 311 backend's structured-location code changed (Verint sf_full_address/Location_description/sf_*; SCF address/location_details[*]; future backends), a regression test exercises (a) a long-form "NNN Street, City, ST, NNNNN" input, (b) an empty input, (c) a coord-string "<lat>, <lng>" input — none of which can land in a structured slot. Pattern #21. |
| 1.45c | Third-party response signal-extraction fixtures (success + failure) | Any parser that classifies a third-party HTTP response into {ok, ...} (DBI complaint replay, Verint dform save, OAuth callback, webhook verifier, scraper detector) must ship with a captured real success AND a captured real failure response under __fixtures__/, plus a tools/repro/<integration>-probe.{sh,mjs} script. Tests must readFileSync the fixtures — synthetic hand-written HTML cannot detect heuristic drift between success and failure pages that share 99% of their structure. Pattern #23 + ~/.claude/skills/shared/third-party-signal-fixtures.md. |
| 1.45d | External link integrity (when the changeset adds/edits any external href/URL in site source) | Every external link the site renders must resolve to HTTP 200. Run ~/tools/linkcheck.sh <repo> — it greps all https?:// links from src/, curls each, and for any non-200 falls back to the live :9222 Chrome (real browser TLS) to distinguish a genuine 404/410/dead link (BLOCK + fix the URL) from a government/WAF bot-block of curl (403/000 but 200 in browser → fine). Curl-only checking false-positives on .gov PDFs (ag.ny.gov, health.ny.gov, otda, nysenate). After deploy, re-run against the LIVE rendered HTML's links. Reference incident: 2026-06-05 NYIA portal /help shipped a malformed POA URL (...short-formpdf → 403) guarded by a "do not alter" comment; the other 6 gov links were fine (curl-403 bot-blocks). |
| 1.45e | Embed + rendered-href integrity (when the changeset touches an <iframe>, a prose→HTML renderer, or any URL literal) | (a) Every <iframe> whose src is a referrer-sensitive host (youtube.com, youtube-nocookie.com, player.vimeo.com, open.spotify.com, w.soundcloud.com, players.brightcove.net) MUST carry referrerpolicy="strict-origin-when-cross-origin" if the site sends Referrer-Policy: no-referrer — which is Hono secureHeaders()'s DEFAULT. Without it YouTube renders "Error 153: Video player configuration error" (CSP frame-src being correct does not save you). Check the header with curl -sI https://<prod>/ | grep -i referrer-policy. (b) No rendered href may contain whitespace — that means a markdown/prose renderer swallowed trailing text into the URL (…/watch?v=ID - explains fault indicator), which only "works" because YouTube truncates ids at 11 chars. (c) No malformed URL literal (https://.join.slack.com → NXDOMAIN) and every YouTube id is exactly 11 chars of [A-Za-z0-9_-] (catches watch?v=_SiFQ_4m0|E). Verify against the DEPLOYED artifact, not source — hono/jsx vs React attribute casing can drop referrerpolicy silently — then load it in a real browser and confirm the player renders. Allow >30s for Worker propagation before calling a post-deploy curl a failure. If a fix autolinks previously-inert prose URLs, every newly-created anchor must be validated before deploy — inert bad text becomes a live broken link. Reference impl: tools/check-links.mjs in the TISF repo (static + --url rendered mode, wired into bun run build); reference incident 2026-07-09 (Error 153 on the homepage; 3 hrefs with prose inside them and 7 dead-text URLs on /blog). Full pattern: ~/.claude/skills/debug/references/csp-cache-patterns.md #27. |
| 1.46 | Admin-user portal sync verification | No BLOCK findings |
| 1.55 | Hot-path data-volume & cache-topology (when route handlers / crons / caches / queries changed) | No per-request query reads >~100k rows; every cache-key read has a writer; cache-warmer crons warm the exact keys the routes read; lagged-source trailing windows anchored on MAX(ts) not now(); TEXT-timestamp range scans use the index. PLUS the account-wide D1 read budget — a single repo's scan can consume the whole account's included allowance and surface as a surprise invoice on an unrelated project. Query it: d1AnalyticsAdaptiveGroups{sum{rowsRead} dimensions{databaseId}} over 30d via the CF GraphQL API. WARN at >25% of the 25,000,000,000/mo Workers-Paid inclusion; investigate any single database that is >90% of the account total. Reference finding (2026-07-10): improvebayarea read 8.72 billion rows in 30 days — 99.99% of the account and 34.9% of the inclusion — while AIVA read 1.0 million. |
| 1.56 | D1 schema-drift / migration-applied-to-prod (when the diff touches any D1 INSERT/UPDATE/SELECT column set, adds a migrations/*.sql file, or references a new column/table) | Every column the changed worker code writes MUST exist in the remote D1 (PRAGMA table_info — the migration FILE and the local D1 are NOT proof), and wrangler d1 migrations list --remote must be clean. Run ~/.claude/skills/shared/tools/d1-schema-drift-check.sh <repo> — BLOCK on any code-referenced column/table missing in prod (it will 500 every write to that table) or unapplied migrations. Fix: apply only the missing DDL additively to remote, INSERT OR IGNORE its filename into d1_migrations; NEVER bulk wrangler d1 migrations apply (re-runs non-idempotent data migrations → corruption). See ~/.claude/skills/shared/d1-schema-drift.md. Reference incident: 2026-07-05 AIVA POST /api/intake 500'd for every user — mos in code + migration file but never applied to prod. |
| 1.57 | Observability / instrumentation (when the diff adds/changes external calls, catch branches, state transitions, or new error paths) | Changed code is debuggable in prod BEFORE it ships: structured logs at those boundaries (log({event, ...attrs}), secrets/PII redacted); every new catch records what was attempted + the real upstream reason (nothing swallowed); no new catch-all/ambiguous error message (split two-root-cause strings). Deploy-gate form of /carmack's instrument-on-build rule. See ~/.claude/skills/shared/observability-instrumentation.md. Skip for pure docs/test/copy diffs. |
| 1.58 | Provider / SDK swap safety (when the diff replaces a third-party provider — email, auth, payments, storage, SMS, LLM gateway — or does a major SDK bump within one vendor) | Run ~/.claude/skills/shared/tools/provider-swap-check.sh <repo> --old-prefix <OLD> --new-binding <NEW> --seam <path> (exit 2 = BLOCK; verified to fire on all breakage classes 2026-07-10) plus the 7-item checklist in ~/.claude/skills/shared/provider-migration-safety.md. BLOCK on any of: (a) any feature guard still naming the OLD provider's env var (rg "env\.OLD_[A-Z_]+" must be empty — including CRITICAL_ENV_VARS/startup validators, which will brick boot once the secret is deleted); (b) more than one module calling the new SDK directly (there must be exactly one seam) or a result type that is not a discriminated union — a non-throwing {data,error} SDK turns every failure into a silent success (11/13 AIVA call sites never checked error); (c) any lookup by a persisted legacy identifier that does not discriminate on id SHAPE before querying — the new provider returns zero rows for old ids, which becomes a confident wrong status (see #29); (d) reliance on emulator behavior (wrangler dev does NOT enforce Cloudflare's header allowlist) without a captured real success + real failure fixture; (e) any re-enabled cron whose blast radius against prod data was not counted and human-approved before its first fire. Also: rg -l "<vendor>" ~/projects ~/tools before decommissioning the old account — another repo may hold a live key on the same verified domain. Reference incident: AIVA Resend→Cloudflare, 2026-07-10, all four breakages shipped before being found by hand. |
| 4.07 | Email deliverability (when the diff touches email-send code: a send_email binding, an email provider seam, a From address/NOTIFY_FROM, or SPF/DKIM/DMARC config) | ~/.claude/skills/ship/tools/email-deliverability-check.sh <repo> --domain <sender-domain> --accounts <dest mailboxes> — STATIC: BLOCK any From literal on an APEX domain whose MX is a hosted mailbox (Google/Microsoft) — same-domain strict SPF/DMARC junks it (the 2026-07-13 diy-fax class: send() resolved, mail landed in SPAM); WARN if the sender domain isn't in wrangler email sending list. LIVE (post-deploy): trigger a REAL send via the app's own event, then BLOCK if the newest message from the sender domain is labeled SPAM in the destination mailbox or Authentication-Results lacks dmarc=pass; no-message-found = UNVERIFIED warn (absence isn't proof — re-trigger). "Send resolved" ≠ "delivered to inbox"; only reading the destination mailbox proves placement. |
| 4.08 | Workers-Cache post-deploy verification (when the cache block was enabled/modified) | Staged enable; t+15/45/90s multi-route-class monitoring (<60s checks are NOT evidence — propagation >30s); semantic Cache-Control per class + HSTS present; sitewide-3xx tripwire → auto-disable + wrangler tail scheme probe |
| 4.09 | Worker surface exposure + AUTO-HEAL (CF API probe: tools/worker-surface-check.sh --apply, runs EVERY ship — the surface regresses on each wrangler deploy) | previews_enabled=false everywhere; workers.dev disabled on custom-domain workers; re-probe confirms closed + canonical URL 200 |
| 4.05 | Site security defaults (live URL) | All baseline items pass — security.txt, sitemap.xml (must exist if robots.txt advertises it), HSTS, CSP, X-*, COOP/CORP. Cloudflare API items (TLS min, DNSSEC, CAA) auto-fix when creds available. |
| 4.05a | CF zone-level security enforcer | always_use_https=on, automatic_https_rewrites=on, min_tls_version=1.2, tls_1_3=on, opportunistic_encryption=on, ssl≥full, zone-HSTS on (preload), 0-RTT=on — auto-PATCHed via CF API. Bot protection excluded by design. |
| 4.05d | Account-wide CF Security Insights sweep (replaces the paused cf-security-watch cron) | ~/.claude/skills/carmack/tools/cf-security-insights.sh --apply — sweeps ALL zones AND all Workers (not just the deployed one). Zone fix: edge security.txt. Worker fix: previews_enabled=false on EVERY worker — the <name>.cloudflare.app preview hostnames are what CF flags as "missing TLS Encryption" / "without Always Use HTTPS" / "without HSTS" (2026-07-07: 17 workers alerted; 13 more were one scan-wave away — Phase 4.09 alone only covers the deployed repo). AI-bots-block + AI Labyrinth are NO LONGER applied (user directive 2026-07-07: AI bots must reach the sites for AI/LLM SEO — both hurt that; report-only now). Auto-SKIPS the judgment-call classes (Bot Fight, unproxied-CNAME, dangling-A, DMARC). Advisory (never blocks the ship). |
| 4.05e | Account-wide CF zone/DNS security-harden (~/.claude/skills/carmack/tools/cf-account-harden.py) | DRY_RUN=0 python3 …/cf-account-harden.py — sweeps ALL zones and applies the free zone/DNS security layer, idempotently: DNSSEC (enable where off — CF-registrar auto-publishes the DS), Free WAF Managed Ruleset (deploy id=77454fe2d30c4220b5701f6fdfb893ba in http_request_firewall_managed — Free plan, high-impact/zero-day CVE coverage), Leaked-Credential detection (leaked-credential-checks.enabled=true), CAA (add the CF Universal-SSL partner-CA union so cert renewal never breaks). Mail-touching fixes (no-mail-domain SPF/DKIM/DMARC lockdown; a p=none→quarantine bump) are GATED behind INCLUDE_MAIL=1 (account-specific; a real sender must not get p=reject blind). Verified live 2026-07-07 across 18 zones. Advisory. |
| 4.05b | CSP header + a11y baseline (every public site) | Live response carries a Content-Security-Policy header (not just the other security headers) — if absent, AUTO-FIX inline (Hono secureHeaders({contentSecurityPolicy}) or equivalent), allow-list ONLY what islands load, browser-verify islands still render under it (0 Refused to…Content Security Policy console violations), redeploy. AND a11y: exactly one content <h1> per page + no skipped heading levels (a logo/wordmark must be <span>/<div>, never <h1>); all text WCAG-AA contrast (≥4.5:1; 3:1 for large/bold ≥24px). Auto-fix in source + redeploy + re-verify on any failure. |
If a phase finds issues, FIX THEM INLINE before moving to the next phase. Do not defer. Do not warn-and-continue for fixable issues. The goal is: every /ship run leaves the repo in a strictly better state than it found it.
Warnings are NOT acceptable. biome check . must return 0 errors AND 0 warnings. Fix warnings by: (1) auto-fixing with biome check --fix, (2) manually fixing remaining issues, or (3) suppressing false positives in biome.json overrides with justification. "Pre-existing warnings" is not an excuse — fix them all.
Dependabot is NOT optional. If gh api repos/{owner}/{repo}/dependabot/alerts returns open alerts with available fixes, add overrides to package.json, run install, verify build, commit, and push — all within this run.
Transitive Dependabot alerts need overrides, not removal (MANDATORY — 2026-05-19). If an alert stays open after the package was removed as a direct dependency, it is still pulled transitively — run npm ls <pkg> to see the path (e.g. wrangler → miniflare → ws). npm uninstall of the direct dep does NOT clear it. Pin the safe version with an overrides block in package.json, npm install, then verify the resolved version with npm ls <pkg> AND npm audit. Note npm audit --omit=dev can read 0 while Dependabot still shows the alert — Dependabot scans the whole lockfile incl. dev. Reference incident: ws@8.18.0 alert stayed open after npm uninstall ws because wrangler → miniflare still pulled it; overrides: {"ws": "^8.20.1"} resolved it.
Stale lockfile check (MANDATORY). Before fixing Dependabot alerts, check which lockfile(s) the alerts reference (manifest_path in the API response). If alerts reference a lockfile the project doesn't use (e.g., pnpm-lock.yaml when project uses bun.lock), the stale lockfile MUST be deleted from git and added to .gitignore.
ACTIVE_LOCK=""
[ -f bun.lock ] && ACTIVE_LOCK="bun.lock"
[ -f pnpm-lock.yaml ] && ACTIVE_LOCK="pnpm-lock.yaml"
[ -f package-lock.json ] && ACTIVE_LOCK="package-lock.json"
for STALE in pnpm-lock.yaml package-lock.json bun.lock yarn.lock; do
if [ "$STALE" != "$ACTIVE_LOCK" ] && git ls-files --error-unmatch "$STALE" 2>/dev/null; then
git rm --cached "$STALE"
echo "$STALE" >> .gitignore
echo "Removed stale lockfile: $STALE (was causing false Dependabot alerts)"
fi
done
AIVA / Cloudflare Worker Hard Gates
When shipping AIVA or any Cloudflare Worker/static-assets site, /ship must verify the real production path, not just a clean local build:
- Prove the deploy target — inspect
wrangler.json/wrangler.toml routes and recent wrangler deployments list output before changing production.
- Use a clean build path — remove stale
dist, run the same TypeScript mode the repo uses for production (tsc -b when project references exist), run vite build, and run the repo asset guard before wrangler deploy.
- Block plaintext Worker secrets — names matching
KEY|SECRET|TOKEN|PASSWORD must not live as literal values in wrangler.json vars; they belong in wrangler secret bindings.
- Handle binding-name conflicts deliberately — if
wrangler secret put reports that a binding name is already in use, remove the plaintext var from config, deploy without --keep-vars, immediately put the secret, then verify wrangler secret list reports secret_text.
- Do not use
--keep-vars when cleaning plaintext vars — it preserves the stale plaintext binding you are trying to remove.
- Verify live HTML and CSP — fetch the production URL with a cache buster and prove expected prod domains are present while dev fingerprints are absent. For AIVA/Clerk this means
clerk.example.com present and no prime-rhino-99, .clerk.accounts.dev, pk_test, or sk_test.
- Verify OG/social preview readiness — every public site must expose
title, description, canonical URL, og:*, twitter:*, and an absolute 1200x630 share image URL that returns 200 with an image/* content type. If missing, add the metadata and image before deploying.
- Run production integration — when the repo has it, run
npm run test:integration:prod after deploy and before reporting success.
Audit output from ClawPatch, Codex, or manual review is not complete until it becomes a regression guard where practical: package-script checks, full asset scanning, secret-list verification, peer-dependency checks, bounded overrides, real a11y gating, and a local Worker integration harness with readiness/cleanup.
Worker Preview Hostnames — LOCKED by Default (user policy 2026-07-07)
Every worker's <name>.cloudflare.app preview hostname stays DISABLED (previews_enabled=false) unless the user explicitly approves exposing it — a bare /ship or task instruction is NEVER preview-publish consent. Preview hostnames bypass ALL zone security (Always-HTTPS/HSTS/WAF/Access are zone-level; cloudflare.app is not your zone) and are exactly what CF Security Insights flags as "Domains missing TLS Encryption" / "without Always Use HTTPS" (17 workers alerted 2026-07-07).
- To expose a preview: AskUserQuestion first, offering (a) Access-gated (RECOMMENDED — enable preview + Cloudflare Access on preview URLs: dash → Workers & Pages → worker → Settings → Domains & Routes → Enable Cloudflare Access, policy = you@example.com / named collaborators; supported per developers.cloudflare.com/workers/configuration/previews/, fetched 2026-07-07) or (b) fully public temporarily. Only after an explicit yes, run the enabling command prefixed
CLAUDE_ALLOW_PREVIEW_PUBLIC=1.
- Enforcement (3 layers): PreToolUse hook
pre-preview-lock-guard.sh blocks any Bash/Edit/Write that sets previews_enabled/preview_urls true without the env override; Stop hook preview-lock-stop-check.sh sweeps the account after any deploy session and blocks stop while a preview is open; Phases 4.09 + 4.05d re-close previews on every ship (wrangler deploy silently re-enables them).
- User-approved exposures: re-apply AFTER the Phase 4.09/4.05d sweeps in the same run, and state the still-open preview in the final report.
After fixing Dependabot alerts, VERIFY they actually closed. Wait 60s after push, then re-check:
sleep 60
REMAINING=$(gh api "repos/${REPO}/dependabot/alerts" --jq '[.[] | select(.state=="open")] | length')
if [ "$REMAINING" -gt 0 ]; then
gh api "repos/${REPO}/dependabot/alerts" --jq '.[] | select(.state=="open") | "\(.number): \(.dependency.manifest_path)"'
fi
Usage
/ship [optional instructions]
Deployment Targets
| Command | Target |
|---|
/ship | PRODUCTION (default) |
/ship --staging | Staging first, then prompt for production |
/ship --staging-only | Staging only, no production |
/ship --audit | Run safety audit tiers only (no deploy) |
/ship --audit tier2 | Run specific audit tier |
/ship --audit full | Run all audit tiers |
/ship --watch-ci | Block until all CI checks complete after push |
/ship --no-ci | Skip Phase 4.6 CI monitoring entirely |
/ship --skip-review | Skip Phase 4.2 multi-agent code review |
/ship --skip-perf | Skip Phase 4.3 web performance audit |
/ship --skip-visual | Skip Phase 4.35 visual regression check |
/ship --skip-loghygiene | Skip Phase 5.2 post-deploy log-hygiene pass |
Examples
/ship — Deploy to production with full verification
/ship the auth feature — Deploy specific feature to production
/ship --staging — Deploy to staging first, then promote to prod
/ship --allow-lint-errors — Override lint failures (with audit trail)
/ship --audit — Run safety audit only, no deployment
/ship --audit tier2 — Run Tier 2 investigation audit
Tools Available
osgrep — Code Search During Gates
osgrep query "throw.*module scope" --mode fulltext
osgrep query "validateClientEnv" -n 20
qmd — Documentation Search
qmd query "deployment checklist"
qmd query "rollback procedure"
bd — Task Tracking (MANDATORY)
bd create --title="Deploy: run quality gates" --type=task --priority=1
bd update <id> --status=in_progress
bd close <id> --reason="Deployed to production"
Before running /ship: Create a beads issue for the deployment itself (bd create --title="Deploy <feature>" --type=task) if one doesn't already exist. Close it only after post-deploy verification passes.
If .beads/ does not exist and this is a git repo:
git config beads.role maintainer && bd init --quiet --skip-hooks
If bd is not installed, warn the user once and continue (don't block the deploy).
CRITICAL: NO TASK MANAGEMENT TOOLS
DO NOT use TodoWrite, TaskCreate, or TaskUpdate tools. Use bd (beads) for task tracking instead. This is a project-wide rule — see CLAUDE.md "Beads Task Tracking Rule".
Print a short status line when transitioning phases:
-- Phase 0 OK -> Phase 1: Build & Test --
Just execute the phases in order. Do the work, don't track the work in TodoWrite.
DEPLOYMENT TARGET (DEFAULT: PRODUCTION)
Default behavior: Deploy directly to production. The /ship command deploys to production unless explicitly told otherwise.
Staging-first workflow: If user specifies "staging first", "to staging", or --staging:
- Deploy to staging/preview environment first
- Display staging URL and verification steps
- Wait for explicit "promote to production" confirmation
- Then run production deployment
Flags:
- (default): Deploy to production
--staging or "to staging first": Deploy to staging, then prompt for production
--staging-only: Deploy to staging only, skip production
--prod or --production: Explicit production deploy (same as default)
--no-babysit: Skip Phase 6 PR babysitter
--babysit: Force Phase 6 even on default branch (monitor CI after direct push)
EXECUTION PROTOCOL
CODE SEARCH
Use standard tools (Grep, Glob, Read) for code discovery. Use osgrep if available for AST-aware search, but never block on it — fall back to Grep/Glob immediately if unavailable.
Reference Files Index
All reference files are in ~/.claude/skills/ship/references/. Read the relevant ones for each phase.
| File | Phases | Content |
|---|
pre-deploy-checks.md | -1, -0, 0.5 | Repo context verification, merge conflict resolution, Vercel rate limit check |
code-quality.md | 0 | Biome lint auto-setup/fixing, zero-tolerance policy, AI-powered fix loop, npm audit |
build-and-test.md | 1, 1.1 | Build verification, smart test execution (timeout/pkill), API contract check |
security-audit.md | 1.25, 1.26 | Security audit, Dependabot auto-fix, code scanning hygiene (OpenSSF, DevSkim) |
react-safety.md | 1.3, 1.35 | React scope/env safety checks, useEffect abuse detection |
~/.claude/skills/shared/undefined-null-render-safety.md | 1.3a | Undefined/null-render bug class — 9 patterns (null-gate hides UI, unguarded property/.map/string/Date/JSON.parse on possibly-undefined, raw-undefined render, missing loading/empty states, backend omits a field) + post-deploy live DOM grep for rendered undefined/NaN/[object Object]. Cross-refs the admin blind-spot class. Reference incident: AIVA isTest !== null toggle silently hidden (2026-06-01). |
seo-and-session.md | 1.4, 1.42 | SEO/sitemap consistency, FOUC prevention, session invalidation (chunk load recovery) |
infra-and-admin.md | 1.45, 1.46, 1.5, 1.55, 1.56 | Third-party config, CSP audit, XSS checks, admin auth, infra protection, admin-user sync, risky change verification, hot-path data-volume & cache-topology gate, D1 schema-drift / migration-applied-to-prod gate |
deployment.md | 2, 3, 3.5, 4 | Override path, GitHub push, README/changelog, Vercel/Cloudflare/Docker deploy |
post-deploy.md | 4.1-4.6, 5, 6 | Post-deploy verification, multi-agent review, web perf, visual regression, rollback, CI gate, monitoring, PR babysitter |
ios-release.md | 4.7 | iOS app surface release (absorbed /ios-ship + /app-ship 2026-06-12): scope detection (web-class → Capacitor OTA publish only; native-class → greenlight gate → manual-distribution signing → archive/export → TestFlight upload + group assignment → App Store submit → MIN_SHELL_VERSION OTA resync), App Review requirements table (live-verified), blocking rules. Development work routes to /ios. |
~/.claude/skills/shared/ant-verification-protocol.md | 1.27, 1.28 | Ant-level quality gates: OWASP Top 10 sweep, supply chain audit, enhanced security review |
~/.claude/skills/shared/observability-instrumentation.md | 1.57, 5.2 | Observability / log hygiene: Phase 1.57 pre-deploy instrumentation gate (changed code logs at boundaries, actionable errors, no swallowed catches); Phase 5.2 post-deploy /log-hygiene pass over the just-shipped worker's live logs. Downgrade-noise-never-delete; no fabricated volume. |
~/.claude/skills/shared/site-security-defaults.md | 4.05, 4.05a | Site security defaults: 12-item baseline (security.txt, HSTS, CSP, COOP/CORP, etc.) — runs post-deploy against live URL, blocking. Phase 4.05a auto-PATCHes CF zone settings (always_use_https, min_tls_version, zone-HSTS, etc.) via CF API; bot protection excluded. |
Phase Execution Order
Read ALL reference files at the start of a /ship run. Execute phases in this exact order:
- Phase -2: World-state refresh (from
~/.claude/skills/shared/no-lie-verification.md Check 1) — MANDATORY FIRST STEP. Run git fetch origin --prune, then git log --oneline @{u}..origin/main 2>/dev/null | head -10 and git status. If origin/main has commits not in the current branch's history (other developers / other agents pushed during this session), BLOCK and rebase before any other phase. If the current branch is a PR branch and its base moved, rebase + git push --force-with-lease + re-verify gh pr view <N> --json mergeable returns MERGEABLE before continuing. Why: the 2026-05-18 hospital-ledger incident — /carmack agent pushed PR #2, main moved during the session (3 commits including one that touched src/routes/home.tsx), the PR went CONFLICTING, the agent reported "PR opened, branch tracking origin" because it never re-fetched. /ship caught it in this session; this gate makes it the FIRST thing /ship does next time.
- Phase -1: Repository context verification (from
pre-deploy-checks.md)
1.5. Phase -0.5: Worktree safety gate (from pre-deploy-checks.md) — blocks deploy if any active worktree has uncommitted or unpushed work. Last line of defense behind the auto-push hook (post-bash-worktree-autopush.sh).
1.7. Phase -0.4: Workers Cache safety gate (from pre-deploy-checks.md Phase -0.4; full pattern ~/.claude/skills/shared/workers-cache-safety.md) — fires when the repo's wrangler config sets cache.enabled: true. Run ~/.claude/skills/ship/tools/workers-cache-check.sh <repo>: BLOCKs the cookie-auth heuristic-cache cross-user leak (no global no-store default) and the request-scheme-sniff redirect-loop class (url.protocol === "http:" without cf-visitor — the 2026-07-06 exampleapp ~25-min sitewide 301-loop outage); WARNs on wrangler <4.69 (flag silently inert) and HSTS gated on request-URL protocol (silently dropped under the cache layer's http:// presentation). No-op when no cache config.
- Phase -0: Merge conflict auto-resolution (from
pre-deploy-checks.md)
- Phase 0: Code quality / lint auto-fixing (from
code-quality.md)
- Phase 0.5: Deployment rate limit check (from
pre-deploy-checks.md)
- Phase 1: Build & test (from
build-and-test.md)
- Phase 1.1: API contract verification (from
build-and-test.md)
- Phase 1.25: Security audit (from
security-audit.md)
7.5. Phase 1.24: Supply-chain scanner health — fires when the changeset touches package.json or any lockfile. Run ~/.claude/skills/shared/tools/socket-health-check.sh --live. BLOCK on exit 2 (scanner disarmed via SOCKET_CLI_ACCEPT_RISKS in a shell rc) or exit 1 (aliased but no working token → every install fails closed). Complements, does not duplicate: Dependabot/npm audit = known CVEs in published versions; Phase 1.29 security-review = OUR code; Socket = what the DEPENDENCY ships (install scripts, obfuscated code, typosquats, malware, new-author takeover, unexpected network/filesystem/shell access). Skip when no dependency file changed.
- Phase 1.26: Code scanning hygiene (from
security-audit.md)
- Phase 1.3: React scope & env safety (from
react-safety.md)
- Phase 1.35: useEffect abuse check (from
react-safety.md)
- Phase 1.4: SEO & sitemap consistency (from
seo-and-session.md)
- Phase 1.4a: OG/social preview metadata and share image gate (from
seo-and-session.md)
12.5. Phase 1.4b: Favicon presence gate (MANDATORY, every served-HTML site) — verify the deployed <head> carries a <link rel="icon"> (or a /favicon.* route returning image/*). Public site → curl the live HTML; auth-gated site → grep the repo head template + confirm on the deployed page via the logged-in :9222/fcdp browser. If missing, AUTO-FIX with a self-contained base64 inline-SVG <link rel="icon"> in the <head> (add data: to img-src if a CSP is set), redeploy, and browser-verify it decodes to valid SVG (DOMParser, no parsererror) and renders. Skip only for non-HTML API/CLI workers.
- Phase 1.42: Session invalidation check (from
seo-and-session.md)
- Phase 1.45: Third-party config & infra protection (from
infra-and-admin.md) — includes production auth Worker binding dry-run and municipal form fallback regression gates
14.1. Phase 1.27: OWASP Top 10 sweep on changed files (from ant-verification-protocol.md Section 1)
14.2. Phase 1.28: Supply chain & enhanced review on new deps (from ant-verification-protocol.md Section 5)
14.6. Phase 1.45e: Embed + rendered-href integrity — fires when the diff touches an <iframe>, a prose→HTML renderer, or any URL literal. Referrer-sensitive embeds need referrerpolicy when the site sends Referrer-Policy: no-referrer (the Hono secureHeaders() default → YouTube "Error 153"); no rendered href may contain whitespace; YouTube ids must be 11 chars; newly-autolinked prose URLs must each be validated before deploy. Reference impl tools/check-links.mjs (TISF). Pattern: ~/.claude/skills/debug/references/csp-cache-patterns.md #27.
- Phase 1.46: Admin-user sync verification (from
infra-and-admin.md)
- Phase 1.5: Deployment verification for risky changes (from
infra-and-admin.md)
15.4. Phase 1.55: Hot-path data-volume & cache-topology gate (from infra-and-admin.md) — fires when the changeset touches an HTTP route handler, a scheduled()/cron body, a cache read/write, or any SQL/D1 query. Enumerate every query that runs before the response; BLOCK on any per-request query reading >~100k rows (measure with wrangler d1 execute --json → meta.rows_read); for every cache-key read, confirm a writer exists; for every cache-warmer, confirm something reads those exact keys; lagged-source trailing windows must anchor on MAX(ts) not now(). Skip only for pure CLI/docs/test changes with no route/cron/cache/query in the diff.
15.45. Phase 1.56: D1 schema-drift / migration-applied-to-prod gate (from infra-and-admin.md, full pattern ~/.claude/skills/shared/d1-schema-drift.md) — fires when the diff touches any D1 INSERT/UPDATE/SELECT column set, adds/edits a migrations/*.sql file, or references a new column/table. Run ~/.claude/skills/shared/tools/d1-schema-drift-check.sh <repo>: (A) wrangler d1 migrations list --remote must be clean; (B) every column the changed worker code writes must exist in the remote PRAGMA table_info. The migration FILE existing and the LOCAL D1 having the column are NOT proof — only remote is. BLOCK on any code-referenced column/table missing in prod (it 500s every write to that table with a generic error) or unapplied migrations. Fix by applying ONLY the missing DDL additively to remote (ALTER … ADD COLUMN/CREATE … IF NOT EXISTS), verify with PRAGMA table_info, then INSERT OR IGNORE the filename into d1_migrations. NEVER run wrangler d1 migrations apply to catch up a drifted DB — it re-runs non-idempotent data migrations (e.g. UPDATE steps SET order_index = order_index + 1) and corrupts data. Skip only for diffs with no D1 write/migration change. Reference incident: 2026-07-05 AIVA POST /api/intake 500'd for every user (mos in code + migration file, never applied to prod).
15.5. Phase 1.57: Observability / instrumentation gate (from ~/.claude/skills/shared/observability-instrumentation.md) — fires when the diff adds or changes external calls (fetch/DB/queue/3rd-party), catch branches, state transitions, or new error paths. Verify the changed code is debuggable in production BEFORE it ships: structured logs at those boundaries (log({event, ...attrs}), secrets/PII redacted), every new catch records what was attempted + the real upstream reason (nothing swallowed), and no new catch-all/ambiguous error message (a vague error is usually two root causes sharing one string — split them). FIX inline, don't warn-and-continue (fix-all rule). This is the deploy-gate form of /carmack's instrument-on-build behavior; the changed code's first production failure must be diagnosable from its logs alone. Skip only for pure docs/test/copy diffs with no logic change.
15.9. Phase 1.29: Semantic security review gate — BLOCKING, loop until clean. The FINAL pre-deploy gate: invoke the built-in security-review skill (Skill tool). It does AI/semantic dataflow analysis (SQLi, XSS, SSRF, auth bypass, hardcoded secrets, business-logic flaws) — the vulnerability class /ship's grep-based Phase 1.45 checks and npm audit/Dependabot cannot see. ⚠️ OPERATIONAL (verified 2026-07-22): (a) security-review runs against the CURRENT WORKING DIRECTORY's git repo — NO path argument; it hard-fails "needs to run inside a git repository" if cwd isn't the repo. cd <repo-root> FIRST (the session cwd is normally the repo when /ship is invoked, but verify it hasn't drifted). (b) It reviews the COMMITTED branch diff vs the base (merge-base with origin/main) — NOT uncommitted working-tree edits (proven: a repo with 4 unstaged-modified files produced an EMPTY diff because HEAD == origin/main). So the changeset MUST be committed before this gate, or it reviews nothing and passes trivially. In the normal ship flow the feature code is already committed (you deploy committed code); if any part of this ship is still uncommitted at 1.29, commit it first. (c) An empty diff = 0 findings (a genuine no-change ship passes correctly). It returns a markdown report (file:line, severity, category, confidence 1–10) and self-filters false positives at confidence ≥8, so an "actionable finding" = any HIGH/MEDIUM in that report. Run the loop: (1) cd into the repo (ensure the changeset is committed), invoke security-review; (2) read its markdown report; (3) FIX every reported (confidence-≥8) finding in source inline — obey the No-Suppression Rule (never @ts-ignore/eslint-disable/biome-ignore a finding away); (4) re-invoke security-review; (5) repeat 1–4 until the report is empty (0 findings). Only then may Phase 2/3/4 (deploy) proceed. Document any finding you deem a genuine false positive inline with its reason. Loop guard: if the same finding survives 5 fix attempts, STOP the ship and surface it to the user with the finding + why the fix isn't landing — do NOT deploy past an unresolved security finding. If security-review is unavailable in this environment (Skill returns unknown-skill), do NOT silently pass — WARN the user the semantic gate could not run and fall back to the Phase 1.45 grep checks. Skip ONLY for pure docs/README/comment diffs with zero code change.
- Phase 2: Manual override path (from
deployment.md)
- Phase 3: GitHub deployment (from
deployment.md)
- Phase 3.5: README & changelog auto-update (from
deployment.md)
18.5. Phase 3.55: README config-sync auto-regen (from deployment.md) — BLOCKING. Run scripts/regen-readme-status.sh (or any project-equivalent regen script). If it produces a diff, commit + push as docs: regen README current-setup [skip auto-readme] BEFORE deploying. Catches drift between deployed code and the README's "how the site works" block. Added 2026-05-10 after the user shipped 3 architectural changes that the GitHub Action's path-allowlist trigger missed.
- Phase 4: Downstream deployments (from
deployment.md)
19.5. Phase 4.7: iOS app surface release (from ios-release.md) — fires when the repo ships an iOS surface (ios/ + capacitor.config.ts, or an *.xcodeproj/*.xcworkspace app target, or Expo app.json). Web-class changes in a Capacitor repo: verify the OTA publish ran (web deploys and app updates come from ONE build — web first, then app) and closed-loop-check /api/app/updates + a sim relaunch — no native build, no ask needed. Native-class changes: Phase 4.7.0a ask gate FIRST — AskUserQuestion which channel (TestFlight only / TestFlight + App Store submission / hold); App Store submission NEVER runs without an explicit same-session "App Store" answer (user rule 2026-06-12). Then full greenlight → sign → archive → TestFlight pipeline with the build-to-group assignment that actually notifies testers. BLOCK on OTA pushes that include native-affecting changes.
19.7. Phase 4.07: Email deliverability verification — BLOCKING when the changeset touches email-send code (a send_email binding, email provider seam, From address/NOTIFY_FROM, or SPF/DKIM/DMARC records). Run ~/.claude/skills/ship/tools/email-deliverability-check.sh <repo> --domain <sender-domain> --accounts <dest mailboxes>. Pre-deploy: the static half (apex-sender-into-hosted-mailbox MX check + Email Sending onboarding). Post-deploy: trigger a REAL send through the app's own event path, then verify the message landed in the destination INBOX (not SPAM) with Authentication-Results: dmarc=pass. A resolved send() is NOT delivery evidence — the 2026-07-13 diy-fax incident sent successfully via Resend from the apex hello@example.com and every inbound-fax alert was silently spam-foldered by the same domain's Google Workspace. Three-verdict discipline: INBOX+dmarc=pass = pass; SPAM or dmarc!=pass = BLOCK; no message found = UNVERIFIED (re-trigger, don't pass).
19.8. Phase 4.08: Workers-Cache post-deploy verification (from post-deploy.md Phase 4.08; pattern ~/.claude/skills/shared/workers-cache-safety.md) — BLOCKING when the deployed changeset enables or modifies the wrangler cache block. (1) Staged enable: if the diff changes worker code AND newly enables cache, deploy the code first with cache.enabled: false, verify healthy, then enable in a second deploy. (2) t+15/45/90s monitoring across route classes (HTML page, authed/JSON API, public opt-in asset) — a <60s check is NOT evidence either way: cache-layer engage/disengage propagates in >30s (the 2026-07-06 incident's 3-second post-disable check false-negatived and triggered an unnecessary rollback). (3) Semantic header checks: authed/JSON → private, no-store; public opt-ins keep public, max-age; HTML matches repo policy; HSTS present on an HTTPS response. (4) Sitewide 3xx tripwire: ANY route 301/302-ing to its own URL → immediately redeploy with cache.enabled: false, wait ≥60s, then diagnose via wrangler tail --format json → event.request.url scheme (http:// for HTTPS visitors = the scheme-presentation class; fix with cf-visitor, never url.protocol).
19.9. Phase 4.09: Worker surface-exposure probe + AUTO-HEAL (ANY Cloudflare Worker repo) — MANDATORY, runs with --apply on every ship (not conditional): ~/.claude/skills/ship/tools/worker-surface-check.sh <repo> --apply. Why every time, not a cron: wrangler deploy silently RE-ENABLES the worker's .cloudflare.app preview hostname (and workers.dev subdomain) on essentially every deploy — so this class regresses each ship. /ship owning it with --apply is what makes it un-accumulate: the deploy that re-opened the surface is the same run that closes it again, before CF's scanner ever emails about it. The probe asks the CF API what wrangler won't tell you (GET /accounts/{acct}/workers/scripts/{name}/subdomain): (a) previews_enabled: true → the <name>.cloudflare.app preview hostname (half-provisioned, 522, un-securable) that trips CF Security Insights alert emails (7 workers flagged 2026-07-06); (b) a custom-domain worker with workers.dev enabled: true → an unprotected full duplicate of prod (the AIVA twin — a class CF's own Insights does NOT flag). --apply POSTs previews_enabled:false everywhere and enabled:false on custom-domain workers (the API POST is required — wrangler deploy does NOT disengage an already-enabled subdomain even with workers_dev:false in config, observed wrangler 4.104); then mirror workers_dev/preview_urls in the wrangler config for declarative parity. workers.dev-canonical sites (no custom routes) keep enabled:true — only previews are closed. Verify: re-probe shows previews=false (+ enabled=false for custom-domain) and the canonical URL still 200s. This makes a standing background cron unnecessary for the surface class — the guarantee lives at the deploy boundary.
- Phase 4.1: Post-deploy verification (from
post-deploy.md) — start with the multi-signal battery ~/.claude/skills/ship/tools/fleet-verify.sh <name> <url> (status/Cache-Control/cf-cache-status/HSTS/CSP/DOM-literals/h1/og/security.txt; grep for FAIL:), then the content-specific checks.
20.5. Phase 4.05: Site security defaults (from ~/.claude/skills/shared/site-security-defaults.md) — BLOCKING. Run the 12-item curl-based baseline check against the live URL. If items 1-11 fail, AUTO-FIX inline (add Worker route / middleware), commit, redeploy, re-check. Items 12-16 (TLS min, DNSSEC, CAA, SPF/DMARC) require Cloudflare API or registrar access — apply auto-fix recipes if CLOUDFLARE_API_KEY+CF_ZONE_ID are set, otherwise warn loudly with the exact command for the user to run.
20.55. Phase 4.05c: Copy-truth gate (from ~/.claude/skills/shared/no-lie-verification.md "Live-Artifact Re-Verification") — BLOCKING when the changeset modifies user-facing copy with numbers, percentages, dates, or counts. Auto-detect with git diff --name-only origin/main..HEAD | grep -E '\\.(tsx|jsx|html|md|astro|svelte|vue)$|public/.*\\.(js|html)$' and git diff origin/main..HEAD -- <those-files> | grep -E '\\+.*[0-9],?[0-9]{3,}'. If matches found, BLOCK until: (a) cache-busted curl https://<prod>/?cb=$(date +%s) returns every NEW number you added, (b) cache-busted curl returns ZERO matches for every OLD number you removed. Format: curl -s "https://<URL>/?cb=$CB" -H "Cache-Control: no-cache" | grep -oE "<old>|<new>" | sort -u. Why: the 2026-05-18 hospital-ledger incident — /carmack reported "no '10,000' strings remain — rg clean" against source, but rg doesn't see the deployed Worker output. The deployed artifact is what users see; source code is not. This gate proves the new copy is live and the old copy is gone.
20.6. Phase 4.05a: Cloudflare zone-level security enforcer (from ~/.claude/skills/shared/site-security-defaults.md § Phase 4.05a) — MANDATORY when CLOUDFLARE_API_KEY+CLOUDFLARE_EMAIL are set. Idempotently PATCHes Z1–Z8 zone settings (always_use_https=on, automatic_https_rewrites=on, min_tls_version=1.2, tls_1_3=on, opportunistic_encryption=on, ssl≥full, zone-HSTS enabled with preload, 0-RTT=on) so no domain can ship with CF Security Center–flagged defaults. Bot protection is intentionally excluded (false-positive risk on agent traffic). Closed-loop-verifies HTTP→HTTPS redirect + TLS 1.1 rejection. Added 2026-05-14 after CF flagged hospitalledger.com for always_use_https=off + min_tls_version=1.0 + HSTS disabled.
20.65. Phase 4.05b: CSP header + a11y baseline (every public site) — BLOCKING. (1) curl -sI https://<prod>/ | grep -i content-security-policy MUST return a CSP header. If absent, AUTO-FIX in source (Hono secureHeaders({contentSecurityPolicy:{...}}) or equivalent middleware), allow-listing ONLY hosts the islands actually load (inspect src/client/* for tile/font/CDN hosts; data: for inline images; 'unsafe-inline' on style-src only when a lib injects inline styles — never on script-src), then drive the logged-in :9222 Chrome to load each island page and BLOCK if the console shows any Refused to … Content Security Policy violation OR an island fails to render (map tiles/markers, charts) — widen the CSP minimally and re-verify. (2) a11y: for each route, evaluate the live DOM — document.querySelectorAll('h1').length MUST equal 1 and there must be no skipped heading levels (logo/wordmark must be <span>/<div>, not <h1>); run a contrast check (Lighthouse a11y or computed-style audit) and BLOCK on any text below WCAG-AA (≥4.5:1 normal, 3:1 large/bold ≥24px) — fix the theme tokens in source. Auto-fix → redeploy → re-verify. Static-count trap (2026-07-06): a curl+grep h1 count includes inert <template> content that is NOT in the rendered/a11y DOM — a template-driven UI can grep as 5 h1s while the runtime DOM has exactly 1 (xbox-nxe: grep said 5, agent-browser eval said 1 → no fix needed). Never BLOCK on the static count alone; the live-DOM querySelectorAll result is the verdict. Added 2026-06-04 (example-project-heritage Hono ship: no CSP, #6b7280 footer text ~3.7:1, logo <h1> colliding with page <h1>).
20.75. Phase 4.05e: Account-wide CF zone/DNS security-harden (advisory) — MANDATORY when ~/.cloudflared/cf-global-api-key.json exists. Run DRY_RUN=0 python3 ~/.claude/skills/carmack/tools/cf-account-harden.py. Idempotent; applies the free zone/DNS security layer account-wide: DNSSEC (enable where off), Free WAF Managed Ruleset (deploy on every zone), Leaked-Credential detection (enable), CAA (add CF Universal-SSL partner-CA union). Mail-touching fixes (parked-domain no-mail lockdown; p=none→p=quarantine) stay OFF unless INCLUDE_MAIL=1 — they are account-specific and a live sender must not get a blind p=reject. Complements 4.05a (per-zone TLS/HSTS enforcer) and 4.05d (insights sweep / security.txt / worker previews). Report what changed; NEVER block the ship. Reference: applied clean across 18 zones 2026-07-07 (see ~/Claude-Reports/cloudflare-security-audit-2026-07-07.html).
20.7. Phase 4.05d: Account-wide CF Security Insights sweep (advisory — replaces the cf-security-watch Hermes cron, paused 2026-07-06) — MANDATORY when ~/.cloudflared/cf-global-api-key.json exists. Run ~/.claude/skills/carmack/tools/cf-security-insights.sh --apply. Unlike Phase 4.05/4.05a (which secure ONLY the zone of the site being deployed) and Phase 4.09 (which closes ONLY the deployed worker's preview surface), this sweeps the entire Cloudflare account — every zone AND every Worker — applying the zero-perf-cost fixes: zone-level AI-bots block + AI Labyrinth + edge-served /.well-known/security.txt, and previews_enabled=false on every Worker (the <name>.cloudflare.app preview hostnames are exactly what CF Security Insights flags as "Domains missing TLS Encryption" / "without Always Use HTTPS" / "without HSTS" / "Security.txt not configured" — reference incident 2026-07-07: 17 workers alerted while 13 more sat un-flagged with previews on, because 4.09 is per-repo and the old sweep was zones-only). Custom-domain workers with workers.dev enabled are reported, not auto-fixed (needs repo context — run worker-surface-check.sh <repo> --apply). It auto-SKIPS the false-positive / judgment-call classes (Bot Fight Mode = hurts Lighthouse; unproxied CNAME = Clerk/Brevo need DNS-only; dangling A/AAAA = Google-forwarding origins are live; DMARC = CF over-counts a usually-valid record) — those still require a human dig/curl verify, so the sweep never touches them. Why in /ship, not a cron: folding it here gives account-wide coverage on every deploy (event-driven) instead of a daily cron — the user chose this so there's no standing background job. Trade-off (state it, don't hide it): if you go a long stretch without shipping, the account-wide sweep doesn't run in that window; Cloudflare still emails raw Security Insights to the inbox as the backstop. Report what it changed; NEVER block the ship on it. Skip only when CF creds are absent. Full triage map: ~/.claude/skills/shared/site-security-defaults.md (Cloudflare Security Insights section).
21. Phase 4.2: Multi-agent code review (from post-deploy.md)
22. Phase 4.3: Web performance audit (from post-deploy.md)
23. Phase 4.35: Visual regression check (from post-deploy.md)
24. Phase 4.5: Deployment failure rollback (from post-deploy.md)
25. Phase 4.6: GitHub Actions CI gate (from post-deploy.md)
26. Phase 5: Post-deploy monitoring (from post-deploy.md)
26.2. Phase 5.2: Post-deploy log-hygiene pass (invokes the /log-hygiene skill) — advisory, non-blocking. Fires when the changeset touched a route handler, a scheduled()/cron body, or an external integration. After Phase 5 monitoring confirms the deploy is healthy, run /log-hygiene <worker> --hours 1 --report-only against the now-live logs of what you just shipped — catch ambiguous errors / noisy lines the new code emits under real traffic (the instrumentation you gated at Phase 1.57, now observed in production). Report findings; bd create a follow-up for any cluster worth fixing — do NOT block the ship on fresh-traffic noise, and NEVER delete log lines (downgrade). Skip with --skip-loghygiene. The recurring scheduled version of this loop is the Phase-2 Hermes cron (bd HOME-w1xq).
26.5. Phase 5.5: Skill/config backup gate (user rule 2026-06-12) — BLOCKING before the final report. (a) Repo: git log @{u}..HEAD must be empty (every commit pushed to GitHub — wrangler deploy alone does NOT track anything). (b) Skills/config: if this session edited ANY file under ~/.claude/skills/, ~/.claude/agents/, ~/.claude/CLAUDE.md, or ~/.claude/settings.json, run ~/claude-code-boilerplate/scripts/backup-claude-config.sh and confirm it prints a pushed commit URL — the SessionStart auto-backup only captures the previous session's state, so mid-session skill improvements are invisible on GitHub until this runs. The final report cites both proofs.
27. Phase 6: PR babysitter (from post-deploy.md)
Pre-Ship Verification Checklist (Non-Negotiable)
Before declaring ANY deploy complete, verify ALL of these:
- Bundle hash changed —
curl -s <URL> | grep 'index-' must show a NEW hash vs. previous deploy
- Page loads correctly — curl the actual page, verify it returns 200 and contains expected content
- API endpoints work — test at least one authenticated endpoint returns data, not "Unauthorized"
- New routes reachable — if you added a route, verify it doesn't 404 or get swallowed by a catch-all
- No stale cache — if
cf-cache-status: HIT, verify it's serving the new content not old
- OG/social preview works — curl the live HTML and prove title/description/canonical,
og:image, and twitter:image are present; curl the image URL and prove 200 plus image/* content type. Create them if absent.
- Clean build — for Cloudflare Workers:
rm -rf dist && tsc -b && vite build && wrangler deploy (never just wrangler deploy)
- Type definitions correct — for CF Workers, edit
worker-configuration.d.ts (ambient), not just src/worker/env.d.ts (module)
- Worker secrets are secrets — sensitive bindings show as
secret_text in wrangler secret list; no plaintext key/token/password remains in wrangler.json vars
- Prod integration passes — if available, run
npm run test:integration:prod against the live domain before success
- PR mergeability re-verified — for every PR opened/touched in this session:
gh pr view <N> --json mergeable,mergeStateStatus must be MERGEABLE / CLEAN. CONFLICTING / BLOCKED / BEHIND blocks the final report.
- Every numeric claim in the final report has an inline proof citation — SQL query, curl output, file:line, build exit code + timestamp. Forbidden without proof: "verified", "confirmed", "all clean", "shipped", "deployed successfully", "PR opened ready to merge", any specific count/percentage. (See
~/.claude/skills/shared/no-lie-verification.md "Wording Discipline".)
- Triggered behavior was actually exercised, not just configured — if the changeset adds/modifies a failover, retry/backoff, fallback chain, circuit-breaker, rate-limit cooldown, error/
catch branch, conditional cron, or feature-flag gate, you must have induced the trigger and observed the path fire (Check 6 in no-lie-verification.md) — force the 429 / fail the dependency / trip the breaker on an isolated copy, confirm the right downstream component served, confirm the live instance is untouched. "The fallback is configured" ≠ "the fallback fires." Do this BEFORE reporting deployed.
If ANY check fails: do NOT say "deployed successfully". Fix it first.
Safety Audit Tiers
Tier 1 — Critical (always runs on deploy)
- Silent failure detection — env vars without validation
- Silent React startup failures — env validation throwing before mount
- Security audit — vulnerabilities, exposed secrets
- Test execution verification
Tier 2 — Investigation (--audit tier2)
Uses systematic-debugging:
- Blind spot auditor — edge cases missing test coverage
- Test quality gate — tests that pass but don't verify real behavior
- Rate limit protector — public endpoints missing rate limiting
Tier 3 — Deep Analysis (--audit tier3)
Uses carmack-mode-engineer:
- Code archaeology — "old/legacy" code that's actually critical
- Critical systems guard — unprotected auth/payment/data-deletion paths
- Build reproduction harnesses for complex issues
Full (--audit full)
Runs all three tiers in sequence.
Cross-Platform CI Verification (Rust/Multi-Platform PRs)
For PRs to open source Rust projects with multi-platform CI matrices:
cargo fmt --all -- --check
cargo clippy --all-targets --all-features
grep -n "let mut" src/**/*.rs | while read line; do
file=$(echo "$line" | cut -d: -f1)
var=$(echo "$line" | grep -oP 'let mut \K\w+')
if grep -A20 "let mut $var" "$file" | grep -q '#\[cfg(target_os'; then
echo "WARNING: $file — $var may need #[allow(unused_mut)]"
fi
done
After push: gh pr checks <PR_NUMBER> --watch
OUTPUT REQUIREMENTS
- Always show clear phase headers marking current stage
- Use visual indicators for success, failure, warnings
- Display progress bars for multi-step operations
- Provide actionable error messages when builds or tests fail
- Show deployment summary table with service name, status, and live URL
- Include estimated time for each phase
- Display comprehensive banners at phase transitions
SAFETY CONSTRAINTS
Critical Rules:
- FAIL FAST: Terminate immediately on build errors or test failures
- GitHub deployment ALWAYS happens before any other platform
- Multiple confirmation gates prevent accidental shipping
- All override actions are permanently logged
- Never silently skip tests or quality checks
- Refuse ambiguous commands that might bypass gates
- Data verification is mandatory for risky changes unless explicitly overridden
- NEVER execute destructive infrastructure commands — these require human execution
- NEVER modify or replace .tfstate files
- ALWAYS show terraform plan output to user before any terraform apply
Phase-Specific Rules:
- Phase -1: NEVER proceed if not in git repo or remote unreachable
- Phase -0.4: BLOCK (rc=2 from
tools/workers-cache-check.sh) on cookie-auth + no global no-store default (cross-user leak) or request-scheme sniff without cf-visitor (redirect-loop class). WARN on wrangler <4.69 (flag inert) and request-URL-gated HSTS.
- Phase 4.08: When the
cache block was enabled/modified: staged enable (code deploy first, cache-enable second); NEVER conclude from a <60s post-deploy/post-disable check (propagation >30s — the 2026-07-06 3s false-negative caused an unneeded rollback); monitor t+15/45/90 across route classes; BLOCK-and-auto-disable on any route 301/302-ing to its own URL, then wrangler tail --format json scheme probe before re-enabling.
- Phase 1: NEVER run
npm test or full npx vitest run — always use --changed and timeout
- Phase 1: NEVER retry tests more than 3 times — stop and report failures
- Phase 1: ALWAYS run
pkill -f vitest 2>/dev/null after every test invocation
- Phase -0: NEVER push or tag during merge conflict resolution
- Phase 0: NEVER deploy with lint errors unless explicit override
- Phase 0: After Biome auto-fix on inline-HTML projects (CF Workers, SSR), ALWAYS run embedded JS string safety check (Stage 1.7 in code-quality.md) — Biome's noUselessEscapeInString silently breaks JS inside HTML template strings
- Phase 0.5: BLOCK on 20+ deployments unless --force-override
- Phase 1: BLOCK on ANY test failure
- Phase 1.1: BLOCK if frontend calls API endpoints with no backend handler
- Phase 1.25: BLOCK on MODERATE+ vulnerabilities unless override
- Phase 1.26: WARN on TODO/FIXME/HACK comments in changed files (auto-fix to NOTE:)
- Phase 1.26: WARN on workflow files missing top-level permissions (auto-fix to read-only)
- Phase 1.26: BLOCK on
permissions: write-all at workflow top level (must narrow to job-level)
- Phase 1.3: BLOCK if env validation throws at module scope without fallbacks
- Phase 1.3a: When the changeset touches a React component that renders fetched/API data OR a backend handler whose JSON shape the frontend destructures, load
~/.claude/skills/shared/undefined-null-render-safety.md and run its 9-pattern sweep. BLOCK if any of: (a) a render is gated on a useState<T|null>/optional value that can stay null/undefined when the API field is missing/NULL → the control silently vanishes (the isTest !== null toggle bug); (b) unguarded .map/.length/property access on a value that can be undefined (API returned {} not [], or an error/404 shape); (c) .toLowerCase/.trim/split/new Date(...)/parseInt/JSON.parse on a possibly-undefined value (renders NaN/Invalid Date or throws); (d) JSX that can render raw undefined/NaN/null/[object Object]. TypeScript does NOT catch these when the response is any/unknown/Record<string, unknown>. Fix every instance (no @ts-ignore/biome-ignore), do NOT warn-and-continue.
- Phase 1.3a (post-deploy live gate, runs in Phase 4.1): for each changed admin/dashboard/detail view, drive the logged-in
:9222 Chrome (chrome-devtools MCP or CDP Runtime.evaluate) and BLOCK if document.body.innerText of the rendered page contains the literal undefined, NaN, Invalid Date, or [object Object], or if the console logged a TypeError: Cannot read propert(y|ies) of undefined. The deployed render is the proof — source review alone is not sufficient.
- Phase 1.4: BLOCK if noindex pages appear in sitemap or sitemaps are out of sync
- Phase 1.4-lastmod: AUTO-BUMP the
<lastmod> of every sitemap URL whose page content/template/copy changed in this changeset, to today's date, before deploy (find the sitemap source — public/sitemap.xml, a worker SITEMAP_XML const, or a generator — and edit only the touched <loc> entries, never the unchanged ones). Commit with the deploy. Do NOT bump pages that didn't change. Post-deploy: curl the live /sitemap.xml and confirm the changed pages show today's date.
- Phase 5.x reindex (post-deploy, when sitemap
lastmod changed): the fresh sitemap IS the durable reindex signal — Google picks it up on next crawl. Google's /ping?sitemap= GET endpoint was deprecated in 2023 (returns 404) — do NOT rely on it. Programmatic GSC resubmit needs a Search-Console-scoped credential on the property (plain gcloud auth print-access-token returns 403 — wrong scope). If no scoped credential is available, TELL THE USER the exact manual step: Google Search Console → Sitemaps → resubmit sitemap.xml, and URL Inspection → Request Indexing for the top changed pages. Optional: IndexNow (Bing/Yandex) — POST the changed URLs to https://api.indexnow.org/indexnow with a key file hosted at the domain root (a separate one-time setup + deploy). Never claim "reindexed" — you can only refresh the sitemap + submit; the search engine decides when to recrawl.
- Phase 1.4a: BLOCK if a public HTML site lacks OG/Twitter metadata, canonical URL, or a project-specific share image URL that returns
200 image/*
- Phase 1.4b: BLOCK if any served-HTML site has no favicon — no
<link rel="icon"> in the deployed <head> AND no /favicon.* route returning image/*. AUTO-FIX inline (base64 inline-SVG <link rel="icon"> added to the <head>; add data: to img-src when a CSP is present), redeploy, and browser-verify the icon decodes to valid SVG and renders before declaring done. Auth-gated pages (CF Access 302) are verified via the repo head template + the logged-in :9222/fcdp browser, not an unauth curl. Skip only for pure non-HTML (API/CLI) workers.
- Phase 1.42: BLOCK if no vite:preloadError handler (deploy will log users out)
- Phase 1.42: BLOCK if no CDN-Cache-Control: no-store header on HTML responses
- Phase 4.2: Run multi-agent review when changes span 3+ files or touch security paths
- Phase 4.2: BLOCK if any reviewer finds CRITICAL security/performance issue
- Phase 4.3: WARN if Lighthouse score drops below 50 (severe perf regression)
- Phase 4.35: BLOCK if mobile screenshot shows blank page (broken rendering)
- Phase 4.35: WARN if mobile screenshot shows horizontal scroll (responsive bug)
- Phase 4: WARN if deployed version.json SHA doesn't match local HEAD (stale deploy)
- Phase 4: BLOCK if
CLOUDFLARE_API_TOKEN in .env.local overrides wrangler OAuth (auth conflict)
- Phase 4: Verify
version.json is in .gitignore and not git-tracked (prevents stale commits)
- Phase 4: Verify service worker has
version.json in NETWORK_ONLY patterns (prevents SW caching)
- Phase 1.46: BLOCK if visibility refetch triggers loading spinner (full-screen flash on tab switch)
- Phase 1.46: WARN if user data hooks lack background refresh (admin changes invisible until reload)
- Phase 1.46: WARN if admin status-change endpoints don't clean up dependent fields
- Phase 1.46: WARN if optimistic updates don't re-sync with server after success
- Phase 1.45: BLOCK if CSP img-src/script-src/connect-src missing third-party CDN domains (broken icons with no console errors)
- Phase 1.45: BLOCK if critical third-party env vars missing from wrangler config
- Phase 1.45: BLOCK if sensitive Worker bindings (
KEY|SECRET|TOKEN|PASSWORD) are stored as plaintext vars instead of secret_text
- Phase 1.45: BLOCK if
wrangler secret put fails with binding-name conflict and the workflow has not removed the plaintext var, deployed without --keep-vars, re-put the secret, and re-listed secrets
- Phase 1.45: BLOCK if production Worker dry-run or live HTML contains
pk_test, sk_test, .accounts.dev, or a dev auth issuer/JWKS
- Phase 1.45: BLOCK if AIVA live HTML/CSP after deploy lacks
clerk.example.com or still contains prime-rhino-99, .clerk.accounts.dev, pk_test, or sk_test
- Phase 1.45: BLOCK if Clerk/custom auth production domain status is not complete before enabling prod auth keys
- Phase 1.45: BLOCK if municipal submit/category code changed without a regression proving category/form 500 fallback
- Phase 1.45b: BLOCK if ANY 311 backend submitter changed how it forwards address/lat/lng to the city's API without regression tests covering: (a) a LONG-FORM
input.address with comma+city+state+zip, (b) an EMPTY input.address (frontend didn't pass one), (c) a COORD-STRING input.address like "37.77, -122.41" (legacy fallback shape — must never reach a structured slot). Applies to src/sf311.ts Verint slots (sf_full_address, Location_description, sf_address_number, sf_primary_street_name, sf_zip_code, sf_city, sf_state_code), src/seeclickfix.ts SCF slots (address, location_details[*]), and any future backend. Verint silently drops structured-location fields when these slots carry long-form strings — Pattern #21 (error-handling-patterns.md). Short-address tests do NOT reproduce the bug. The frontline guard is looksLikeCoordinateString() in src/sf311.ts — any new structured-location-touching code must route through it (or document why the destination accepts coord strings safely).
- Phase 1.45c: BLOCK if a third-party-response parser (function that returns
{ok, ...} after reading response.text()/response.json() from a city form, OAuth callback, webhook, or scraper) was modified AND there is no captured real-success + real-failure fixture under __fixtures__/ AND/OR the test file doesn't readFileSync the fixture. Synthetic hand-written success HTML in tests cannot catch heuristic drift: success and failure pages of ASP.NET WebForms / Verint dform / aspx replays often share 99% of their structure and differ only in a small dynamic element (e.g. <span id="InfoReq1_lblError">…</span>). See ~/.claude/skills/shared/third-party-signal-fixtures.md for the capture protocol and error-handling-patterns.md Pattern #23 for the 2026-05-27 DBI reference incident (every IBA-submitted DBI complaint reported as validation error for ~10 days while the city actually recorded all of them).
- Phase 1.45d: BLOCK if
~/tools/linkcheck.sh <repo> reports a browser-confirmed dead external link (genuine 404/410, not a curl bot-block). Fires whenever the diff adds/edits an external href/URL in site source. The checker curls each link and, on any non-200, re-tests via the live :9222 Chrome so government/WAF curl-403s (ag.ny.gov, health.ny.gov, otda, nysenate) are NOT false-flagged — only links that fail in a real browser block. Treat any source comment like "URLs are used EXACTLY as provided — do not alter" as a hypothesis to re-verify, not a reason to ship a 404.
- Phase 1.45d: Classify with THREE verdicts, never two. An HTTP status from a plain client does not classify a link (measured 2026-07-09):
nytimes.com curl-403 / browser-200 = ALIVE; fcc.gov curl-000 (HTTP/2 stream … INTERNAL_ERROR) / browser-200 = ALIVE; calwavetech.com curl-000 = DEAD (NXDOMAIN); militarypoisons.org/… = DEAD behind a styled 404 page. Only NXDOMAIN proves dead and only a final 2xx proves alive — everything else is UNVERIFIED: warn, do not block (a gate that cries wolf on every WAF gets disabled). Resolve the unverified set in a real browser and read the authoritative status via performance.getEntriesByType('navigation')[0].responseStatus (fcdp open <url> → fcdp js). Two traps: fcdp js pretty-prints JSON across lines (match /\{[\s\S]*\}/ on full stdout, never tail -1), and key off responseStatus, NOT readyState === 'complete' — the latter never fires on JS-challenge pages. Reference impl: tools/check-links.mjs --check-liveness [--browser] (TISF).
- Phase 1.45d: Never substitute a replacement URL you cannot prove points at the same resource.
calwavetech.com (dead, a solar developer) vs calwave.energy (live, an ocean-wave company) is a one-character-plausible swap and a fabrication. Confirm by page title/content, else leave it dead and track it. An accepted-dead allowlist entry must carry a reason + tracking id, and a link that comes back to life must be reported as RESURRECTED and fail — otherwise the ledger rots into a mute button.
- Phase 1.45d: Do NOT put liveness checking in
build/predeploy — a network probe fails the build on someone else's outage. Run it on a cadence and block only on the shape checks at build time.