WebProCMS keeps a site's Privacy Policy and Terms of Service true to how the site actually works. Instead of a static legal page that drifts out of date, each page shows a live document: the code that owns each data practice contributes a clause, and the page puts together the clauses that apply at the moment it is shown. Switch on the shop and the policy gains its orders clause; add a Google Analytics ID and it names Google; turn on cookie consent and it explains the visitor's choices. The owner can still reword any single clause or add their own text, and a reworded clause is flagged when the live text it replaced changes.
How it works
Four layers:
- Signals — PrivacySignalRegistry detects every data practice the CMS can see at runtime: which features are switched on, whether analytics sets a persistent cookie, whether a GA4 ID or tracking snippets are configured, whether any page embeds a video, which sign-in and email providers are wired up. Each signal reports whether it is active, a status line for the settings page, and the values its clauses print (retention days, provider names, …). Feature checks read the owner's switch (
Features::configuredEnabled()), never the licence-gated one, so a licence lapse never churns the policy. - Facts — what the code can't know, recorded on Settings → Privacy & Compliance (see Manual facts below).
- Clauses — LegalClauseRegistry holds every section and clause of both documents. Core registers its own (CoreLegalClauses); each feature module registers its clauses from its own provider (
app/Features/{Name}/Support/{Name}LegalClauses.php). A clause has a condition, a body partial underresources/views/partials/legal/{privacy,terms}/whose every sentence is a literal__()(so the shipped lang files translate it), and the list of signals it reads or describes. A clause whose condition is off is left off the page. A feature clause stays while the site still holds that feature's data, even after the feature is switched off. - The document — the
x-dl.legal-documentcomponent (LegalDocumentAssembler) assembles the applicable clauses in the visitor's language, with the owner's wording wherever they customised a clause.
Detected signals
Each signal decides one or more clauses. Settings → Privacy & Compliance lists, under each detected practice, the clauses it puts in the policy (struck through when the clause is not on the page right now).
| Signal | Active when | Clauses it drives |
|---|---|---|
| Built-in analytics | Analytics feature on | Site analytics (daily totals with no IP or browser details; the 7-day daily hash; the stable hash and where it is stored — campaign and ad-click arrivals with raw click IDs, every counted action, funnel steps when a funnel exists, and the copy on contact records and orders that links visits to a person; never for Do Not Track / GPC; the consent-gated wpcms_vid cookie, which stops being used and is expired once analytics consent is withdrawn or the banner is switched off — on the visitor's next request, counted or not; the live view's 30-minute record of each page view, while it is on; the engagement measure's daily per-page totals, while its beacon is on; retention; visit totals shared with the software provider when telemetry.share_traffic is on); Cookies we use |
| Google Analytics | A GA4 measurement ID is configured | Google Analytics (never loads for Do Not Track / GPC; analytics-consent-gated while the banner is on); Cookies we use (_ga, _ga_*) |
| Cookie consent banner | Cookie Consent on | Your cookie choices (what each mode really does — opt-in, opt-out with the Do Not Track / GPC exception, no banner with content held back, and by region: EEA/UK/Switzerland and unknown places opt-in, the US opt-out, elsewhere the configured default; the local country lookup; the wpcms_consent lifetime; the Cookie settings button; what withdrawal does and what the banner does not control — including the site's own visit counting while Analytics is on, which runs whatever the banner says); Cookies we use |
| Tracking snippets | Any active snippet categorized Analytics or Marketing | Third-party analytics and marketing tools (consent-gated by category while the banner is on); Cookies we use |
| Embedded videos | Any page has an embed-mode x-dl.video |
Embedded videos (YouTube in privacy-enhanced mode, Vimeo, Loom; marketing-gated with a local YouTube preview while the banner is on); Cookies we use |
| Forms | Any form exists | Forms you submit (stored submissions with the page, IP address and country; private file uploads; saved progress and its 30-day life; encryption; how long submissions are kept; contact records only while the CRM takes forms; texts to the owner; reCAPTCHA / Turnstile / Akismet; the spam fingerprints shared with the software provider when staff mark spam) |
| CRM | CRM feature on | Contact records (what a record holds, IP and country from forms and sign-ups, first touch and the link to later visits while analytics is on — never for Do Not Track / GPC, the email open pixel while CRM → Settings → Track email opens is on (off by default), kept until deleted) |
| Newsletters | Marketing feature on | Newsletter (what a subscriber row and its consent record hold, double opt-in when on, the ways onto the list, marketing to contact records, global unsubscribe and the transactional exception, open and click tracking as configured, the sign-up conversion, Twilio texts with STOP, web push, bounces, retention) |
| Online shop | Ecommerce feature on | Purchases (order name, email and shipping address — never a billing address; how the visitor found the shop; unfinished checkouts and the optional cart reminder; product reviews; back-in-stock alerts; the shipping-label carrier when one is set up). Terms: Orders (tax, pricing mistakes, discount codes, refunds to card or store credit, download links); Product subscriptions when a recurring product exists |
| Real estate | Real Estate feature on | Property inquiries (lead details with IP and country, the home-value service when RentCast or ATTOM is set up, packet links, saved homes and searches, text alerts, the sign-up prompt, phone checks, the agents' buyer digest, hotlinked listing photos); terms: Property listings (listing accuracy, estimates are not appraisals) |
| Member accounts | Any feature that opens visitor accounts is on — Memberships, Ecommerce, Real Estate, Automotive, Courses or Client Portal (MemberAccounts) | Member accounts (account data, the sign-up record of what the visitor agreed to, stay-signed-in cookie, sign-in providers, passkeys, CRM capture at sign-up). Terms: Accounts; Membership plans once a plan exists (renewal, trials, cancelling, pausing, failed payments) |
| Online payments | Stripe is set up and any feature that checks out through it is on (PrivacySignalRegistry::PAYMENT_FEATURES) |
Payments — one clause for every paying feature: what Stripe receives, the card kept on file for recurring payments, Stripe's own cookies |
| Gift cards | Ecommerce on and the shop sells gift cards or holds a card or store credit | Gift cards and store credit (privacy and terms) |
| Loyalty points | Loyalty feature on | Reward points (the points record, the birthday given for a birthday reward, reminder emails); terms: no cash value, expiry, refunds, changes |
| Donations | Donations feature on | Donations (donor details, e-cards, what a fundraiser's owner sees); terms: no goods or services, fee cover, cancelling a recurring gift, tax, fundraising pages |
| Payment links | Payment Links feature on | Payment links and paid forms (payer details; a paid form's submission is always kept); terms |
| Client billing | Client Billing feature on | Clients and invoices (client details, the invoice link's first view, the billing-system sync when on); terms: due dates, deposits, recurring invoices never auto-charged, reminders, the late fee as configured |
| Client portal | Client Portal feature on | Client portal (shared files, approval responses with IP address); terms: ownership of uploads, what an approval means |
| Event tickets | Events Calendar on and an event sells tickets (or the site holds a ticket order or waitlist entry) | Event tickets (buyer and attendee details, check-ins, waitlist, the order link, Google Wallet when set up, buyers staff add to the mailing list); terms |
| Online food ordering | Restaurant feature on | Food orders (order details, delivery address, text updates when on); terms: timing estimates, delivery areas, allergens |
| Affiliate program | Affiliates feature on | Referral links (the wpc_aff cookie and its lifetime, never set for Do Not Track / GPC, partner applications and tax forms, Stripe Connect when on); terms: commission, the referral window, first- or last-click credit, the holding period |
| Online booking | Online Booking feature on | Appointments (booking details, the manage link, service-call address and early CRM capture, Nominatim geocoding in radius mode, Twilio text reminders, staff calendar sync, missed appointments); terms: payment and deposits, the reschedule/cancel cut-off as configured, no automatic refund, service areas and arrival windows |
| Venue rental | Venue Rental feature on | Venue bookings (organizer and event details, the manage link, emails, unpaid holds); terms: deposit and balance, the checkout hold, quote validity, the cancel cut-off as configured, no automatic refund, rental agreements, the owner's rental terms |
| Online scheduler | Field Service on and a scheduler set up | Online scheduling (names the vendor — ServiceTitan, or the embed's host — that collects the booking, consent gating); terms: the vendor's terms apply |
| Courses | Courses feature on | Courses (enrollment and progress, quiz answers, purchases, accounts created on enrollment, public certificate pages without email, lesson Q&A, course emails); terms: personal access, no expiry, drip, quizzes, certificates |
| Event RSVPs | Events Calendar on and an event has its RSVP switch on (or the site holds an RSVP) | Event RSVPs (guest details, confirmation and cancel link, public count only) |
| Forum | Forum feature on | Forum (post details and IP, what is public incl. search-engine data, reply emails, reports, likes, guest posting when on); terms: Forum (rules, review as configured, removal) |
| Directory submissions | Directory on and visitors can submit, claim or pay for listings (or the site holds what they sent) | Directory listings (submitter details, claims, payments, renewal emails, CRM capture); terms: authority and accuracy, review, removal, paid terms as configured, no auto-renew |
| Job listings | Job Listings feature on | Job applications (once a job-application form exists: what is stored, résumés kept privately, CRM only if the form feeds it) and Job alerts (double opt-in, unsubscribe, kept-unsubscribed record) |
| Vehicle inquiries | Automotive feature on | Vehicle inquiries (lead and trade-in details with IP and country, saved vehicles and alerts, browser-only payment estimates); terms: Vehicle listings |
| Support tickets | Support Tickets feature on | Support requests (request details, private attachments, the request link, reply by email, ratings, AI-drafted replies, the support form's own reCAPTCHA / Turnstile); terms: no promised response time, auto-close as configured |
| Calls and texts | Inbox feature on | Calls and text messages (texts, call logs, missed-call text-back, Twilio voicemail transcription, call-tracking numbers) |
| Electronic signatures | Client Billing on (proposals and contracts), or any form has a signature field | Electronic signatures (first open, what signing records, the signed-copy PDF with the IP address, form signatures); terms: Electronic signatures (the disclosure's points) and Proposals |
| Blog comments | Comments on | Comments (social sign-in and what the provider shares, what each comment stores, what is public, hotlinked profile pictures, the stay-signed-in cookie); terms: Comments (rules, moderation as configured, removal) |
| AI chat assistant | AI Chat feature on (the clause also needs an AI service the chat can answer through, or held conversations) | AI chat assistant (what is sent and kept, contact capture and staff emails while the CRM takes chats, sign-ups, bookings, support requests, order look-ups, staff takeover, handoff emails, retention and the copies that outlive it); terms: Answers from our chat assistant |
| Platform reviews | Reviews feature on and a review platform connected | Reviews (the platforms shown, fetched server-side) |
| Search query logging | Analytics feature on | On-site search (what is logged, never for Do Not Track / GPC, the retention) |
| Email delivery service | marketing.provider is Postmark, Resend, Mailgun or SendGrid — whether or not Marketing is on, because every feature's email goes through it |
Email delivery |
| Pop-ups and banners | Marketing on (popups are folded into it) and a pop-up or bar is switched on and not ended | Pop-ups and banners (what the browser keeps, aggregate show/act counts, A/B versions); Cookies we use |
| Reviews collected on this site | Reviews on, and the review page on or Marketing on (review requests go out through it) | Reviews (on-site reviews, search-engine markup, requests by email or Twilio text, the request record, the private feedback form for low ratings, AI reply drafts, social sharing); terms: Reviews you write |
| AI services | The chat can answer, visitors can search by meaning, or staff can draft support or review replies with AI | AI services (what reaches them for each use, the companies by name, the managed relay keeping only usage counts, DeepSeek's location) |
| Search by meaning | Semantic Search on, an embeddings service resolves (managed relay, OpenAI or Google), and the owner's choice lets a visitor's search reach it — Both or Meaning as the opening method, or the Both / Keywords / Meaning toggle offered (VisitorSearchMode::meaningAllowedByOwner()) |
Search services; Cookies we use (siteSearchMethod) |
| Meilisearch | Advanced Search on, the Meilisearch engine chosen and a host that is not this server | Search services (the host, what it receives and holds — including form submissions with IP and account names and emails) |
| Age check | Age Verification on | Age check (whole site or some pages, confirm or date of birth checked in the browser, the wpcms_age_ok lifetime, pass/fail counts with the stable hash); Cookies we use |
| Fonts from Bunny Fonts | Web fonts are selected and this server has no local copy of them yet | Fonts |
| Maps | A page — or a header or footer a page shows — has an x-dl.map, the directory shows its map, or Real Estate is on |
Maps (the services by name, from the site's map mode and any mode a map sets in its own tag: Esri, Google Maps only while a Google key is set (without one a Google map falls back to the embed provider, which is named instead), OpenStreetMap; marketing-gated while the banner is on; closest-location in the browser) |
| Hosted video | Video Hosting on and a video is ready to play | Videos we host (the streaming services by name; not held back by the banner) |
| Connected tools | A form has a webhook, the Webhooks feature has an active endpoint, or the API has a live key | Connected tools |
The cookies table is one clause built from several signals and lists every cookie and browser-storage item the site can give a visitor, each only while the site can set it: the session and XSRF-TOKEN cookies (with the real session cookie name and lifetime), wpcms_visitor and wpcms_device (the known-browser cookie Login Protection sets at sign-in) always; remember_web_* while any feature opens accounts; remember_commenters_* and wpcms_commenter while comments have social sign-in; wpcms_consent; wpcms_vid; wpcms_cart_n; wpc_aff and wpcms_dni (both marked as not controlled by the banner); wpcms_age_ok; Google Analytics' and Stripe's own cookies; a third-party row for embeds, maps, schedulers, snippets and hosted video; and a second table of browser storage (appearance, accessibility settings, pop-up memory and A/B versions, pop-up message rows, call tracking, chat state and invitation, the engagement page count, the listing sign-up gate and compare list, the search mode, push notifications). LegalCookieTableCoverageTest reads the site's own cookie and storage names out of the scripts, blades and PHP cookie constants and fails on any the table does not name — a new one needs a row, or an exemption with its reason when it never reaches a visitor.
Manual facts (Settings → Privacy & Compliance)
The Privacy Details card records the things the code can't know, stored under privacy.* Settings (the terms facts under legal.*). Saving it refreshes every live legal page.
- Legal entity name (defaults from Business settings) and privacy contact email (published in the policy, so never prefilled from the business email)
- Governing law / jurisdiction (optional; both documents use it). On a site with more than one language, an input per other language names the same place in that language (
privacy.jurisdiction__{lang}, read byPrivacyFacts::jurisdiction()), so a Spanish page reads "las leyes de Arkansas, EE. UU." rather than the English place inside a Spanish sentence. Blank, or out of date after the English changed, the base value prints. It is on the settings translation surface, sotranslations:statusreports it andtranslations:import/translations:fillcan fill it. - "We sell or share personal data" toggle — drives the CCPA wording
- Data retention statement (optional; replaces the generic retention paragraph)
- Other third-party services free text (CDN, fulfillment partner, …) — gets its own clause
- Last reviewed date (for the owner's records)
- Policy page slug (advanced — only if the policy lives at a non-default URL)
- Terms page URL (
legal.terms_url) — where the signup consent checkbox links. Left blank, it links the page that shows the live Terms of Service, or no terms link at all when no page does. A URL naming a page of this site that visitors can't open (a draft) is passed over for the live terms page the same way, so signup never links — or records as accepted — text nobody can read. - Minimum age, Refund window (days) and Cancellation notice (days) (
legal.minimum_age,legal.refund_window_days,legal.cancellation_notice_days) — each adds its sentence to the live Terms of Service only once it is set. A refund window of 0 says purchases are not refunded; blank says nothing about refunds.
Legal pages (Settings → Privacy & Compliance)
The Legal Pages card shows one panel per document (LegalPageStatus):
| Status | Meaning | What the panel offers |
|---|---|---|
| Live text | A published (or unlisted) page shows the live document | Each host page with Open in page editor (opens the page with the document's row selected; so does each clause name), its "Last updated" date, its clauses grouped by state, and its revision history |
| Legacy | No published page shows the live document, but the page the site links to — or one at a URL the themes ship it at (/privacy-policy, /privacy, /terms-of-service, /terms) — shows fixed text |
The page, an editor link, and how to make it live (add the Privacy Policy / Terms of Service row and remove the old text). Once the daily legal audit has run: whether the text is the standard text the CMS shipped (with Switch to the live …, admin only), and what an owner-written page says that the site contradicts — see "Existing installs" below |
| Missing | Neither | Create page — builds the page at the URL the site links to (else /privacy-policy or /terms-of-service): the install's page-title band, then the live document. Admin only. |
Warnings shown on a panel:
- Linked elsewhere (Live text) — the site's links (the policy page slug, or the Terms page URL) point at a page that does not show the live document.
- Not linked (Legacy) — the site links somewhere else, or (terms) links nothing at all, so the signup form shows no terms link.
- On another site (Legacy) — the Terms page URL is an external link the CMS can't read.
- Out of date (Legacy) — a policy written by the retired generator whose detected services or privacy details have changed since it was generated.
- Not published (Missing) — a page shows the document, or sits where one would be created, but visitors can't open it. Create page is not offered; publish that page instead.
- Standard text / Standard text, reformatted / Your own wording (Legacy) — what the legal audit found the page to be.
Clause states are worked out exactly as the page editor works them out for the same page, from the overrides that page sees: Live text (follows the live text), Customized (the owner's wording), Live text changed (the live text the owner's wording replaced has since changed), Not checked (owner wording with no record of what it replaced — a version restore or a tool wrote it), and Not shown (the clause doesn't apply right now, with or without the owner's wording kept). "Needs review" groups the flagged ones. Customising, Use live text and Keep mine happen in the page editor — see "Legal document clauses" in docs/editor-architecture.md.
Revision history lists the latest 10 versions of each host's document, newest first; View shows the document as it read from that date, as plain text in the site's default language.
The cookie banner's Privacy policy URL (Cookie Consent → Other) follows the privacy page wherever it lives when left blank. The field's old default, /privacy-policy, counts as blank, so installs that saved it unchanged follow the page too.
Existing installs: the legal audit and switching
Installs built before the live documents carry a privacy policy and terms page of fixed text. LegacyLegalPageDetector finds them by content, not URL, comparing every rich-text body (as its own page shows it — that page's override tier over the global one, else the text baked into the tag) with what the CMS has shipped:
| Class | What it is | Switched automatically? |
|---|---|---|
| Stock | Exactly a policy or terms body the CMS shipped (however the editor has re-saved its markup), a policy the retired generator wrote and nobody has changed (privacy.snapshot's hash), or a terms template's untouched "Your text here." on the site's terms page |
Yes — nothing the owner wrote is lost |
| Likely stock | The shipped words with different formatting | No — reported |
| Edited | An edited copy of the shipped text anywhere, or substantial text on the page the site uses for the document (its policy slug / terms URL, a URL the themes shipped, or a page named "Privacy Policy", "Terms of Service"…) | No — reported, never replaced without --force-edited |
What the CMS shipped lives in StockLegalBodies, generated from git history by php artisan legal:build-stock-catalog (development only; --check compares it with history). Installs have no history, so the catalogue is committed. Its hashes are of LegalBodyText's forms — change those and the catalogue must be rebuilt (FORMAT).
legal:audit (daily LazyCron; report only) records the legal.audit Setting: each document's state (live, stock, likely_stock, edited, unknown, missing, external), every legacy page found — including theme pages parked by a theme switch — and, for the published owner-written page the Settings card shows for each document (so what the fleet counts is what the owner can see and dismiss there), two kinds of finding from the clauses that apply to the site (LegalAudit):
- Contradicts — the page says a sentence the clause makes false: "There is no Google Analytics" with Google Analytics on, "only strictly-necessary cookies" with a tracking cookie set, "We do not use these conversations to identify you" while chat leads go to the contact records. Precise by design; shown on the card and counted in the fleet badge.
- May be missing — the page names nothing a clause's practice would be called ("Stripe", "payment" on a site taking payments). A prompt to look, never a badge.
Each clause declares its own rules (audit: on its registration; contradicts phrases, optionally held to a flag in the clause's data, and mentions keywords). They are English sentences from the shipped text, so a site whose default language isn't English is not checked. The owner can Dismiss a finding from the card; dismissals are kept apart from the report (legal.audit_dismissed) so the next run doesn't bring them back — a contradiction is known by its sentence, so a dismissal holds when another clause comes to make the same sentence false — and Show them again restores a page's. Check again re-runs the audit at once. A legacy page the audit couldn't read (its text is not in a rich-text body, or too short to judge) says so on the card, rather than "not checked yet".
Switching a stock page — the card's Switch to the live … button (admin), or php artisan legal:migrate-pages (LegacyLegalPageMigrator):
- Replaces the old body with
x-dl.legal-documentin the same row on the RowDoc tree: the row keeps its slug and its section (and so its design), the document takes the body's classes (plusarticle-contentwhen an old page lacked it), and a terms template's placeholder heading and button go too while still untouched. - Re-reads the page under a lock first, validates the result (byte-identical round trip, Blade compiles, the document is in the row) before writing anything, and saves a forced page version first — the old text and its translations are one restore away. With page version history switched off it refuses rather than switch without one.
- Moves the file's modified time even within the same second, so an editor that had the old page open merges with the switch on its next save.
- Leaves alone: edited and likely-stock pages (unless
--force-edited), a page whose text is in more than one row, a page already showing the live document, and parked theme pages (switch them once their theme is active again). - Ends every run by dropping the cached copy of every page showing a live document, recording first revisions and re-running the audit — so a run killed after its write is finished by the next one. It deletes
privacy.snapshotonce no page shows the generated policy any more (a page left alone because the text sits in two rows still counts), and pointsprivacy.page_slugat the live policy when it pointed at no page and exactly one page shows it. --dry-runlists what would switch and why the rest would not;--page=limits a run to one page (URL, slug or file). The card's button switches only its own document, even on a page that carries both, and a refusal says why (turn on page history first; or the page can't be switched automatically; or it changed since it was checked).
At update. The update after the live documents shipped switches every stock page by itself, shortly after it finishes. Its migration (swap_legacy_legal_pages_to_live) only schedules the switch (the legal.stock_switch Setting). The next 15-minute legal:refresh sweep runs the migrator, as legal:migrate-pages with no options would (ScheduledLegalSwitch). So the page walk never runs while the site shows the maintenance page, and nothing it does can fail the update.
- A sweep that finds the audit holding the lock leaves the switch for the next sweep, without using up a run.
- A run that fails is tried again on the next sweep, three runs in all. Each run is counted before it starts, so one that dies outright (a fatal error) still counts. After the third failure the switch is given up with a log line naming
php artisan legal:migrate-pages. - A run that finishes logs what it switched and why it left the rest, one line per page and document.
- The suite's own migrations schedule nothing.
The fleet health block carries the audit as legal_issues ({privacy, terms, contradictions, mentions}, absent before the first audit); the fleet badges an install with an undismissed contradiction.
Freshness, revisions and "Last updated"
A live document is assembled at render, but its public page is response-cached for days, so something has to notice when the text changes:
- Fast path — saving a privacy fact, switching a feature (from anywhere, including the theme installer and setup wizard), or saving a Setting a clause watches refreshes every host page at the end of that request.
- Render — a page render compares the text it just assembled with the newest revision. That is how an owner's own clause edit, which is an ordinary page save, gets dated.
legal:refresh— a LazyCron sweep every 15 minutes catches what no hook sees (an embed added on another page, a licence change). It first runs the stock-page switch an update scheduled, when there is one (see "At update" above).
Each change of text writes one row to the revision log (legal_revisions: document, page, row, the document's item prefix — a row can hold two copies of one document, each with its own wording and history — a hash and the plain text in the site's default language). The newest revision's date is the page's "Last updated" line, which each document can switch off in the editor. It is written in the site's timezone and the way the visitor's language writes a date ("October 6, 2026", "6 de octubre de 2026", "2026年10月6日"). The date line is never part of the hash, so the date moves only when the text does.
Adding a clause for a new feature
- Write the body partial at
resources/views/partials/legal/privacy/{name}.blade.php(orterms/): class-free semantic HTML (<p>,<ul>,<table>), every sentence a literal__(). - Register the clause from the module's provider (
app/Features/{Name}/Support/{Name}LegalClauses.php, onecallAfterResolving(LegalClauseRegistry::class, …)line) into an existing section. Conditions readFeatures::configuredEnabled()plus aholdsDatacheck, neverFeatures::enabled(). Keys are lowercase snake case, never renamed once shipped. - If it reads a privacy signal (or a new signal you add to PrivacySignalRegistry), name it in the clause's
signals. A test fails when a clause reads a signal it doesn't name, names one that doesn't exist, or a signal drives no clause. - A folded sub-feature (gift cards, event ticketing, proposals, contracts, popups —
Features::FOLDED) has no switch of its own: key the clause on its parent feature (ecommerce,events, …).Features::configuredEnabled()of a folded key reads a Setting nothing writes, so a clause keyed on it would never appear. - Terms clauses go in the section that fits:
purchasesfor anything bought or paid,bookingsfor appointments and rentals,programsfor rewards and partner programs,accounts,content(including community rules),signaturesfor e-signing,disclaimersfor listing accuracy. - A new cookie or browser-storage item a visitor can get needs a row in
partials/legal/privacy/cookies-table.blade.php(LegalCookieTableCoverageTest). - Every new clause sentence and heading needs translating in all shipped
lang/*.jsonbefore release (LegalClauseStringsTranslatedTest). In this repo that is a Sonnet subagent per locale, never Auto Translate. - Whether a page shows a component (a map, a pop-up message row) is read from the page files — with their shared and role rows, and the header and footer partials the site and each page use — by
LegalPageScan, once per request.
Retired: Generate and AI rewrite
Earlier releases had Generate policy and Generate + rewrite with AI buttons, which wrote the whole policy as one block of fixed text into the privacy page's rich-text row. That text went stale the moment the site changed, and rewriting the whole document would have frozen every clause of a live one, so both are gone. Per-clause AI rewriting still works in the page editor, through the same save path as any other edit, so a rewritten clause is tracked like a hand-written one.
The generator itself is gone too. A policy it wrote that nobody has changed counts as stock — the privacy.snapshot Setting it left behind is read only to recognise that text — so the update switches it like any shipped body. One the owner edited stays as fixed text and shows as Legacy.
Limits / notes
- Not legal advice. The live documents describe what the CMS does, accurately and honestly, but owners in regulated industries should still have counsel review them.
- Languages. Clause text is translated by the shipped lang files and follows the visitor's language. The owner's own wording for a clause can be translated per language; a stored translation of a clause the owner has not customised is ignored, so it can never go stale.
- New installs ship the live documents. Every theme's Privacy Policy and Terms of Service page shows the live document (
x-dl.legal-document), so a new site's policy and terms follow what it has switched on from the first day, and the signup checkbox links the terms page with no Terms page URL set. The row picker offers Privacy Policy and Terms of Service rows (Page Content → Content) to put either document on any page. - A captured theme never carries an install's legal wording.
themes:captureand Save as custom theme leave out every reworded clause and the owner's own section text, in every language, so installing the theme can't freeze someone else's wording onto a new site's policy. The document's design (classes, the "Last updated" toggle) is captured as usual. - Copy-writing tools leave legal text alone. The AI site generator, the popup copywriter, the MCP copy tools and the dashboard assistant read live legal text as read-only and never write a clause.
- Existing installs: stock pages switch at update, the owner's own stay. An owner-written page shows as Legacy, with the legal audit's verdict, until someone switches it;
legal:migrate-pages --force-editeddoes, keeping the old text in the page's history.