We've received your deal registration for Elena Vasquez — Northgate Security. Our partner team will review it and confirm next steps within one business day.
+
We've received your deal registration for Elena Vasquez — Northgate Security. Our partner team will review it and confirm next steps with you.
We have your demo request. A specialist will reach out within one business day.͏ ͏ ͏ ͏ ͏
+
We have your demo request. A specialist will reach out to arrange a time.͏ ͏ ͏ ͏ ͏
@@ -58,7 +58,7 @@
Demo request
Thanks, Priya
-
We've received your demo request. One of our solutions specialists will reach out within one business day to arrange a time that suits you.
+
We've received your demo request. One of our solutions specialists will reach out to arrange a time that suits you.
It helps to know what you would like to see: your current base images, the CVE burden you are carrying, or the audit you are preparing for. Reply to this email with anything you want covered.
The CleanStart team
diff --git a/apps/cms/src/payload/lib/deal-registrations/notification-email.ts b/apps/cms/src/payload/lib/deal-registrations/notification-email.ts
index 847ecdec9..af23344d3 100644
--- a/apps/cms/src/payload/lib/deal-registrations/notification-email.ts
+++ b/apps/cms/src/payload/lib/deal-registrations/notification-email.ts
@@ -80,7 +80,7 @@ export const buildDealRegistrationConfirmationEmail = (
blocks: [
{
kind: 'paragraph',
- text: `We've received your deal registration for ${dealName}. Our partner team will review it and confirm next steps within one business day.`,
+ text: `We've received your deal registration for ${dealName}. Our partner team will review it and confirm next steps with you.`,
},
{
kind: 'details',
diff --git a/apps/cms/src/payload/lib/email/lead-emails.ts b/apps/cms/src/payload/lib/email/lead-emails.ts
index 311d6fbd3..d87d32400 100644
--- a/apps/cms/src/payload/lib/email/lead-emails.ts
+++ b/apps/cms/src/payload/lib/email/lead-emails.ts
@@ -24,13 +24,13 @@ export const buildDemoConfirmationEmail = (
): { subject: string; htmlContent: string } => ({
subject: 'Your CleanStart demo request',
htmlContent: renderEmail({
- preheader: 'We have your demo request. A specialist will reach out within one business day.',
+ preheader: 'We have your demo request. A specialist will reach out to arrange a time.',
eyebrow: 'Demo request',
heading: greeting(input.firstName),
blocks: [
{
kind: 'paragraph',
- text: "We've received your demo request. One of our solutions specialists will reach out within one business day to arrange a time that suits you.",
+ text: "We've received your demo request. One of our solutions specialists will reach out to arrange a time that suits you.",
},
{
kind: 'paragraph',
@@ -47,13 +47,13 @@ export const buildContactConfirmationEmail = (
): { subject: string; htmlContent: string } => ({
subject: "We've received your message",
htmlContent: renderEmail({
- preheader: 'Your message reached the CleanStart team. We reply within one business day.',
+ preheader: 'Your message reached the CleanStart team and someone will get back to you.',
eyebrow: 'Contact',
heading: greeting(input.firstName),
blocks: [
{
kind: 'paragraph',
- text: "We've received your message and someone from the team will reply within one business day.",
+ text: "We've received your message and someone from the team will get back to you soon.",
},
{ kind: 'paragraph', text: 'If it is urgent, reply to this email and it will reach us directly.' },
signOff,
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index 52fa6492c..7233d6270 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -217,6 +217,19 @@ const nextConfig: NextConfig = {
destination: "/news/why-containers-drive-supply-chain-breaches",
permanent: true,
},
+ // Second duplicate news article, same collision-suffix pattern as above.
+ // Both documents were created 2025-08-26 and render the same story with
+ // the same 104-character H1 (617 vs 616 words); both returned 200 with
+ // their own self-canonical. The unsuffixed slug is kept, matching the
+ // precedent set directly above. The CMS document still exists and is
+ // still in the sitemap until its `seo.indexable` is set to noindex.
+ {
+ source:
+ "/news/triam-security-rebrands-as-cleanstart-to-reflect-product-led-focus-on-securing-the-software-supply-chain-2",
+ destination:
+ "/news/triam-security-rebrands-as-cleanstart-to-reflect-product-led-focus-on-securing-the-software-supply-chain",
+ permanent: true,
+ },
];
},
images: {
diff --git a/apps/web/src/app/(legal)/privacy-policy/page.tsx b/apps/web/src/app/(legal)/privacy-policy/page.tsx
index 9f46b9aad..c101025a2 100644
--- a/apps/web/src/app/(legal)/privacy-policy/page.tsx
+++ b/apps/web/src/app/(legal)/privacy-policy/page.tsx
@@ -8,6 +8,8 @@ import { buildPageMetadata } from "@/lib/seo/canonical";
import { resolveCmsSeo } from "@/lib/seo/cms-seo";
import { breadcrumbSchema } from "@/lib/seo/jsonld";
import { JsonLdGraph } from "@/components/JsonLdGraph";
+import { sectionIndexHref } from "@/lib/nav/section-index";
+
import { getPageGraph } from "@/lib/seo/compose-page";
/**
@@ -60,7 +62,7 @@ export default async function PrivacyPolicyPage(): Promise {
const graph = await getPageGraph("/privacy-policy", [
breadcrumbSchema([
{ name: "Home", path: "/" },
- { name: "Legal", path: "/legal" },
+ { name: "Legal", path: sectionIndexHref("/legal") },
{ name: doc.title },
]),
]);
diff --git a/apps/web/src/components/sections/Footer.tsx b/apps/web/src/components/sections/Footer.tsx
index bafae9c49..efd1daebb 100644
--- a/apps/web/src/components/sections/Footer.tsx
+++ b/apps/web/src/components/sections/Footer.tsx
@@ -2,6 +2,8 @@ import React from "react";
import Link from "next/link";
import { CookiePreferencesButton } from "@/components/consent";
+import { sectionIndexHref } from "@/lib/nav/section-index";
+
import { FooterSignature } from "@/components/sections/FooterSignature";
/**
@@ -74,7 +76,7 @@ const CREDENTIALS: Badge[] = [
];
const LEGAL_LINKS = [
- { label: "Legal", href: "/legal" },
+ { label: "Legal", href: sectionIndexHref("/legal") },
{ label: "Privacy Policy", href: "/privacy-policy" },
{ label: "Acceptable Use Policy", href: "/legal/acceptable-use-policy" },
];
@@ -300,10 +302,17 @@ export function Footer({
page, so default in-viewport prefetch fires an RSC
(`?_rsc=`) request site-wide — pure crawl-budget waste
for low-traffic policy pages. Click nav is unaffected. */}
+ {/* `before:` lifts the touch target from 21px (12px x
+ 1.75, no padding) to 27px, clearing the 24x24 WCAG
+ 2.5.8 AA floor — same pseudo-element trick as the nav
+ columns, so the utility row's visual rhythm is
+ untouched. The row uses gap-y-1 (4px) when it wraps, so
+ 3px of overhang each side still leaves the wrapped
+ lines from overlapping. */}
{link.label}
diff --git a/apps/web/src/components/sections/contact/ContactForm.tsx b/apps/web/src/components/sections/contact/ContactForm.tsx
index a7d2e8e4d..05ba53cc5 100644
--- a/apps/web/src/components/sections/contact/ContactForm.tsx
+++ b/apps/web/src/components/sections/contact/ContactForm.tsx
@@ -162,7 +162,7 @@ export function ContactForm() {
tone: "success",
title: "Message sent",
message:
- "Thanks, we've received your message and will reply within 24 hours.",
+ "Thanks, we've received your message and will get back to you soon.",
});
window.setTimeout(() => {
setSubmitted(false);
diff --git a/apps/web/src/components/sections/forms/BookDemoForm.tsx b/apps/web/src/components/sections/forms/BookDemoForm.tsx
index ef77f41b3..2dc15ee15 100644
--- a/apps/web/src/components/sections/forms/BookDemoForm.tsx
+++ b/apps/web/src/components/sections/forms/BookDemoForm.tsx
@@ -150,7 +150,7 @@ export function BookDemoForm(): React.ReactElement {
tone: "success",
title: "Demo request received",
message:
- "Thanks, your demo request has been received. Our team will reach out within 24 hours.",
+ "Thanks, your demo request has been received. Our team will reach out to arrange a time.",
});
window.setTimeout(() => setStatus(null), 5000);
return;
diff --git a/apps/web/src/components/sections/forms/DealRegistrationForm.tsx b/apps/web/src/components/sections/forms/DealRegistrationForm.tsx
index a3e43383a..529671013 100644
--- a/apps/web/src/components/sections/forms/DealRegistrationForm.tsx
+++ b/apps/web/src/components/sections/forms/DealRegistrationForm.tsx
@@ -171,7 +171,7 @@ export function DealRegistrationForm(): React.ReactElement {
tone: "success",
title: "Deal registration received",
message:
- "Thanks, your deal registration has been received. We'll be in touch within one business day.",
+ "Thanks, your deal registration has been received. We'll be in touch about next steps.",
});
window.setTimeout(() => setStatus(null), 6000);
return;
diff --git a/apps/web/src/components/sections/knowledge-hub/KnowledgeHubArticle.tsx b/apps/web/src/components/sections/knowledge-hub/KnowledgeHubArticle.tsx
index 04737846f..d10589c65 100644
--- a/apps/web/src/components/sections/knowledge-hub/KnowledgeHubArticle.tsx
+++ b/apps/web/src/components/sections/knowledge-hub/KnowledgeHubArticle.tsx
@@ -2,6 +2,8 @@ import { HeroReveal, Reveal } from '@/components/ui/Reveal';
import type { KhArticle } from '@/lib/knowledge-hub';
import { RenderLexical } from '@/lib/renderLexical';
import { KnowledgeHubArticleFAQ } from './KnowledgeHubArticleFAQ';
+import { sectionIndexHref } from "@/lib/nav/section-index";
+
import Link from 'next/link';
export function KnowledgeHubArticle({
@@ -88,7 +90,7 @@ function Breadcrumb({ category }: { category?: string | null | undefined }): Rea
diff --git a/apps/web/src/components/sections/partners/BecomePartnerCta.tsx b/apps/web/src/components/sections/partners/BecomePartnerCta.tsx
index 58acb08b9..0dfb1d9fc 100644
--- a/apps/web/src/components/sections/partners/BecomePartnerCta.tsx
+++ b/apps/web/src/components/sections/partners/BecomePartnerCta.tsx
@@ -227,7 +227,7 @@ function PartnerModal({ open, onClose }: PartnerModalProps): React.ReactElement
tone: "success",
title: "Request received",
message:
- "Thanks, your partnership request has been received. Our team will be in touch within one business day.",
+ "Thanks, your partnership request has been received. Our team will be in touch soon.",
});
// Modal UX: show the confirmation briefly, then close the popup.
if (closeTimerRef.current != null) window.clearTimeout(closeTimerRef.current);
diff --git a/apps/web/src/lib/nav-config.ts b/apps/web/src/lib/nav-config.ts
index f1398c077..b71c8576f 100644
--- a/apps/web/src/lib/nav-config.ts
+++ b/apps/web/src/lib/nav-config.ts
@@ -6,6 +6,8 @@
// the href is data-only until the page exists. When a page is marked ✅ in WEB-PAGES.md,
// switch its renderer from to in MegaMenu / MobileNav.
+import { sectionIndexHref } from "@/lib/nav/section-index";
+
export type NavLeaf = {
label: string;
href: string;
@@ -195,7 +197,7 @@ export const NAV_TREE: NavItem[] = [
{ label: "Resource Center", href: "/resource-center", icon: "folder" },
{ label: "Case Studies", href: "/case-studies", icon: "doc-signed" },
{ label: "Newsroom", href: "/news", icon: "newspaper" },
- { label: "Knowledge Hub", href: "/knowledge-hub", icon: "book-open" },
+ { label: "Knowledge Hub", href: sectionIndexHref("/knowledge-hub"), icon: "book-open" },
],
},
{
diff --git a/apps/web/src/lib/nav/section-index.test.ts b/apps/web/src/lib/nav/section-index.test.ts
new file mode 100644
index 000000000..6f735ade1
--- /dev/null
+++ b/apps/web/src/lib/nav/section-index.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ SECTION_INDEX_REDIRECTS,
+ sectionIndexHref,
+} from "./section-index";
+
+describe("sectionIndexHref", () => {
+ it("resolves a section landing path to its canonical document", () => {
+ expect(sectionIndexHref("/knowledge-hub")).toBe(
+ "/knowledge-hub/vex-documents",
+ );
+ expect(sectionIndexHref("/legal")).toBe(
+ "/legal/additional-third-party-terms",
+ );
+ });
+
+ it("returns any other path unchanged", () => {
+ expect(sectionIndexHref("/blogs")).toBe("/blogs");
+ expect(sectionIndexHref("/")).toBe("/");
+ });
+
+ it("never maps a path to another redirecting path", () => {
+ for (const target of Object.values(SECTION_INDEX_REDIRECTS)) {
+ expect(SECTION_INDEX_REDIRECTS[target]).toBeUndefined();
+ }
+ });
+});
diff --git a/apps/web/src/lib/nav/section-index.ts b/apps/web/src/lib/nav/section-index.ts
new file mode 100644
index 000000000..e892b991c
--- /dev/null
+++ b/apps/web/src/lib/nav/section-index.ts
@@ -0,0 +1,22 @@
+/**
+ * Section landing paths that have no standalone index page. Each permanently
+ * redirects to its canonical first document, issued in `proxy.ts` before any
+ * rendering so crawlers receive a hard 308.
+ *
+ * This is the single source of truth for those pairs. `proxy.ts` reads it to
+ * issue the redirect; link sites read it through `sectionIndexHref` so no
+ * internal link ever points at a URL that redirects. Keeping both sides on one
+ * map is what stops the two drifting apart.
+ */
+export const SECTION_INDEX_REDIRECTS: Record = {
+ "/knowledge-hub": "/knowledge-hub/vex-documents",
+ "/legal": "/legal/additional-third-party-terms",
+};
+
+/**
+ * Resolves an internal path to the URL that actually returns 200. Paths with no
+ * entry in the map are returned unchanged, so this is safe to wrap any href in.
+ */
+export function sectionIndexHref(path: string): string {
+ return SECTION_INDEX_REDIRECTS[path] ?? path;
+}
diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts
index 499d97fbf..e1c9df0ba 100644
--- a/apps/web/src/proxy.ts
+++ b/apps/web/src/proxy.ts
@@ -23,6 +23,7 @@ import {
import { detailRouteExists } from "@/lib/detail-route-not-found-guard";
import { isIndexingAllowed } from "@/lib/seo/indexing";
import { stripLegacyPaginationParams } from "@/lib/seo/legacy-params";
+import { SECTION_INDEX_REDIRECTS } from "@/lib/nav/section-index";
const PRODUCTION_HOST = "www.cleanstart.com";
const APEX_HOST = "cleanstart.com";
@@ -36,11 +37,8 @@ const DRAFT_BYPASS_COOKIE = "__prerender_bypass";
// async Header fetches the CMS), Next downgrades the redirect to a 200 + meta
// refresh, which search engines read as a duplicate page (it inherited the
// default/home canonical). Targets are displayOrder-pinned, so they're stable
-// across re-seeds; the owning pages keep the same slugs as their fallback.
-const SECTION_INDEX_REDIRECTS: Record = {
- "/knowledge-hub": "/knowledge-hub/vex-documents",
- "/legal": "/legal/additional-third-party-terms",
-};
+// across re-seeds; the owning pages keep the same slugs as their fallback. The
+// map lives in lib/nav so link sites resolve hrefs through the same pairs.
// Stays report-only until the CSP burn-in is complete; set CSP_ENFORCE=1 to flip.
const CSP_MODE: "report-only" | "enforce" =
diff --git a/docs/operations/PRODUCTION-ROLLOUT.md b/docs/operations/PRODUCTION-ROLLOUT.md
index e00d3c926..de21316e7 100644
--- a/docs/operations/PRODUCTION-ROLLOUT.md
+++ b/docs/operations/PRODUCTION-ROLLOUT.md
@@ -6,6 +6,16 @@
**Run mechanism:** prod CMS image lacks `scripts/`; `docker cp` the script into `cleanstart-cms-1` and run via `pnpm exec tsx` (no `--env-file` — env is via container vars). See memory note `prod-backfill-script-run-mechanism`.
+**Deploy-order hazard:** `apps/cms` and `apps/web` both deploy from `main`, so one
+merge fires the Docker build and the Vercel build together, and Vercel normally
+wins by several minutes. Any change where the web app stops sending a field that
+the CMS `forms` row still marks required will 400 every submission for the length
+of that gap. There is no way to sequence the two from a single merge, so relax the
+constraint in prod *before* merging: make the field optional, deploy, then run the
+script that finishes the job. Field defs are additive-safe in the other direction,
+because `validateFields` iterates the form definition and ignores submitted fields
+it does not list.
+
### /industries path rename follow-up (2026-09-08)
Script: `scripts/repoint-industry-paths.ts` — repoints the CMS rows that key on
@@ -38,6 +48,7 @@ docker exec -w /app/apps/cms cleanstart-cms-1 pnpm exec tsx \
| 11 | Webflow media filename cleanup | DONE | 2026-06-09 |
| 14 | Legacy Webflow 301 redirects seed | DONE | 2026-06-11 |
| 15 | SEO keywords backfill (guides) | DONE | 2026-06-29 |
+| 17 | Forms overhaul field changes (`apply-form-field-changes.ts`) | DONE | 2026-09-09 |
---
diff --git a/packages/schema/src/builders/jsonld.test.ts b/packages/schema/src/builders/jsonld.test.ts
index 6803b00f7..dbfdcfa7c 100644
--- a/packages/schema/src/builders/jsonld.test.ts
+++ b/packages/schema/src/builders/jsonld.test.ts
@@ -1,11 +1,13 @@
import { describe, expect, it } from "vitest";
import {
+ articleSchema,
blogPostingSchema,
breadcrumbSchema,
eventSchema,
jobPostingSchema,
caseStudyListSchema,
organizationSchema,
+ newsArticleSchema,
reviewSchema,
videoObjectSchema,
} from "./jsonld";
@@ -193,3 +195,46 @@ describe("reviewSchema", () => {
expect(reviewSchema([])).toEqual([]);
});
});
+
+describe("author fallback (Google Article requirement)", () => {
+ const ORG = { "@id": "https://www.cleanstart.com/#organization" };
+
+ it("attributes an Article with no named author to the Organization", () => {
+ const node = articleSchema({ title: "T", path: "/knowledge-hub/x" });
+ expect(node.author).toEqual([ORG]);
+ });
+
+ it("attributes a BlogPosting with no named author to the Organization", () => {
+ const node = blogPostingSchema({ title: "T", path: "/blogs/x" });
+ expect(node.author).toEqual([ORG]);
+ });
+
+ it("attributes a NewsArticle with no named author to the Organization", () => {
+ const node = newsArticleSchema({ title: "T", path: "/news/x" });
+ expect(node.author).toEqual([ORG]);
+ });
+
+ it("prefers named contributors over the Organization fallback", () => {
+ const node = articleSchema({
+ title: "T",
+ path: "/blogs/x",
+ authors: [{ name: "Dhanush VM", slug: "dhanush-vm" }],
+ });
+ expect(node.author).toEqual([
+ {
+ "@type": "Person",
+ name: "Dhanush VM",
+ url: "https://www.cleanstart.com/author/dhanush-vm",
+ },
+ ]);
+ });
+
+ it("still collapses the house byline onto the Organization node", () => {
+ const node = articleSchema({
+ title: "T",
+ path: "/blogs/x",
+ authors: [{ name: "CleanStart Security", slug: "cleanstart-security" }],
+ });
+ expect(node.author).toEqual([ORG]);
+ });
+});
diff --git a/packages/schema/src/builders/jsonld.tsx b/packages/schema/src/builders/jsonld.tsx
index 8e622c5da..90a1a37ef 100644
--- a/packages/schema/src/builders/jsonld.tsx
+++ b/packages/schema/src/builders/jsonld.tsx
@@ -53,6 +53,22 @@ const authorNode = (a: { name: string; slug?: string | undefined }) =>
name: a.name,
...(a.slug ? { url: absoluteUrl(`/author/${a.slug}`) } : {}),
};
+
+/**
+ * `author` is required on Article and its subtypes. Named contributors win when
+ * the document has them; otherwise the publishing Organization is the honest
+ * attribution, which is what the house byline already resolves to. Omitting the
+ * property instead leaves the node failing Google's Article requirements, which
+ * is what knowledge-hub, news and resources documents used to do.
+ */
+const authorProperty = (
+ authors?: Array<{ name: string; slug?: string | undefined }> | undefined,
+) => ({
+ author:
+ authors && authors.length > 0
+ ? authors.map(authorNode)
+ : [{ "@id": ORGANIZATION_ID }],
+});
const WEBSITE_ID = `${SITE_URL}/#website`;
/** Shared schema.org event-status IRIs (Event + webinar ItemList). */
@@ -361,11 +377,7 @@ export function blogPostingSchema({
...(imageUrl ? { image: [imageUrl] } : {}),
...(publishedAt ? { datePublished: publishedAt } : {}),
...(lastModified ? { dateModified: lastModified } : {}),
- ...(authors && authors.length > 0
- ? {
- author: authors.map(authorNode),
- }
- : {}),
+ ...authorProperty(authors),
...(category ? { articleSection: category } : {}),
...(cleanRelatedLinks ? { relatedLink: cleanRelatedLinks } : {}),
publisher: { "@id": ORGANIZATION_ID },
@@ -404,11 +416,7 @@ export function articleSchema({
...(imageUrl ? { image: [imageUrl] } : {}),
...(publishedAt ? { datePublished: publishedAt } : {}),
...(lastModified ? { dateModified: lastModified } : {}),
- ...(authors && authors.length > 0
- ? {
- author: authors.map(authorNode),
- }
- : {}),
+ ...authorProperty(authors),
...(type ? { genre: type } : {}),
publisher: { "@id": ORGANIZATION_ID },
};
@@ -454,11 +462,7 @@ export function newsArticleSchema({
...(publishedAt ? { datePublished: publishedAt } : {}),
...(lastModified ? { dateModified: lastModified } : {}),
...(section ? { articleSection: section } : {}),
- ...(authors && authors.length > 0
- ? {
- author: authors.map(authorNode),
- }
- : {}),
+ ...authorProperty(authors),
publisher: { "@id": ORGANIZATION_ID },
};
}