Skip to content
Skip to main content
Novel Systems home
Decision log
D-054August 5, 2026

One CTA label per action, not one CTA label per site

Decided

Where two pages offer the *same* action, they use the same words for it, taken from PRIMARY_CTA. Where a page offers a *different* action, it keeps its own label even though the button looks identical. The rule is scoped to the action, not to the site.

Affects: lib/constants.ts, lib/contact-configuration.ts, app/about/page.tsx, app/solutions/page.tsx, app/smart-tech/page.tsx, app/integrations/page.tsx, app/security/page.tsx

What the problem was. One scheduled working session was called four things. /about said "Book a technical session", /solutions said the same, /smart-tech said "Talk to an engineer", /integrations said "Review your integration map", and the navbar said "Book Enterprise Demo". All five posted the visitor to the same twenty-minute event on the same scheduler. A buyer moving between those pages reads five offers and has to work out that they are one, and the one who does not work it out is the one who books nothing because none of the five sounded like the demo they were told about.

/integrations carried a second, worse defect underneath the first. Its href was /contact?source=integrations with no #book fragment, so the button landed at the top of a nine-step brief instead of on the scheduler control — exactly the regression D-046 added the fragment to PRIMARY_CTA to prevent, still live on this page because the page wrote its own URL by hand.

What the alternative was, and why it was rejected. The tidy version is to replace every closing CTA on the site with PRIMARY_CTA, add a twelfth rule to check-claim-consistency.mjs forbidding the retired labels, and be done.

That was tried and reverted on /security. The paragraph immediately above that page's closing button reads "No sales call is required to get any of that." A button labelled "Book Enterprise Demo" underneath that sentence makes the sentence false in the space of one line — and false in the specific way this whole codebase exists to avoid, where the copy says one thing and the control does another. /security offers a security review: a different action, with a different artifact at the end of it, reached by a different ?source=. It keeps "Request a security review" on both its CTAs, and a JSX comment at the site says why so the next person to run a consistency pass does not re-break it.

/developers keeps "Ask an engineer" for the same reason: it is a mailto:, not a booking.

How the href problem is prevented from returning. primaryCtaHref(source?) composes the URL, so no page writes ?source= and a fragment by hand. Its PrimaryCtaSource union is asserted equal, in both directions at compile time, to RECOGNISED_CONTACT_SOURCES in lib/contact-configuration.ts — the list /contact actually parses. Adding a source that the contact page would silently ignore is a type error rather than a dead query parameter.

The union is written out in constants.ts rather than imported from contact-configuration.ts because constants.ts currently imports nothing and is depended on by the navbar, the footer and the root layout. The duplication is deliberate and the assertion is what makes it safe.

What was deliberately not done. No claim rule was added for the retired labels. check-claim-consistency.mjs is documented on /security as catching "figures this site once stated two ways", and CLAIM_RULE_COUNT is published there as 11; a label is not a figure, and a twelfth rule would either make that published sentence wrong or require rewording a compliance page to catch a copy nit. The labels are now interpolated from one constant, which is the same protection by construction rather than by inspection.

What would make this wrong. A page that offers the booking under materially different terms — a longer session, a different audience, a paid engagement — would need its own label, and the right response is to add it to the source list, not to reuse PRIMARY_CTA and let the button under-describe what happens.