> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hiveku.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Site Architecture, Bottom-Funnel Pages, the Decision Loop and Conversion

> How a site's pages get roles and clusters and a keyword map that shows every collision, how the comparison, alternatives and research pages that win the last click are planned and seeded from the tracked competitors, how a decaying page is found, briefed, refreshed or pruned on a weekly loop, and how every piece carries a stage-matched call to action from the brand's offers through two shortcodes

A content program that only publishes new posts grows a site nobody can navigate: no page is marked as the one that converts, two posts target the same keyword and split its ranking, a pillar exists in the SEO plan and nowhere on the site, a page that stopped earning traffic a year ago is still live, and the call to action at the end of every piece is whatever the writer felt like. Four contracts, each a row, a route and a rule, close those gaps. The other four round-B contracts, the named author, the SERP brief, positioning and titles, are on [Authors, the SERP Brief, Positioning and Titles](/marketing/content-briefs-and-titles).

## Site architecture: roles, clusters and the keyword map

### Page roles

Every page and post on a website project can hold one of four roles, stored on the row (`website_pages.page_role` and `content_items.page_role`), never inferred at read time:

| Role      | What it is                                                                                                           |
| --------- | -------------------------------------------------------------------------------------------------------------------- |
| `money`   | The pages that convert: pricing, contact, quote, demo, booking. Every consideration and decision piece must link one |
| `pillar`  | The hub of a topic cluster, the page its spokes link up to                                                           |
| `support` | Posts and articles: the pages that earn the visit                                                                    |
| `utility` | Legal, auth, thank-you and housekeeping routes                                                                       |

`GET /api/olympus/marketing/site-architecture/page-roles?project_id=` (`site_page_roles_get`) returns every page and post of one project with its current role, its URL, its cluster role and, for rows whose role is unset, a suggestion with the reason it was made: `conversion_goal` (a url-kind conversion definition on the account, skipped when the goal sits on a confirmation route such as `/thank-you`), `seo_memory` (a *Money pages: /pricing, /contact* line or a *Money pages* list in the SEO department's memory, exactly as the plugin's decay play writes it), `route_pattern` (pricing, contact, quote, demo, book; the housekeeping routes for utility), `cluster_pillar_url` and `cluster_role_pillar`. `POST` takes one of two bodies: `{ pages: [{ page_id | content_id, page_role }] }` sets roles in bulk (up to 500, each entry its own write, `null` clears, a row outside the account is `not_found` and never updated), or `{ project_id, seed: true, apply }` proposes roles for the unset rows and, with `apply: true`, writes them. A role a person set is never overwritten by a seed, and an account that has marked no money page gets a note wherever a money page is expected, never a guess. `site_page_roles_set` is the tool; the dashboard twin is `/api/marketing/site-architecture/page-roles`.

A page that exists only in the crawl index cannot hold a role until it has a `website_pages` row (the file-page sync creates them); the seed and the link list match crawled rows to roles by route, so a role set on the row applies to the crawled URL too.

### Clusters and the Sunday sweep

A content item joins a topic cluster through two fields the routes accept: `topic_cluster_id` (a `seo_topic_clusters` row in the account) and `cluster_role` (`pillar` or `spoke`). Every read returns `topic_cluster { id, pillar_keyword }` beside them, and the Olympus list filters on both. The writer decides them: the department at brief time, the API, or a person; nothing sets them by inference.

Every Sunday at 06:30 UTC the `content-cluster-sweep` recomputes each cluster's scores from published content, after the SEO analysis sweep (05:35) and the decay link (06:20) have written their rows:

* `coverage_score` is the share of `cluster_keywords` that some published item in the account targets (`target_keyword`, normalised), and `missing_subtopics` lists the ones nothing covers, up to 100: the next briefs.
* `internal_link_score` is the share of spokes (published items with the cluster's `topic_cluster_id` and a role other than pillar) whose body links the pillar URL; `linking_issues` names each spoke that does not (`spoke_missing_pillar_link`) or says `no_pillar_url` when the cluster has none.
* The pillar URL is the cluster's `pillar_url`, else the URL of the cluster's item marked `cluster_role: pillar`; when `pillar_url` was null the sweep fills it from that item, once, and never changes a stored one.
* Every `seo_keyword_clusters` row whose primary or member keyword a published item targets gets `content_status: mapped` and `target_url` set to the item's URL. Rows already on that URL are skipped; a `target_url` a person set to a URL no published item serves (a hosted service page, a page on another site) is kept and named in the sweep's notes, never overwritten. The sweep only moves a mapping between URLs the content system owns.

A hand-set score on a cluster survives until the next sweep. The scores count a keyword as covered by any published item in the account whatever site it lives on, so an account with several sites reads a little high on each.

### The keyword map

`GET /api/olympus/marketing/content/keyword-map?project_id=` (`content_keyword_map`; `GET /api/marketing/content/keyword-map` from the dashboard) is the one read to make before choosing a keyword for a new piece: keyword to item to live URL to best rank position to decay status, grouped by cluster with the pillar row first.

```json theme={null}
{
  "data": {
    "generated_at": "2026-09-12T15:04:05.000Z",
    "project_id": "f7a0ec1d-...",
    "groups": [
      {
        "cluster": { "id": "c1c1c1c1-...", "name": "Materials testing", "domain": "acme.com", "pillar_keyword": "materials testing services", "pillar_url": "https://www.acme.com/services/testing", "coverage_score": 0.6, "internal_link_score": 0.75, "missing_subtopics": ["asphalt testing", "steel testing"] },
        "rows": [
          { "keyword": "materials testing services", "normalized_keyword": "materials testing services", "content_id": "p1p1p1p1-...", "title": "Materials testing services", "status": "published", "content_type": "page", "url": "https://www.acme.com/services/testing", "project_id": "f7a0ec1d-...", "cluster_id": "c1c1c1c1-...", "cluster_role": "pillar", "page_role": "pillar", "search_intent": "commercial", "best_position": 3, "tracked_keywords": 2, "decay_status": null, "refresh_priority": null, "review_disposition": null, "published_at": "2026-05-02T09:00:00.000Z" },
          { "keyword": "concrete testing", "normalized_keyword": "concrete testing", "content_id": "s1s1s1s1-...", "title": "What concrete testing tells you before the pour", "status": "published", "content_type": "article", "url": "https://www.acme.com/blog/concrete-testing", "project_id": "f7a0ec1d-...", "cluster_id": "c1c1c1c1-...", "cluster_role": "spoke", "page_role": "support", "search_intent": "informational", "best_position": 9, "tracked_keywords": 1, "decay_status": "detected", "refresh_priority": 2140, "review_disposition": null, "published_at": "2025-06-10T09:00:00.000Z" }
        ]
      },
      { "cluster": null, "rows": [] }
    ],
    "collisions": [
      { "keyword": "soil testing", "kind": "duplicate_target", "items": [{ "content_id": "a...", "title": "Soil testing explained", "url": "https://www.acme.com/blog/soil-testing", "status": "published" }, { "content_id": "b...", "title": "Soil testing: the guide", "url": null, "status": "draft" }], "cannibalization": null }
    ],
    "without_keyword": [{ "content_id": "n...", "title": "Company news, June", "status": "published", "url": "https://www.acme.com/blog/news-june" }],
    "totals": { "items": 14, "with_keyword": 13, "without_keyword": 1, "clusters": 3, "collisions": 1, "ranked": 9, "decaying": 2 }
  }
}
```

Every cluster is a group, an empty cluster included (its `missing_subtopics` are the briefs to write); items with no cluster are the last group with `cluster: null`. `best_position` is the lowest `current_rank` among the rank-tracking rows publish-to-site seeded for the item. `collisions[]` holds the two things a site must not carry: `duplicate_target` (two non-archived items on one normalised keyword) and `cannibalization` (an open `seo_keyword_cannibalization` row, matched by keyword or by a competing page URL, with its severity and `recommended_primary_url`). `totals.decaying` counts rows whose `decay_status` is set and not `recovered`. The map reads the newest 2,000 non-archived items.

### Links that build the architecture

The site-links read the department picks internal links from (`GET /api/olympus/marketing/content/site-links?project_id=&content_id=`, `content_site_links`) is ordered for the architecture, not by date: the money pages first, then the pillar of the item being written (its cluster's `pillar_url`, else the cluster's item marked pillar; a pillar URL nothing lists is synthesised as `pillar:<url>`), then the other pillars, then the rest by recency. Every row carries `role`, `suggested_anchor` (the target's keyword, else its title without the brand suffix) and `is_pillar_for_item`; the response adds `roles { money, pillar }`, `item { id, topic_cluster_id, cluster_role }` and `pillar { id, title, url, cluster }`. The item asking is never listed as its own target, by URL and not only by id, and when it is itself its cluster's pillar the response says so in a note and lists no pillar, because a pillar links down to its spokes and to the money pages.

Three check rules hold the structure. `money_link_missing` is an error on a consideration or decision piece that links no money page (not raised at other stages, and skipped when the account has marked none), `pillar_link_missing` is an error on a spoke whose body does not link its cluster's pillar URL, and `keyword_already_targeted` warns when another non-archived item in the account targets the same keyword. The keyword collision is also reported at write time: `content_create` and `content_update` answer with `warnings[]` carrying one *keyword\_already\_targeted* line and `keyword_conflicts[]` naming the other item, and still write the row. The department reads the keyword map before choosing a keyword, takes one from the cluster's `missing_subtopics` when the brief names none, and refuses a keyword the map lists as a collision unless the owner says consolidate. See [Pre-Publish SEO Check](/marketing/content-seo-check#what-it-checks) and [The Content Department](/marketing/content-department#internal-links-from-real-urls).

## Bottom-funnel pages

### Three content types

Three content types join the list every content writer accepts: `comparison` (*your brand vs a rival*, with a sourced, dated table), `alternatives` (*rival alternatives*, or *best category for segment*, side by side) and `research` (an original data study from the account's own numbers, with the methodology on the page). Each carries structured blocks in `settings`, top-level keys the settings merge keeps:

| Type                         | Block                       | Shape                                                                                                                        |
| ---------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `comparison`, `alternatives` | `settings.comparison_table` | `{ columns: [brand, rival...], rows: [{ criterion, values: [one per column], source, checked_at }] }`; the brand is column 0 |
| `comparison`, `alternatives` | `settings.decision_cta`     | `{ decision, label, url, offer_id }`: the one decision the page ends on and where it goes                                    |
| `research`                   | `settings.methodology`      | `{ question, population, period, method, limitations[] }`                                                                    |
| `research`                   | `settings.dataset`          | `{ name, source, record_count, fields[], collected_at }`                                                                     |

Two rules in the [pre-publish check](/marketing/content-seo-check#what-it-checks) are errors on a comparison or alternatives page: `comparison_table_missing` when there is no table in the body and no `settings.comparison_table` rows, and `competitor_claim_unsourced` for every table row that carries a rival value with no `source` URL or no `checked_at` date (a body table needs a link or a `[source: ...]` marker in the row and a date in the row, the header or the line under the table). A rival's price or feature is never written from memory.

### The plan

`POST /api/olympus/marketing/content/bofu-plan` (`content_bofu_plan`; `POST /api/marketing/content/bofu-plan` from the dashboard) plans the pages people read when they are choosing now, for one website project:

1. **Rivals** are the tracked competitor set of the SEO project whose domain is the site's host (screened the way the competitors read screens them, sorted by shared keywords, at most ten), plus any hosts sent in `rivals[]`. **Segments** are the active customer avatars (at most six). The **brand** is the brand guide's name and the **category** is `positioning.category_name`, else the guide's industry.
2. **Phrases** are built per rival and segment: *brand vs rival*, *rival alternatives*, *best category for segment*, with variants. DataForSEO is asked once for the volume and difficulty of every exact phrase and once, when `include_ideas` is true, for discovery ideas from the seeds; both calls count against the monthly SEO research cap. A cap already spent is `402 seo_research_limit` with nothing written; a cap hit or a vendor error on the second call is a warning, never a failure.
3. **Gates**, in order: `keyword_too_long`, `volume_below_floor` (`min_volume`, default 10), `difficulty_above_cap` (`max_difficulty`, default 70) and `keyword_collision` (an item in the account, not deleted or archived, whose normalised target keyword equals the phrase or a variant, or an open cannibalisation row). Every drop is returned in `dropped[]` with its reason and the item or row it hit, so a collision drop is read as *refresh that one instead of writing a twin*.
4. **Candidates** come back with `type` (`vs`, `alternatives`, `best_for`), the title, the target keyword and its variants, volume and difficulty, the rival and segment, `search_intent: commercial`, the template slug and `evidence`: `rival_facts_sources` (the rival's pricing page, its home page and any tracked competitor change URLs, at most five) and `own_proof`, the [proof pack](/marketing/content-program#the-proof-pack) counts for the segment at the Decision stage.
5. **Seeding.** With `seed_drafts: true` each candidate becomes a draft: `content_type` comparison or alternatives, `page_role: money`, `journey_stage: Decision`, `search_intent: commercial`, the avatar, the target keyword, the project, `settings.bofu` (rival, segment, sources, own proof, volume, variants, the template) beside an empty `comparison_table` (the six default criteria, values blank) and `decision_cta`, plus a first version row. The three bottom-funnel templates (`brand-vs-rival`, `rival-alternatives`, `best-category-for-segment`, identified by `default_settings.template_slug`) are materialised as account templates on first use and appear in the templates list like any other.
6. **The record.** The plan is stored as one `bofu_plan` artifact per project in the account's Content research knowledge base; `GET /api/olympus/marketing/content/bofu-plan?project_id=` (`content_bofu_plan_get`) returns the newest one with what it seeded.

`max_candidates` caps the plan (1 to 40, default 12). `Idempotency-Key` is honoured, and two plans for one project that run at the same moment seed each page once: the second reports the first run's drafts as `keyword_collision` in `seeded.skipped`, and one artifact is refreshed. An empty competitor set, no category or no avatars returns a plan with no candidates and a note per gap, and spends nothing.

### Writing one

The department's play runs the plan without seeding first and shows the candidates with their volume, the rival's pricing URL and the proof counts, and the dropped list with reasons; on an explicit yes it seeds the approved ones. For each draft the rival facts come from the competitor analysis and from reading the URLs in `settings.bofu.sources` (the pricing page first), and every row written into `settings.comparison_table.rows` carries `source` (the URL read) and `checked_at` (that day). The account's own column comes from the proof pack. The body follows the seeded template's headings, an honest-concession section included, and `settings.decision_cta` names one decision and the [offer](#offers-on-the-brand-guide) or URL it goes to. *Learn more* is not a decision. The check must be clean of the two table rules before review.

## The decision loop: double down, refresh, prune

### What the Sunday run stamps

The SEO analysis sweep already writes `seo_content_decay` rows: pages whose Search Console clicks fell between two 28-day windows (see [SEO Delivery Tools](/marketing/seo-delivery-tools#the-analysis-suite-and-where-its-data-comes-from)). Until this round those rows named a URL and nothing else knew which content item served it. Every Sunday at 06:20 UTC the `content-decay-link` run resolves each row of the account to the published item behind its URL and stamps the item:

| Field                    | What it holds                                                                                                                                                                                                                                                                              |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `decay_status`           | The decay row's status: `detected` from the sweep, or whatever a person moved the row to (`refreshing`, `resolved`, ...); `recovered` once the row converges away; `null` for a page that never decayed. Decaying means not `resolved`, `refreshed`, `ignored`, `dismissed` or `recovered` |
| `refresh_priority`       | An integer rank, higher sooner: the thousands are the severity (severe 3000, moderate 2000, mild 1000) and the remainder the clicks lost between the windows, capped at 999                                                                                                                |
| `top_declining_keywords` | `[{ query, prior_clicks, current_clicks, prior_position, current_position, clicks_lost }]`, worst first                                                                                                                                                                                    |
| `refreshed_at`           | Stamped when a new version of the piece published on the same URL after the decay was detected; the publish path stamps it at publish time and marks the decay rows the same moment                                                                                                        |

The four are read-only on `content_update` (`400 read_only_field`; echoing what the row holds is accepted), so the values are always the run's. An item whose row converged away is marked `recovered` with the priority cleared; a row that cannot be resolved to a live URL keeps its link and its stamps rather than being unlinked; a run whose URL resolver fails writes nothing for that account and retries next Sunday. Rows a person closed are mirrored but never notified.

### The trigger and the refresh task

Once per decay episode (the pair of item and decay row, claimed on the item as `settings.decay_episode` before anything customer-visible happens) the run raises `content.decay_detected`. The **Content Decay Detected** workflow trigger (`contentDecayDetectedTrigger`; `content_decay_detected_trigger` on the API, in the event trigger catalog with a sample payload) carries `content_id`, `title`, `url`, `decay_status`, `decay_severity`, `refresh_priority`, `top_declining_keywords`, `review_disposition`, `suggested_disposition`, `decay_id`, `traffic_decline_pct`, `peak_traffic`, `current_traffic`, `detected_at`, `project_id` and `content_type`, and filters on `project_id`, `content_types`, `severities` (`severe`, `moderate`, `mild`) and `min_refresh_priority`, each failing closed. One run per workflow, item and decay row.

When no workflow in the account handles the event, the run files the work instead: a PM task titled *Refresh: title* (`task_type: content`, `todo`, high priority and due in 14 days at a priority of 3000 or more, medium and 30 days at 2000, low below), with the refresh brief below as its description, `related_url` the live page, the tags `content` and `refresh`, and `custom_fields.seed_key` so one open task exists per episode. The task lands on the PM project linked to the item's website project, else the newest marketing or SEO project, else a *Content* project created once. A workflow with the trigger replaces that default.

### The refresh brief

`GET /api/olympus/marketing/content/{id}/refresh-brief` (`content_refresh_brief_get`; `GET /api/marketing/content/{id}/refresh-brief` from the dashboard) assembles the brief from rows that already exist and spends nothing: what declined (the linked decay row: clicks, keywords and weighted position across the two windows, severity, type, when it was detected, the sweep's recommended actions), the declining keywords worst click loss first, the SERP brief stored on the item (re-read before changing the outline; the checklist asks for a fresh one when none is stored), the scorecard from the newest nightly row (views, form submits, leads, contacts, lead rate, best rank, deals won), keyword siblings and any open cannibalisation finding, newer internal-link donors (published pieces newer than this one, same cluster first, at most eight), the suggested disposition beside the one already recorded, and a checklist. It returns `data` (the structured brief, `version: 1`) and `markdown`. A row outside the account is a 404.

The refresh play the department runs from it: re-read the SERP brief (capture one when missing), cover the declining keywords, close the outline gaps against the top three, add links from the donors, publish a new version on the **same URL** (a version, then the publish), re-run the check, and record the decision on the row. `refreshed_at` is the proof the refresh shipped.

### Dispositions

Five words say what happens to a page, stored as `review_disposition` on the item, writable through `content_update` and a filter on the list: `double_down`, `refresh`, `rewrite`, `consolidate`, `prune`. The run only suggests. Its `suggested_disposition` follows one rule set: zero traffic over twelve months is `prune`, or `consolidate` when a sibling holds the keyword; a keyword sibling is `consolidate`; severe decay with the current traffic at a quarter of the peak or less is `rewrite`; any other decay is `refresh`; a top performer that is not decaying is `double_down`. The refresh brief recomputes it with the siblings in hand; the trigger payload carries the run's own suggestion, which never says `consolidate` because the run does not read siblings.

### Prune candidates

`GET /api/olympus/marketing/content/prune-candidates` (`content_prune_candidates`; `GET /api/marketing/content/prune-candidates` from the dashboard) lists the published items at least `min_age_days` old (30 to 1095, default 365) with zero views and zero leads across the last twelve months, oldest first, up to `limit` (1 to 200, default 50), optionally on one `project_id`. Each candidate carries `views_12m`, `leads_12m`, `measured_by`, `suggested_disposition` (`consolidate` with `consolidate_into` naming the sibling when another published item holds the keyword or an open cannibalisation row names it, else `prune`), the recorded `review_disposition` and `decay_status`, and `default_action`: `content_unpublish_from_site`, which drafts the site entry; nothing leaves the internet until the project deploys.

The measurement is honest about its source. Stored nightly scorecard rows count as the twelve-month measurement only when they cover the window (the earliest row within three days of the window start, or of the item's `published_at` when the item is younger); otherwise the item is measured through the live views lookup over the whole window (`measured_by: views_lookup`) and `warnings[]` says so. An item that cannot be measured (no page, or the analytics collector unreachable) is listed under `unmeasured`, never as a candidate. Money pages are excluded and counted. The department reads this list and the refresh queue before it proposes a net-new topic: a decaying page on the keyword is a refresh, never a new page; a zero-traffic page on the keyword is consolidate-or-prune first.

## Conversion inside the piece

### Offers on the brand guide

The offers a call to action can point at live on the account's active brand guide, one list read and written whole: `GET` and `PUT /api/olympus/marketing/brand/offers` (`brand_offers_get`, `brand_offers_set`; `/api/marketing/brand/offers` from the dashboard). Each offer is `{ id, name, promise, guarantee, bonuses[], cta_label, cta_url, stages[] }`: the promise the reader accepts, the guarantee that removes the risk, the bonuses, the button label and the one link, and the journey stage names it fits (an offer with no stages fits every stage; names match the item's `journey_stage` case-insensitively). `PUT` is a full replacement: read first, send the whole list back. Ids are minted by the server, and an incoming id survives only when it names an existing entry, so content items stay pointed at their offer across a save. The response carries `lock_version`; a `PUT` with `expected_lock_version` behind the guide is `409 offers_conflict` with the current version to reload. Limits: 20 offers, names unique and up to 120 characters, promise and guarantee 400, eight bonuses of 200, `cta_label` 60, `cta_url` an http(s) URL or a site path starting with `/` (at most 500 characters), ten stage names of 80.

A content item names its offer through `offer_id`. The content routes check the id against the list at write time (an unknown id, or no active guide, is `400 invalid_reference` naming the offers read), and every read returns it as stored.

### The five-stage CTA table

The three buckets (awareness subscribes, consideration downloads, decision buys) are replaced by a five-rung table, Eugene Schwartz's stages of awareness plus the after-sale rung, keyed to the account's own journey stage names:

| Rung             | Job of the piece             | Proof beside the ask | CTA verb              | Destination                                           |
| ---------------- | ---------------------------- | -------------------- | --------------------- | ----------------------------------------------------- |
| `unaware`        | Name the problem             | data                 | subscribe             | newsletter                                            |
| `problem_aware`  | Show the cost of the problem | story                | download              | lead magnet                                           |
| `solution_aware` | Compare the ways to solve it | case study           | compare               | the newest published comparison or alternatives piece |
| `product_aware`  | Show this product doing it   | testimonial          | see                   | offer                                                 |
| `most_aware`     | Ask                          | guarantee            | start                 | offer                                                 |
| `retention`      | Keep and expand              | testimonial          | refer, renew, upgrade | offer                                                 |

`buildStageCtaTable` produces one row per stage of the item's journey map, named the way the map names it (a keyword read of the stage name, then its position), with the five rungs as the fallback when the item has no journey. Each rung falls back down its destination list when the first cannot be resolved: a solution-aware piece on an account with no comparison page asks with the lead magnet, then the newsletter.

The [pre-publish check](/marketing/content-seo-check#what-it-checks) reads the same table. `cta_missing` warns when the body has no `::cta` or `::upgrade` shortcode, no form, no link whose text carries a CTA verb or whose target is a CTA destination, no link to the offer's `cta_url` and no link to a money page. `cta_stage_mismatch` warns when every call to action the check could place asks for a rung the piece's stage does not allow (awareness allows `unaware` and `problem_aware`; consideration `problem_aware`, `solution_aware` and `product_aware`; decision `product_aware` and `most_aware`; retention `retention` and `most_aware`): a decision piece whose only ask is *Subscribe*, an awareness piece linking `/pricing`. An account whose journey names its last stage *Ready to book* writes `settings.cta_stage_table: [{ stage: "most_aware", journey_stages: ["Ready to book"] }]` (with optional `verbs` and `destinations` that merge into the built-in row) so the check keys to that name; the department writes the key at brief time from the table it drafted against.

### The shortcodes

The department writes placements, never CTA copy. Two shortcodes, each on a line of its own with two colons, mark where the plan's blocks render:

```
::cta{variant=inline}
::cta{variant=end}
::upgrade
```

`inline` sits after the second section, once the reader has committed; `end` follows the last section; `upgrade` follows it when the piece has a lead magnet. The HTML twins are the comments `hiveku:cta variant=inline`, `hiveku:cta variant=end` and `hiveku:upgrade`. An unlabelled `::cta` is read by position (the last one with nothing after it is the end). Optional `verb=` and `destination=` attributes on the end CTA are checked against the stage row; a `url=` counts toward the money-page and pillar-link rules. The check accepts a looser grammar (three colons, mid-line) than the site renders, so write the strict form.

The plan supplies the copy, so a changed offer or lead magnet reaches every published piece: a CTA block is `{ variant, verb, destination, headline, supporting_line, label, url, form, proof_type, proof_line }` (the `form` carries a `form_key` Hiveku captures submissions on) and the upgrade block `{ kind, title, description, benefits[], download_url, form }`. On a markdown body the shortcodes publish verbatim and the site's `PostBody` component (in the starter template) renders them as the inline card, the final call to action and the lead magnet; a site scaffolded before the component existed shows the lines until its post page adopts it. On an html-typed body (Webflow rich text, html collections) the mapper renders the blocks as plain HTML asides when it is handed the plan and removes the shortcodes when it is not; the publish path hands it no plan yet, so an html body publishes clean and without the blocks today.

### The lead magnet and the checkpoint

`lead_magnet` on the item is `{ kind, title, asset_id?, content_item_id?, form_id?, delivery_sequence_id? }` with `kind` one of `guide`, `checklist`, `template`, `ebook`, `whitepaper`, `webinar`, `download` or `other`, or `null` to clear; every id present is looked up in the account (`400 invalid_reference` naming `lead_magnet.<field>`). It should name the thing the reader can take away from this piece, the checklist from the checklist post, the template from the how-to, never a generic newsletter. The plan resolves it into the asset, the gated item, the form (with its `form_key`) and the delivery sequence, reports `download_url` and `missing[]`, and renders a kind it has no copy for (`download`, `other`) as a guide.

`GET` and `POST /api/olympus/marketing/content/{id}/conversion` (`content_conversion_plan`; `/api/marketing/content/{id}/conversion` from the dashboard) return the plan: the stage row, the whole table, the offer and where it came from (`item`, `stage`, `first`, or none), the lead magnet, the three blocks, the shortcode counts in the body, the journey and guide, and `warnings[]` naming what is missing: `lead_magnet_missing`, `lead_magnet_reference_missing`, `offer_missing`, `offer_stage_mismatch`, `comparison_page_missing`, `form_missing`, `delivery_sequence_missing`, `journey_stage_missing`. `POST` also ensures the journey checkpoint: when the piece has a lead magnet with a form and sits on a journey stage, that stage gets a `form_submit` checkpoint for the form, once (the stage exit when it has none, an entry signal otherwise), so enrollment and the scorecard measure the conversion; `checkpoint.status` is `created` when this call made it, `exists`, `missing` or `skipped` with a reason. `delivery.status` says whether the download is delivered: `sequence_linked`, `sequence_inactive`, `sequence_missing` (the form's submissions land in the ledger and the CRM, but no sequence sends the file yet), `no_form` or `no_lead_magnet`. The publish path does not call this route yet; run the `POST` once the piece is live so the checkpoint exists.

## Known limits

* Page roles and cluster fields are stored, never inferred: an account that marks no money page gets `money_link_missing` skipped and a note on every link read, and a spoke with no cluster is not checked for its pillar.
* The keyword map reads at most 2,000 non-archived items, newest first, and does not flag the cut.
* The bottom-funnel gate compares the raw target keyword case-insensitively against rows committed while the plan ran; a twin whose keyword differs only by punctuation or *versus* is caught by the plan's own normalised check, not by that last re-check.
* The decay link derives `refreshed_at` from the last publish when the publish path could not stamp it, so a refresh is never lost, at most a week late. A decay row a person closed keeps its id on the SEO side; a page that decays again under a closed row is not notified again.
* Nothing inserts the shortcodes on its own: the department writes them, and the editor's helper adds the missing placements on request. An html body publishes without the blocks until the publish path hands the mapper the plan.
* No workflow template ships for the decay trigger yet; the PM task is the default, and a workflow with the trigger replaces it.

## Related pages

* [Authors, the SERP Brief, Positioning and Titles](/marketing/content-briefs-and-titles) — the other four round-B contracts
* [The Content Department](/marketing/content-department) — the Cluster: line, the money-first linking rule, the refresh and bottom-funnel plays
* [Pre-Publish SEO Check](/marketing/content-seo-check) — `money_link_missing`, `pillar_link_missing`, `keyword_already_targeted`, the two table rules and the two CTA rules
* [Distribution, Performance, Proof and Research](/marketing/content-program) — the scorecard the prune list and the refresh brief read, and the proof pack the comparison table's own column comes from
* [SEO Delivery Tools](/marketing/seo-delivery-tools) — the analysis sweep that writes the decay rows the Sunday link run reads
* [Workflow Nodes](/integrations/workflow-nodes) — the Content Decay Detected trigger
* [Content API](/api-reference/marketing-content#page-roles) — every route on this page with its params and response keys
