EZO Content Pipeline
For: Qasim Humayun
From: Brent Bartosch
Date: 2026-08-05
You asked three things in Tuesday's call: how many checks there are, whether they are weighted equally, and which ones carry the most importance. All three are answered below. The second one has an answer you will not expect, so it is first.
Nothing is weighted. The score is the number of checks that passed divided by the number that ran, as a percentage. Every check counts exactly once. There are no weights anywhere in the code - pipeline/qa_gate/core.py:539 is the whole calculation:
score = round(100 * passed / total)
This matters because the call notes recorded it as "weights were AI-generated from shared documents, not yet validated." That is not the case. There are no weights to validate. Flat scoring was a deliberate choice, and whether it stays flat is a decision for you, not an assumption for me. See the decisions at the end.
Severity does exactly two things, and neither is weighting:
score >= 80 and zero CRITICAL findings to pass (core.py:541).So a page can score 95/100 and still not pass. That is the only place importance is expressed today, and it is binary rather than graded.
The 80 threshold is not arbitrary: it maps to the engagement's own "80% first-pass QA" target.
| Page type | Checks | Severity spread |
|---|---|---|
| Blog | 8 | 3 CRITICAL, 2 HIGH, 3 MEDIUM |
| Feature page | 21 | 10 HIGH, 10 MEDIUM, 1 LOW |
| Comparison page | 12 | 7 CRITICAL, 3 HIGH, 2 MEDIUM |
| Solution page | 22 | 1 CRITICAL, 11 HIGH, 9 MEDIUM, 1 LOW |
A page is only ever checked against its own page type's list. The counts differ because the team's documents specify different amounts for each: the feature and solution page templates are fully specified in the sprint SOP, while the blog workflow is still undocumented, so the blog list is deliberately short.
Of the 63 checks, 11 carry a line-level citation in the code - the document name and line you can go read. The rest are covered by a statement recorded once per page type rather than on each rule, quoted in full in each section below.
| Page type | Line-level citations |
|---|---|
| Blog | 7 of 8 |
| Feature page | 0 of 21 |
| Comparison page | 4 of 12 |
| Solution page | 0 of 22 |
This is worth saying plainly because it is the weakest part of the answer to your question. Blog and comparison rules were written against your documents and each one names its line. The feature and solution rules were written against the SOP's template tables, and the line numbers were recorded once for the whole page type instead of per rule. No value in this document was invented, but the two halves are not equally easy for you to audit, and you should know which is which before you spend time on it.
8 checks. 3 CRITICAL, 2 HIGH, 3 MEDIUM. Source: pipeline/qa_gate/blog_page.py.
7 of 8 carry a line-level citation naming the team document and line they came from.
The remaining 1 do not carry their own line reference. They are not invented - the values were read off the template tables in the document quoted below - but the line is recorded once for the whole page type instead of on each rule, so you cannot spot-check those rows the way you can the rest. Flagged here rather than left for you to find.
CANON, and what it is: - audit/data/docs/blog-qa-process.md (+ the older revision doc-2.md) is the team's Blog QA Process. It is a PROCESS document - a 3-the AI reviewer (Qwen -> Kimi -> Claude consolidation) plus a manual final-review checklist. It specifies NO page structure and NO character or element counts for blogs. - audit/data/docs/marketing-team-sprint-sops.md specifies Feature/Solution, Use Case and Industry page structures in detail. It contains NO blog section. Its "Rules for standardization" chapter (line 1474 onward) is the one page-type-agnostic part, and CTA rules there apply to any asset. - audit/data/docs/rule-book-qa-rubric.md (Qasim's rulebook) is about topical clusters, LLM citations and server-log retrieval measurement. It states no on-page blog rules at all - no meta title or description lengths, for blogs or anything else. - audit/data/docs/pmm-automation-wishlist.md line 24 puts "Create asset-specific workflows ... for blogs" at P0 in the team's OWN backlog: the blog workflow is, by their admission, not documented yet.
| # | Group | Check | What it looks at | Reads | Limit | If it fails | Where the rule comes from |
|---|---|---|---|---|---|---|---|
| 1 | page-level metadata: presence only, no invented ranges | blog-h1-present |
the field is filled in | seo.h1 |
- | CRITICAL | blog-qa-process.md:176 "Headings are descriptive and logically organized" (the article title is the page's H1); pmm-automation-wishlist.md:43 "Automate pre-publishing checks: Verify links, metadata, heading hierarchy ...", whose scope list at :56 includes "Corporate and product blogs". The SOP's 20-40 char H1 rule is stated only for feature pages (marketing-team-sprint-sops.md:64), so it is NOT applied here. |
| 2 | page-level metadata: presence only, no invented ranges | blog-meta-title-present |
the field is filled in | seo.meta_title |
- | CRITICAL | pmm-automation-wishlist.md:41 "Draft meta titles, meta descriptions ..." and :43 (metadata verified pre-publish). Lengths: the 50-60 / 145-160 ranges appear ONLY inside the feature (sops:64-65), use-case (:546-547) and industry (:927-928) tables - never for blogs - so presence is all the rule checker can honestly assert. |
| 3 | page-level metadata: presence only, no invented ranges | blog-meta-description-present |
the field is filled in | seo.meta_description |
- | CRITICAL | pmm-automation-wishlist.md:41 "Draft meta titles, meta descriptions ..." and :43 (metadata verified pre-publish). Lengths: the 50-60 / 145-160 ranges appear ONLY inside the feature (sops:64-65), use-case (:546-547) and industry (:927-928) tables - never for blogs - so presence is all the rule checker can honestly assert. |
| 4 | page-level metadata: presence only, no invented ranges | blog-slug-format |
URL formatting, path prefix allowed | seo.slug |
- | MEDIUM | Live convention, read from their own published URLs: every EZO blog sits under a /blog/ path segment - corporate blogs at ezo.io/blog/ |
| 5 | structure | blog-section-order |
sections present and in publish order | whole page | 2 sections | HIGH | SOP overall, no line recorded |
| 6 | structure | blog-body-headings |
every item carries its heading | body.blocks |
- | HIGH | blog-qa-process.md:176 "Headings are descriptive and logically organized" + :29 "Review only the complete article" (an empty body is not a complete article). Descriptiveness is a judgment call for the AI reviewer; presence of a heading per section is machine-checkable. |
| 7 | structure | blog-internal-links-recorded |
the field was answered, even if the answer is empty | body.internal_links |
- | MEDIUM | blog-qa-process.md:178 "Internal linking opportunities have been considered" + pmm-automation-wishlist.md:42. "Considered" is not "N links", so the check is that the draft RECORDS the consideration; an empty list is a valid answer, a missing key is not. It hangs off the article body because that is where the links live. |
| 8 | CTAs (page-type agnostic canon) | cta |
CTA wording: sentence case, 2-5 words, nothing vague | whole page | - | MEDIUM | marketing-team-sprint-sops.md:1474-1560 "Rules for standardization > CTAs" - sentence case, 2-5 words on buttons, no vague labels - stated outside any page-type chapter; blog-qa-process.md:180 requires a clear CTA. |
These are omissions on purpose. Where the team's own documents are silent, no number was invented, because a number invented here would look like the team's rule forever.
21 checks. 10 HIGH, 10 MEDIUM, 1 LOW. Source: pipeline/qa_gate/feature_page.py.
0 of 21 carry a line-level citation naming the team document and line they came from.
The remaining 21 do not carry their own line reference. They are not invented - the values were read off the template tables in the document quoted below - but the line is recorded once for the whole page type instead of on each rule, so you cannot spot-check those rows the way you can the rest. Flagged here rather than left for you to find.
Feature / Solution page ruleset, encoded from the Marketing Team Sprint SOPs.
The feature and solution page templates share the SOP's own table values, and the line numbers are recorded against the solution page ruleset (
solution_page.py, quoted in the solution section below). Same numbers, recorded once:CANON, and what the rule checker takes from it: - Template publish order and tables: 13 sections, H1 20-40 (QA checklist line 588), meta title 50-60 (:589), meta description 145-160 (:590), hero supporting heading 40-50 (:91), hero description 200-230 (:92, :595), 3 stats (:168), stats H2 50-70 (:164), 6 benefit cards (:213), benefits H2 50-60 (:209), 5-8 logos (:263), 4-6 workflow blocks (:313), 2-4 trust badges (:136), 2-3 testimonials (:368), 3-5 resources (:425), 8-10 FAQs (:474, :619), final CTA H2 30-50 (:543). - Section 4.2 competitive context (pmm-page-brief-process-v1.md:219): "solution pages can carry a light wedge (why this category, why us) without becoming a comparison page. Keep it to one to two sentences maximum." Encoded twice: the wedge field is capped at two sentences (HIGH), and a head-to-head comparison_table section on a solution draft is CRITICAL - that is the comparison page's defining section, and a solution draft that carries one has become a comparison page.
| # | Group | Check | What it looks at | Reads | Limit | If it fails | Where the rule comes from |
|---|---|---|---|---|---|---|---|
| 1 | Page-level SEO | h1-length |
character count within range | seo.h1 |
20-40 chars | HIGH | SOP overall, no line recorded |
| 2 | Page-level SEO | meta-title-length |
character count within range | seo.meta_title |
50-60 chars | MEDIUM | SOP overall, no line recorded |
| 3 | Page-level SEO | meta-title-separator |
meta title separator convention | seo.meta_title |
- | LOW | SOP overall, no line recorded |
| 4 | Page-level SEO | meta-description-length |
character count within range | seo.meta_description |
145-160 chars | MEDIUM | SOP overall, no line recorded |
| 5 | Page-level SEO | slug-format |
URL word count and formatting | seo.slug |
3-6 words | MEDIUM | SOP overall, no line recorded |
| 6 | Section presence + order | section-order |
sections present and in publish order | whole page | 13 sections | HIGH | SOP overall, no line recorded |
| 7 | Hero | hero-supporting-heading |
character count within range | hero.supporting_heading |
40-50 chars | MEDIUM | SOP overall, no line recorded |
| 8 | Hero | hero-description |
character count within range | hero.description |
200-230 chars | MEDIUM | SOP overall, no line recorded |
| 9 | Stats: exactly 3 | stats-count |
how many items in the section | stats.items |
3-3 chars | HIGH | SOP overall, no line recorded |
| 10 | Stats: exactly 3 | stats-h2 |
character count within range | stats.h2 |
50-70 chars | MEDIUM | SOP overall, no line recorded |
| 11 | Benefits: exactly 6 cards | benefits-card-count |
how many items in the section | benefits.cards |
6-6 chars | HIGH | SOP overall, no line recorded |
| 12 | Benefits: exactly 6 cards | benefits-h2 |
character count within range | benefits.h2 |
50-60 chars | MEDIUM | SOP overall, no line recorded |
| 13 | Trusted-by logo strip: 5-8 | logo-count |
how many items in the section | logos.items |
5-8 chars | HIGH | SOP overall, no line recorded |
| 14 | Feature-detail workflows: 4-6 | feature-block-count |
how many items in the section | feature_detail.blocks |
4-6 chars | HIGH | SOP overall, no line recorded |
| 15 | Feature-detail workflows: 4-6 | feature-block-body-length |
feature block body length | whole page | 350-360 chars | MEDIUM | SOP overall, no line recorded |
| 16 | Trust badges: 2-4 | trust-badge-count |
how many items in the section | trust_badges.items |
2-4 chars | HIGH | SOP overall, no line recorded |
| 17 | Testimonials: 2-3 | testimonial-count |
how many items in the section | testimonials.items |
2-3 chars | HIGH | SOP overall, no line recorded |
| 18 | Resources: 3-5 | resource-count |
how many items in the section | resources.items |
3-5 chars | HIGH | SOP overall, no line recorded |
| 19 | FAQs: 8-10 | faq-count |
how many items in the section | faqs.items |
8-10 chars | HIGH | SOP overall, no line recorded |
| 20 | Final CTA | final-cta-h2 |
character count within range | final_cta.h2 |
30-50 chars | MEDIUM | SOP overall, no line recorded |
| 21 | CTAs across the page: sentence case, no banned labels, 2-5 words | cta |
CTA wording: sentence case, 2-5 words, nothing vague | whole page | - | MEDIUM | SOP overall, no line recorded |
12 checks. 7 CRITICAL, 3 HIGH, 2 MEDIUM. Source: pipeline/qa_gate/comparison_page.py.
4 of 12 carry a line-level citation naming the team document and line they came from.
The remaining 8 do not carry their own line reference. They are not invented - the values were read off the template tables in the document quoted below - but the line is recorded once for the whole page type instead of on each rule, so you cannot spot-check those rows the way you can the rest. Flagged here rather than left for you to find.
CANON, and what the rule checker takes from it: - Section 4.6 core principles: source-first, claim-by-claim verification, evidence discipline, honesty over aggression, externally safe. - Phase 5 (external-safety pass): every negative/competitor-gap claim must carry one validated label - "Not publicly documented", "Requires configuration / paid tier" (only if citable), or "Via integration" with the integration named - and scope must be qualified ("via CMDB", "some Elite setups") over absolute "they can't do X". - Evidence discipline: no claim ships without a citable source. Competitor numbers are quantitative claims; the citation markers accepted here are brief_gate's own (CITATION_RE / CLAIM_RE are imported, not re-invented, so page check and brief check cannot drift). - The fairness pass (Phase 7 "fairness"; the exemplar opens with "Snipe-IT is a solid, open-source ITAM tool" and carries a whole "Who Snipe-IT Is Right For (Be Honest)" section): at least one competitor strength is acknowledged. - The feature matrix ("Head-to-Head Comparison", the exemplar's 13-row capability table): a comparison page without one is not a comparison.
| # | Group | Check | What it looks at | Reads | Limit | If it fails | Where the rule comes from |
|---|---|---|---|---|---|---|---|
| 1 | page-level metadata: presence only, no invented ranges | comparison-h1-present |
the field is filled in | seo.h1 |
- | CRITICAL | Section 4.6 states no character ranges for comparison pages; the 20-40 / 50-60 / 145-160 ranges are feature/use-case/industry table values (marketing-team-sprint-sops.md) and are NOT applied here. |
| 2 | page-level metadata: presence only, no invented ranges | comparison-meta-title-present |
the field is filled in | seo.meta_title |
- | CRITICAL | Section 4.6 states no character ranges for comparison pages; the 20-40 / 50-60 / 145-160 ranges are feature/use-case/industry table values (marketing-team-sprint-sops.md) and are NOT applied here. |
| 3 | page-level metadata: presence only, no invented ranges | comparison-meta-description-present |
the field is filled in | seo.meta_description |
- | CRITICAL | Section 4.6 states no character ranges for comparison pages; the 20-40 / 50-60 / 145-160 ranges are feature/use-case/industry table values (marketing-team-sprint-sops.md) and are NOT applied here. |
| 4 | No live slug convention is documented for comparison pages; same honest treatment as blogs - lowercase hyphenated segments, path prefixes allowed, no invented word count. | comparison-slug-format |
URL formatting, path prefix allowed | seo.slug |
- | MEDIUM | SOP overall, no line recorded |
| 5 | Section 4.6 Phase 2 maps THE competitor; every safety scan below needs to know who the page is about. | comparison-competitor-named |
the field is filled in | competitor |
- | CRITICAL | SOP overall, no line recorded |
| 6 | structure | comparison-section-order |
sections present and in publish order | whole page | 3 sections | HIGH | SOP overall, no line recorded |
| 7 | structure | comparison-table |
ComparisonTable | whole page | - | CRITICAL | SOP overall, no line recorded |
| 8 | Section 4.6 safety and evidence rules | comparison-negative-claim-labels |
NegativeClaimLabels | whole page | - | CRITICAL | SOP overall, no line recorded |
| 9 | Section 4.6 safety and evidence rules | comparison-competitor-numbers-cited |
CompetitorNumbersCited | whole page | - | CRITICAL | SOP overall, no line recorded |
| 10 | Section 4.6 safety and evidence rules | comparison-honesty-over-aggression |
HonestyOverAggression | whole page | - | HIGH | SOP overall, no line recorded |
| 11 | Section 4.6 safety and evidence rules | comparison-competitor-strength |
CompetitorStrength | whole page | - | HIGH | SOP overall, no line recorded |
| 12 | CTAs (page-type agnostic canon) | cta |
CTA wording: sentence case, 2-5 words, nothing vague | whole page | - | MEDIUM | marketing-team-sprint-sops.md "Rules for standardization > CTAs". |
These are omissions on purpose. Where the team's own documents are silent, no number was invented, because a number invented here would look like the team's rule forever.
22 checks. 1 CRITICAL, 11 HIGH, 9 MEDIUM, 1 LOW. Source: pipeline/qa_gate/solution_page.py.
0 of 22 carry a line-level citation naming the team document and line they came from.
The remaining 22 do not carry their own line reference. They are not invented - the values were read off the template tables in the document quoted below - but the line is recorded once for the whole page type instead of on each rule, so you cannot spot-check those rows the way you can the rest. Flagged here rather than left for you to find.
CANON, and what the rule checker takes from it: - Template publish order and tables: 13 sections, H1 20-40 (QA checklist line 588), meta title 50-60 (:589), meta description 145-160 (:590), hero supporting heading 40-50 (:91), hero description 200-230 (:92, :595), 3 stats (:168), stats H2 50-70 (:164), 6 benefit cards (:213), benefits H2 50-60 (:209), 5-8 logos (:263), 4-6 workflow blocks (:313), 2-4 trust badges (:136), 2-3 testimonials (:368), 3-5 resources (:425), 8-10 FAQs (:474, :619), final CTA H2 30-50 (:543). - Section 4.2 competitive context (pmm-page-brief-process-v1.md:219): "solution pages can carry a light wedge (why this category, why us) without becoming a comparison page. Keep it to one to two sentences maximum." Encoded twice: the wedge field is capped at two sentences (HIGH), and a head-to-head comparison_table section on a solution draft is CRITICAL - that is the comparison page's defining section, and a solution draft that carries one has become a comparison page.
| # | Group | Check | What it looks at | Reads | Limit | If it fails | Where the rule comes from |
|---|---|---|---|---|---|---|---|
| 1 | page-level SEO: the shared Feature/Solution table values | solution-h1-length |
character count within range | seo.h1 |
20-40 chars | HIGH | SOP overall, no line recorded |
| 2 | page-level SEO: the shared Feature/Solution table values | solution-meta-title-length |
character count within range | seo.meta_title |
50-60 chars | MEDIUM | SOP overall, no line recorded |
| 3 | page-level SEO: the shared Feature/Solution table values | solution-meta-title-separator |
meta title separator convention | seo.meta_title |
- | LOW | SOP overall, no line recorded |
| 4 | page-level SEO: the shared Feature/Solution table values | solution-meta-description-length |
character count within range | seo.meta_description |
145-160 chars | MEDIUM | SOP overall, no line recorded |
| 5 | page-level SEO: the shared Feature/Solution table values | solution-slug-format |
URL word count and formatting | seo.slug |
3-6 words | MEDIUM | SOP overall, no line recorded |
| 6 | section presence + publish order (template lines 5-29) | solution-section-order |
sections present and in publish order | whole page | 13 sections | HIGH | SOP overall, no line recorded |
| 7 | hero (template hero table) | solution-hero-supporting-heading |
character count within range | hero.supporting_heading |
40-50 chars | MEDIUM | SOP overall, no line recorded |
| 8 | hero (template hero table) | solution-hero-description |
character count within range | hero.description |
200-230 chars | MEDIUM | SOP overall, no line recorded |
| 9 | stats: exactly 3 | solution-stats-count |
how many items in the section | stats.items |
3-3 chars | HIGH | SOP overall, no line recorded |
| 10 | stats: exactly 3 | solution-stats-h2 |
character count within range | stats.h2 |
50-70 chars | MEDIUM | SOP overall, no line recorded |
| 11 | benefits: exactly 6 cards | solution-benefit-card-count |
how many items in the section | benefits.cards |
6-6 chars | HIGH | SOP overall, no line recorded |
| 12 | benefits: exactly 6 cards | solution-benefits-h2 |
character count within range | benefits.h2 |
50-60 chars | MEDIUM | SOP overall, no line recorded |
| 13 | trusted-by logo strip: 5-8 | solution-logo-count |
how many items in the section | logos.items |
5-8 chars | HIGH | SOP overall, no line recorded |
| 14 | workflow blocks (the template's feature-detail section): 4-6 | solution-workflow-block-count |
how many items in the section | feature_detail.blocks |
4-6 chars | HIGH | SOP overall, no line recorded |
| 15 | trust badges: 2-4 | solution-trust-badge-count |
how many items in the section | trust_badges.items |
2-4 chars | HIGH | SOP overall, no line recorded |
| 16 | testimonials: 2-3 | solution-testimonial-count |
how many items in the section | testimonials.items |
2-3 chars | HIGH | SOP overall, no line recorded |
| 17 | resources: 3-5 | solution-resource-count |
how many items in the section | resources.items |
3-5 chars | HIGH | SOP overall, no line recorded |
| 18 | FAQs: 8-10 | solution-faq-count |
how many items in the section | faqs.items |
8-10 chars | HIGH | SOP overall, no line recorded |
| 19 | final CTA | solution-final-cta-h2 |
character count within range | final_cta.h2 |
30-50 chars | MEDIUM | SOP overall, no line recorded |
| 20 | Section 4.2 solution-specific rules | solution-wedge-limit |
SolutionWedgeLimit | whole page | - | HIGH | SOP overall, no line recorded |
| 21 | Section 4.2 solution-specific rules | solution-not-a-comparison |
SolutionNotAComparison | whole page | - | CRITICAL | SOP overall, no line recorded |
| 22 | CTAs (page-type agnostic canon) | cta |
CTA wording: sentence case, 2-5 words, nothing vague | whole page | - | MEDIUM | SOP overall, no line recorded |
These are omissions on purpose. Where the team's own documents are silent, no number was invented, because a number invented here would look like the team's rule forever.
You flagged a meta description at 162 characters showing as a "critical issue" against a 145-160 range, said it is not actually a critical SEO problem, and that you could not find the source of the range in your own documents. Three separate things there, and they have three different answers.
1. The range is real and cited, but it is not from your documents. It comes from the sprint SOP, at marketing-team-sprint-sops.md:64-65 for feature pages, :546-547 for use-case pages and :927-928 for industry pages, plus the solution page QA checklist at :590. That is why it is not in your files. It is part of the website standardisation canon, so whether it should apply at all is a conversation with Rubab rather than a code change.
2. It was never marked critical. A length overage produces a MEDIUM finding. The default severity for a character range check is MEDIUM (core.py:155), and CRITICAL fires only when the field is missing entirely. At 162 characters you would have seen MEDIUM. If something showed you the word critical, that is a presentation bug and I need to know which document or comment you were reading, because the check itself is behaving correctly.
3. Blogs already have no range at all. Blog meta descriptions are checked for presence only, deliberately, because no blog character counts exist in any team document. That is recorded in the blog omissions above.
These are yours, not mine, and I would rather have them on the record than guess:
You asked which model runs the fan-out query and retrieval tree prompt, since the prompt was written for GPT. It runs on openai/gpt-5.2 (pipeline/fanout.py:71, pipeline/config.py:24) - the same lineage you wrote it for, routed through EZO's OpenRouter account rather than a separate OpenAI key.
Generated by scripts/build_rule_inventory.py on 2026-08-05. Every count, threshold, severity and citation in this document is read out of the live code at build time, so re-running it after a rule change produces a correct document rather than a stale one. Nothing here was typed by hand.