# Blog Delivery Contract The contract every blog must pass before being presented to the user. Five gates that fire automatically between content generation and delivery, plus an iteration loop that retries failures up to three times before escalating. This contract is the v1.9.0 answer to a failure pattern from the v1.8.x cycle: skills had reviewers, but the reviewer ran as advisory and the writer presented sloppy drafts anyway. The fix is infrastructure, not effort. Same shape as `scripts/lint_prose.py` (v1.8.4) and `tests/test_installer_sync.py` (v1.8.6). ## Gate summary | Gate | What it enforces | Failure mode | Implementation | |---|---|---|---| | 1. Capability Discovery | Required tools + agents are available before write begins | Block if no valid local hero and no permitted image path; block if reviewer agent missing | `scripts/blog_preflight.py --gate 1` | | 2. Format Completeness | `.md` + `.html` + `.pdf` + `hero.(png\|jpg)` all present | Block on any missing artifact | `scripts/blog_render.py` renders `.html` and `.pdf`; `scripts/generate_hero.py` emits `hero.` | | 3. Visual Verification | Rendered HTML has no SVG overflow, no console errors, valid JSON-LD | Block on any defect; preserve screenshots | `scripts/blog_preflight.py --gate 3` via `patchright` | | 4. Content Review | `blog-reviewer` scores ≥ 90/100 AND zero P0 issues | Block + iterate | `agents/blog-reviewer.md` (now blocking) | | 5. Asset + Link Integrity | Every `` resolves, every `` returns 200, schema validates | Block on any 404 or count mismatch | `scripts/blog_preflight.py --gate 5` | All gates run sequentially. First failure halts the chain and triggers the iteration loop. Successful drafts ship with `preflight-report.json` + `review.md` + `preview/*.png` in the draft folder. ## Gate 1: Capability Discovery Runs once at the start of `/blog write` or `/blog rewrite`. Enumerates the project's available capabilities and writes `/capabilities.json`. Every later gate consumes this artifact rather than re-detecting. ### What gets enumerated - **MCP servers loaded**: `nanobanana-mcp`, `dataforseo-mcp`, others. Detected via tool availability, not by reading `.mcp.json` (the file may declare servers that failed to start). - **Env vars present**: `GOOGLE_AI_API_KEY`, `UNSPLASH_ACCESS_KEY`, `PEXELS_API_KEY`, `PIXABAY_API_KEY`. Key names only; values never read or logged. - **Optional Python deps**: `patchright`, `weasyprint`, `google-genai`, `requests`. Probed via `importlib.util.find_spec()`. - **Project-root context files**: `BRAND.md`, `VOICE.md`, `DISCOURSE.md`. Loaded only via the trusted installed `load_untrusted_root.py` helper. - **Agents available**: `blog-reviewer` is mandatory; `blog-researcher`, `blog-writer`, `blog-seo`, `blog-translator` are optional. - **Helper scripts present**: `scripts/lint_prose.py`, `scripts/analyze_blog.py`, the new `scripts/blog_preflight.py` itself. ### Failure modes - **No hero path at all** (no valid local `hero.png` or `.jpg`, no Banana MCP, no Gemini key, no stock API key, and Openverse unreachable): BLOCK with explicit setup instructions. - **Reviewer agent missing**: BLOCK. Cannot enforce Gate 4 without it. - **Capability declared but unused**: WARN (informational, not blocking). Example: `dataforseo-mcp` is loaded but the post topic doesn't need keyword research. ## Gate 2: Format Completeness Every delivered blog ships with four artifacts in `/`: - `.md`: canonical source of truth. Frontmatter + prose + figure references. The `.html` and `.pdf` are rendered from this; they cannot diverge by construction. - `.html`: self-contained, valid HTML5, JSON-LD `BlogPosting` schema, Open Graph + Twitter Card meta, dark-mode-aware CSS via `prefers-color-scheme`, real `` tag for hero (never a chart-as-hero). - `.pdf`: generated from rendered HTML via `patchright`'s `page.pdf()`, or `weasyprint` as fallback when Playwright is unavailable. - `hero.(png|jpg)`: 1200×630 raster image. Either generated by an image-gen path or downloaded from a licensed stock source. Never hot-linked; always lives in the draft folder. Implementation split: `scripts/blog_render.py` renders `.html` and `.pdf` from the existing `.md`; `scripts/generate_hero.py` emits `hero.` plus `hero-credit.txt`; `scripts/blog_preflight.py` Gate 2 verifies all four artifacts. Failure to produce any of the four artifacts blocks delivery. ### Optional hygiene pass (non-blocking) After rendering and before Gate 3, you may run `python3 scripts/blog_hygiene.py --md .md --html .html --apply` to auto-apply judgment-free fixes: add `loading="lazy"` to images that lack it and insert a Table of Contents on posts over 2000 words. It is optional, never blocks delivery, and reports any images missing alt text (which a human must fill in). It does not replace any gate. ## Gate 3: Visual Verification Renders the `.html` in headless `patchright` at three viewport widths and checks for visual defects. This is the "review before present" step. ### Viewport widths - 375×812 (mobile, iPhone SE class) - 768×1024 (tablet, iPad portrait) - 1280×800 (desktop) ### Checks per viewport 1. **Full-page screenshot**: saved to `/preview/.png` for inspection. 2. **SVG bounding box check**: for every `` and `
` element, query `getBoundingClientRect()` on the element and on every descendant `text`, `path`, `rect`, and `image` child. Assert no descendant overflows its parent SVG `viewBox`. This catches the exact class of defect from the rankenstein.pro draft: labels positioned outside the chart area, dashed lines passing through annotation text. 3. **Dark-mode pass**: re-render with `prefers-color-scheme: dark` emulation. Assert the body `background-color` differs from light mode. This catches the `var()` in attribute regression where dark mode silently fails to swap colors because CSS custom properties were used in SVG XML attributes (which don't reliably resolve). 4. **Console errors**: capture browser console output during render. Assert zero errors. 5. **JSON-LD validation**: parse the `
` resolves. Local paths must stay under the draft root after `resolve()`, refuse symlinks, and use slug-sanitized filenames. Absolute URLs must use `http` or `https` only; reject `javascript:`, `data:`, `file:`, protocol-relative URLs, credentials in URLs, and invalid hosts. - External URL checks must resolve DNS before connecting and reject loopback, private, link-local, multicast, reserved, unspecified, and cloud-metadata IP ranges. Do not follow redirects automatically; if redirects are allowed, validate every redirect target with the same checks. Use 5s timeouts, response-size caps, and no request bodies. - Validate links with `HEAD` first, then fall back to `GET` with a small range request when servers block `HEAD`. Accept valid 2xx or 3xx responses, and allow documented 403 or 405 cases only through the per-project `external-links.allowed` config. - `og:image` URL resolves with the same SSRF, redirect, size, and timeout rules; this is the load-bearing social-preview asset. - Every `
` resolves under the same policy, or is in the per-project `external-links.allowed` config. - Every `filename.ext` mention either references a real file in the project (verified via `Path.exists()`) or is wrapped in a "hypothetical example" marker. - `` is set and well-formed. - JSON-LD `wordCount` matches actual `
` word count within ±5%. Catches the "I claimed 1,715 words but the body is 1,400" honesty defect. ## Hero Image Generation Ladder Tried in order. First success wins. Skip steps for capabilities not available per Gate 1's `capabilities.json`. 1. **Banana MCP** (`nanobanana-mcp` loaded as a tool, not just declared in `.mcp.json`): call its `generate_image` tool with an optimized six-component prompt (Subject + Action + Context + Composition + Lighting + Style) targeting 1200×630. 2. **Direct Gemini API** (`GOOGLE_AI_API_KEY` present, MCP not loaded): call the `google-genai` SDK with `gemini-3.1-flash-image` by default. Fallback, in order, to `gemini-3.1-flash-lite-image` and `gemini-3-pro-image`. See `https://ai.google.dev/gemini-api/docs/image-generation`. 3. **Premium stock APIs** (`UNSPLASH_ACCESS_KEY`, `PEXELS_API_KEY`, or `PIXABAY_API_KEY` present): search via the official API using post title + top tags as query. Do not scrape or construct raw CDN URLs. Capture each source's license metadata and attribution requirements, download the asset locally, and write `hero-credit.txt`. Unsplash, Pexels, and Pixabay use their own licenses; do not treat them as CC sources. 4. **Openverse public API** (no key required): `GET https://api.openverse.org/v1/images/?q=&aspect_ratio=wide&license=cc0,by`. Pick the top relevance match with complete attribution. Always download to `hero.` plus `hero-credit.txt` for CC attribution. 5. **Block with clear error**: "Hero image required but no generation path available. Configure Banana MCP, set GOOGLE_AI_API_KEY, set UNSPLASH/PEXELS/PIXABAY key, or place a 1200×630 hero.png in the draft folder manually." Implementation: `scripts/generate_hero.py`. Always writes `hero-credit.txt` next to `hero.` for attribution compliance, even when generation paths 1-2 (AI-generated, no attribution needed) are used. The file then contains "AI-generated; no attribution required." ## Iteration Loop When any gate fails, the orchestrator (`skills/blog/SKILL.md`) drives a retry loop: 1. **Capture diagnostic**: which gate failed, which specific check, screenshot if visual, scorecard if content. 2. **Construct iteration prompt** keyed to the failing gate: - Gate 2 missing artifact → re-run `scripts/blog_render.py` after fixing the source `.md` - Gate 3 visual fail → re-run `scripts/blog_render.py` with adjusted layout (e.g. shrink SVG inner area, wrap long labels) - Gate 4 score < 90 → re-dispatch `blog-writer` agent with the reviewer report as input and an instruction to fix the lowest-scoring category first - Gate 4 P0 issue → re-dispatch with a targeted instruction for that specific P0 - Gate 5 404 → re-dispatch with instruction to remove or replace the broken URL 3. **Re-run all five gates** from Gate 1. (Re-running Gate 1 catches the case where an iteration changed capabilities, e.g. installed a missing dep.) 4. **On pass**: present draft to user with a one-line summary ("Delivered after N iteration(s)"). 5. **On fail after 3 iterations**: STOP. Show the user the failure diagnostic for each remaining defect, the partial draft, the latest reviewer report, and an explicit "Manual fix required" message. Do not iterate further automatically. The orchestrator holds the loop counter. Sub-skills never loop themselves; that would risk infinite loops if two sub-skills disagree. ## Bypass Mechanism Strict mode is the default. Users can override only via an explicit operator-controlled `--no-strict` flag on `scripts/blog_preflight.py` or signed/trusted project config. Draft frontmatter is untrusted content and must never disable delivery gates. Any bypass logs loudly: ``` WARNING: Delivery contract bypassed. Failed gates: [Gate 3, Gate 5]. The draft is being presented anyway per --no-strict. Do not publish without manual review. ``` Bypass is intended for two cases: (1) the contract has a false positive the user has verified, (2) the user is iterating on a draft and wants to see intermediate output before the gates pass. It is not intended for shipping. A future CI workflow will reject merges where `preflight-report.json` shows `"blocked": true && "strict": false` (the bypassed-and-published case); that enforcement is not yet implemented. ## Community Footer Public support links are optional. Share one only when the user asks for support, contribution guidance, or relevant next steps. Do not append a promotional footer to every deliverable, and never put support promotion inside generated blog content, HTML, or markdown files. ``` Project discussions: https://github.com/AgriciDaniel/claude-blog/discussions Public community: https://www.skool.com/ai-marketing-hub ``` For bugs or reproducible defects, prefer: https://github.com/AgriciDaniel/claude-blog/issues. ## References - `skills/blog/references/quality-scoring.md`: the 100-point numeric scoring rubric used by Gate 4 - `skills/blog/references/editorial-heuristics.md`: the P0-P3 ordinal scoring used for the P0 filter in Gate 4 - `skills/blog/references/visual-media.md`: image and asset standards consumed by Gate 5 - `skills/blog/references/schema-stack.md`: JSON-LD structure validated by Gate 3 step 5 - `agents/blog-reviewer.md`: the reviewer agent that produces the Gate 4 scorecard - `scripts/load_untrusted_root.py`: the v1.8.3 helper used for project-root file loading in Gate 1 - `scripts/lint_prose.py`: the v1.8.4 prose linter run as part of Gate 4's editorial-heuristics scoring - `tests/test_blog_delivery_contract.py`: coherence test that asserts this contract and its implementation stay in sync ## How this contract maps to the v1.8.x lesson The rankenstein.pro draft failure was a Category-3 defect: a contradiction between what the project's tooling could do (Banana MCP, blog-reviewer, /blog image, Playwright) and what the writer actually invoked (none of them). The v1.8.x lesson says: convert Category-3 into a gate or a test, not into more discipline. This contract is exactly that. Five gates fire automatically. The writer cannot forget to use the tools because the tools are wired into the gates themselves.