Home›Deliora›Test run
Deliora · Test run · run 2026-11-05

Deliora Test run, as shipped in the download

1,831 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-11-05 with php tests/run.php. Run it yourself after you download.

== 0. launch-mode flags ship OFF on a fresh install ==
  PASS  fresh install: demos_live defaults to 0 (got 0, want 0)
  PASS  fresh install: sales_live defaults to 0 (got 0, want 0)

== 1. helpers ==
  PASS  e() escapes for view context (got '<b>"x"</b>', want '<b>"x"</b>')
  PASS  clip caps to width (got 190, want 190)
  PASS  clip passes short values (got 'abc', want 'abc')
  PASS  clamp_int caps range (got 2000000000, want 2000000000)
  PASS  clamp_int floor (got -2000000000, want -2000000000)
  PASS  clamp_int non-numeric -> 0 (got 0, want 0)
  PASS  fmt_money USD (got '$49.00', want '$49.00')
  PASS  fmt_money unknown code suffixes (got '49.00 SEK', want '49.00 SEK')
  PASS  now_utc format

== 2. safe markdown renderer (escape-first) ==
  PASS  heading rendered as h2
  PASS  subheading rendered as h3
  PASS  bold rendered
  PASS  list rendered
  PASS  paragraph rendered
  PASS  script tag escaped
  PASS  img/onerror escaped
  PASS  raw HTML never passes through bold
  PASS  heading content escaped
  PASS  list content escaped
  PASS  quote attribute breakout escaped
  PASS  table rendered with thead
  PASS  table body cell inline-rendered
  PASS  separator row not rendered as cells
  PASS  blockquote rendered
  PASS  ordered list rendered
  PASS  hr rendered
  PASS  code span rendered
  PASS  italic rendered
  PASS  table cell XSS escaped
  PASS  code span in table escaped
  PASS  blockquote XSS escaped
  PASS  ordered-list XSS escaped
  PASS  code span shields markdown and html
  PASS  bold not applied inside code
  PASS  legacy constructs unchanged
  PASS  line breaks: a wrapped paragraph line is a space, not a <br> (got '<p>reaching for when they type the phrase.</p>', want '<p>reaching for when they type the phrase.</p>')
  PASS  line breaks: two trailing spaces or a backslash still break the line (got '<p>one<br>two<br>three</p>', want '<p>one<br>two<br>three</p>')
  PASS  line breaks: a wrapped bullet continues its item instead of leaving the list (got '<ul><li>first item that wraps here</li><li>second</li></ul>
<ol><li>one that wraps</li></ol>', want '<ul><li>first item that wraps here</li><li>second</li></ul>
<ol><li>one that wraps</li></ol>')
  PASS  line breaks: blockquote lines stay one per line (a quote, then its source) (got '<blockquote><p>the quote<br>— the source</p></blockquote>', want '<blockquote><p>the quote<br>— the source</p></blockquote>')
  PASS  line breaks: an indented block after a blank line is code, escaped, one line each (got '<p>Headers:</p>
<pre><code>X-Event: a.b
Sig: &lt;hex&gt;</code></pre>
<p>After.</p>', want '<p>Headers:</p>
<pre><code>X-Event: a.b
Sig: &lt;hex&gt;</code></pre>
<p>After.</p>')
  PASS  line breaks: an indented line inside a paragraph is only a continuation (got '<p>a paragraph continued</p>', want '<p>a paragraph continued</p>')

== 3. Payments demo contract (crypto module boundary) ==
  PASS  demo fixture parses
  PASS  type normalized (got 'paid', want 'paid')
  PASS  order_ref kept (got 'ord_123', want 'ord_123')
  PASS  buyer email lowercased (got 'buyer@x.com', want 'buyer@x.com')
  PASS  currency uppercased (got 'USD', want 'USD')
  PASS  amount_cents int (got 4900, want 4900)
  PASS  items mapped (got array (
  0 => 
  array (
    'sku' => 'invoicer-pro',
    'tier' => 'single',
  ),
), want array (
  0 => 
  array (
    'sku' => 'invoicer-pro',
    'tier' => 'single',
  ),
))
  PASS  refunded event parses without items (got 'refunded', want 'refunded')
  PASS  unknown type -> other (got 'other', want 'other')
  PASS  garbage body -> null
  PASS  missing order_ref -> null
  PASS  bad email -> null
  PASS  paid with no items -> null
  PASS  provider mismatch -> null (demo body to lemonsqueezy endpoint)
  PASS  LS: forged signature -> null
  PASS  Stripe: bogus/stale signature -> null
  PASS  unknown provider -> null
  PASS  LS: valid signature -> normalized paid event
  PASS  LS: tampered signature -> null
  PASS  LS: wrong secret -> null
  PASS  LS: UUID identifier preferred over numeric data.id as order_ref
  PASS  LS: empty identifier falls back to data.id (refund keeps matching old rows)
  PASS  LS O13: a PAID order with no identifier never takes the sequential data.id as its ref
  PASS  LS O13: that derived ref is deterministic, so an LS resend stays idempotent
  PASS  Stripe: valid signature -> normalized paid event
  PASS  Stripe: stale timestamp -> null
  PASS  Stripe: tampered payload -> null
  PASS  module HAS real HMAC verification (not a stub)

== 4. Licensing (real HMAC, DB-bound tamper-evidence) ==
  PASS  sign() returns a real OWX key (not DEMO), fits 80 chars
  PASS  sign -> insert -> verify returns the row grant
  PASS  verify with WRONG secret -> null
  PASS  verify detects a tampered DB grant -> null
  PASS  verify unissued real-format key -> null
  PASS  verify demo-format key (never issued) -> null
  PASS  verify garbage -> null
  PASS  verify with no secret -> null
  PASS  200 keys for identical claims are unique (random nonce) (got 200, want 200)
  PASS  module HAS real HMAC signing (not a stub)

== 5. FULL webhook → fulfillment loop ==
  PASS  W16-6: the two download fixtures are shutdown-guarded, not only cleaned inline
  PASS  W16-6: data/files carries no orphaned test_*.zip from an earlier aborted run
  PASS  paid webhook -> 200 (got 200, want 200)
  PASS  one item fulfilled (got 1, want 1)
  PASS  customer upserted
  PASS  order created paid
  PASS  order amount stored (got 4900, want 4900)
  PASS  order provider stored (got 'demo', want 'demo')
  PASS  one order item (got 1, want 1)
  PASS  license issued (real signed key that verifies)
  PASS  license active (got 'active', want 'active')
  PASS  activation limit from settings (got 3, want 3)
  PASS  download token issued (48 hex)
  PASS  download max_uses from settings (got 5, want 5)
  PASS  download expiry ≈ 72h from settings
  PASS  fulfillment email in outbox (demo mode, not sent)
  PASS  email points at the Lemon Squeezy receipt for the license, and never carries a second key
  PASS  email contains download link
  PASS  DUPLICATE order_ref -> 200 (got 200, want 200)
  PASS  duplicate flagged (got true, want true)
  PASS  duplicate: NO double fulfillment (orders/items/licenses/downloads/outbox all unchanged) (got array (
  0 => 1,
  1 => 1,
  2 => 1,
  3 => 1,
  4 => 1,
), want array (
  0 => 1,
  1 => 1,
  2 => 1,
  3 => 1,
  4 => 1,
))
  PASS  multi-item order fulfills both (got 2, want 2)
  PASS  two licenses on multi order (got 2, want 2)
  PASS  two downloads on multi order (got 2, want 2)
  PASS  unknown sku -> 200 (got 200, want 200)
  PASS  unknown sku -> 0 fulfilled (got 0, want 0)
  PASS  unknown sku order still recorded
  PASS  pipeline: non-demo provider with stub -> 400 (fail closed) (got 400, want 400)
  PASS  pipeline: demo webhook rejected while real provider active (got 400, want 400)
  PASS  rejected webhook wrote NOTHING
  PASS  refund -> 200 (got 200, want 200)
  PASS  order marked refunded (got 'refunded', want 'refunded')
  PASS  ALL its licenses revoked (got 2, want 2)
  PASS  refund again idempotent -> 200 (got 200, want 200)
  PASS  refund of unknown ref -> 200 no-op

== 6. download tokens ==
  PASS  refunded order download -> 410 (got 410, want 410)
  PASS  valid token -> 200 (got 200, want 200)
  PASS  resolved path is inside data/files
  PASS  attachment filename derived from slug+tier+version
  PASS  used_count incremented (got 1, want 1)
  PASS  used_count capped at max_uses (atomic) (got 5, want 5)
  PASS  exhausted token -> 410 (got 410, want 410)
  PASS  expired token -> 410 (got 410, want 410)
  PASS  expired token consumed no use (got 0, want 0)
  PASS  garbage token -> 404 (got 404, want 404)
  PASS  well-formed unknown token -> 404 (got 404, want 404)
  PASS  edition without a file -> 404 (got 404, want 404)
  PASS  path-traversal download_file -> 404 (basename + realpath guard) (got 404, want 404)

== 7. license API ==
  PASS  check valid
  PASS  check wrong sku (got 'sku_mismatch', want 'sku_mismatch')
  PASS  check garbage key (got 'invalid_key', want 'invalid_key')
  PASS  check non-demo-format key (got 'invalid_key', want 'invalid_key')
  PASS  activate consumes a slot
  PASS  re-activate SAME domain idempotent (no slot consumed)
  PASS  normalized variants map to the same domain (still no slot)
  PASS  limit reached exactly
  PASS  over limit rejected
  PASS  same-domain still fine at limit (idempotent)
  PASS  domain normalization (got 'foo-bar.com', want 'foo-bar.com')
  PASS  invalid domain -> empty (got '', want '')
  PASS  activate with invalid domain (got 'invalid_domain', want 'invalid_domain')
  PASS  admin activation reset frees the slot (got 1, want 1)
  PASS  check on revoked (got 'revoked', want 'revoked')
  PASS  activate on revoked (got 'revoked', want 'revoked')
  PASS  reissue creates a NEW key
  PASS  old key revoked by reissue (got 'revoked', want 'revoked')
  PASS  old key now fails check (got 'revoked', want 'revoked')
  PASS  new key valid with a fresh activation budget
  PASS  Licensing::verify returns claims from the DB row

== 8. CSV formula-injection guard (numeric-aware) ==
  PASS  =-formula neutralized (got '\'=1+2', want '\'=1+2')
  PASS  @-formula neutralized (got '\'@SUM(A1)', want '\'@SUM(A1)')
  PASS  +cmd neutralized (got '\'+cmd', want '\'+cmd')
  PASS  +1 numeric -> KEPT (got '+1', want '+1')
  PASS  -text neutralized (got '\'-abc', want '\'-abc')
  PASS  negative number KEPT (got '-45.00', want '-45.00')
  PASS  leading TAB neutralized (got '\'	X', want '\'	X')
  PASS  leading CR neutralized + quoted
  PASS  plain value untouched (got 'Ava T', want 'Ava T')
  PASS  comma value quoted (got '"a,b"', want '"a,b"')
  PASS  quote value escaped (got '"say ""hi"""', want '"say ""hi"""')
  PASS  export header row (got 'name,amount', want 'name,amount')
  PASS  CRLF endings
  PASS  formula neutralized in real export
  PASS  negative amount preserved in export
  PASS  empty export -> empty string (got '', want '')

== 9. clip/clamp on write paths (MySQL strict-mode defense) ==
  PASS  customer email clipped to 190 (got 190, want 190)
  PASS  customer name clipped to 190 (got 190, want 190)
  PASS  webhook order_ref clipped to 190 (got 190, want 190)
  PASS  webhook currency clipped to 8 (got 8, want 8)
  PASS  webhook sku clipped to 80 (got 80, want 80)
  PASS  webhook amount clamped inside INT (got 2000000000, want 2000000000)
  PASS  100KB description survives clip(200000) intact

== 10. buyer-portal magic links ==
  PASS  unknown email -> true (no enumeration)
  PASS  …and no token row created (got 0, want 0)
  PASS  buyer with orders -> true
  PASS  portal token created (48 hex, 1h expiry)
  PASS  magic link is in the outbox email
  PASS  consume returns the customer id
  PASS  token is single-use
  PASS  expired magic link rejected
  PASS  garbage magic link rejected
  PASS  invalid email -> true, silent

== 11. per-IP rate limiter ==
  PASS  61st hit from same IP+bucket blocked
  PASS  different IP unaffected
  PASS  different bucket unaffected

== 12. Mailer message building (no socket) ==
  PASS  From header present
  PASS  To header present
  PASS  Subject header present
  PASS  MIME text/plain utf-8
  PASS  dot-stuffing applied
  PASS  CRLF stripped from To (header injection)
  PASS  CRLF stripped from Subject (header injection)
  PASS  encodeHeader UTF-8 -> RFC 2047 (got '=?UTF-8?B?Q2Fmw6k=?=', want '=?UTF-8?B?Q2Fmw6k=?=')
  PASS  send refuses when unconfigured (no socket)
  PASS  send refuses invalid recipient before any socket
  PASS  sendMail in demo/provider-demo mode -> outbox only, never a socket

== 13. auth + csrf ==
  PASS  stored hash verifies
  PASS  wrong password rejected
  PASS  empty stored csrf token can never match (bypass killed)
  PASS  non-matching token rejected by hash_equals
  PASS  matching token accepted

== 14. security hardening regressions ==
  PASS  csrf_check rejects empty stored token (hash_equals empty-token bypass)
  PASS  installer rejects empty install token (_it bypass)
  PASS  settings_save blocks writes in public demo mode
  PASS  settings view never echoes smtp_pass value
  PASS  settings view never echoes ls_webhook_secret value
  PASS  settings view never echoes stripe_webhook_secret value
  PASS  webhook handler NEVER echoes the raw body
  PASS  webhook exceptions leak no details (generic 500)
  PASS  download response sets nosniff + attachment
  PASS  image serve sets nosniff + whitelisted mime
  PASS  demo session cookie is None + Secure + Partitioned (CHIPS)
  PASS  buyer session cookie is HttpOnly + Lax + Secure-on-HTTPS
  PASS  session cookie named deliora
  PASS  dev router denies config.php / data / .sqlite
  PASS  .htaccess denies config.php / sqlite / demo marker
  PASS  .htaccess denies data/ (the deliverables live there)
  PASS  no error_log of credentials anywhere in mail/domain layer
  PASS  secrets never in Deliora logs: raw body never persisted
  PASS  MEDIUMTEXT used for big columns on MySQL
  PASS  license column avoids MySQL reserved word `key`
  PASS  portable UTC timestamps (no SQLite datetime() in domain/controllers)
  PASS  front controller catches boot errors (no stack traces)
  PASS  SaaS boot shim present verbatim
  PASS  license API rate-limited per IP
  PASS  portal + demo-checkout rate-limited
  PASS  licenseGate compares sku with hash_equals (constant time)
  PASS  installer generates license_secret + demo provider default
  PASS  CSV exports go through the guarded toCsv

== 15. schema migration v1.0 -> v1.1 ==
  PASS  install() left the v1.1 landing columns present
  PASS  install() left settings site_url + demo_base present
  PASS  comparisons + demo_clicks tables exist
  PASS  legacy shape reproduced (columns gone)
  PASS  migrate() restores landing columns
  PASS  migrate() restores settings columns + new tables
  PASS  old->new migration keeps every product row (got 2, want 2)
  PASS  old->new migration keeps row data intact (got 'Invoicer Pro', want 'Invoicer Pro')
  PASS  migrate() is idempotent (re-runs are no-ops, no throw)

== 16. Storefront helpers ==
  PASS  break-even ceil: 12900/6200 -> 3 (got 3, want 3)
  PASS  break-even ceil: 9900/900 -> 11 (got 11, want 11)
  PASS  break-even exact: 7900/7900 -> 1 (got 1, want 1)
  PASS  break-even guard: monthly 0 -> 0 (got 0, want 0)
  PASS  featuresFrom parses valid list
  PASS  featuresFrom garbage -> [] (got array (
), want array (
))
  PASS  featuresFrom wrong shape -> [] (got array (
), want array (
))
  PASS  featuresFrom skips titleless items (got 1, want 1)
  PASS  faqFrom parses valid list
  PASS  faqFrom garbage -> [] (got array (
), want array (
))
  PASS  validateFeaturesJson: empty + valid -> null
  PASS  validateFeaturesJson: broken JSON -> friendly error
  PASS  validateFeaturesJson: missing title -> item-numbered error
  PASS  validateFaqJson: missing answer -> friendly error
  PASS  sentence leaves an existing full stop alone (got 'Self-hosted invoicing.', want 'Self-hosted invoicing.')
  PASS  sentence adds one when missing (got 'Self-hosted invoicing.', want 'Self-hosted invoicing.')
  PASS  sentence respects a question mark (got 'Ready?', want 'Ready?')
  PASS  sentence respects an ellipsis (got 'Own it once…', want 'Own it once…')
  PASS  sentence leaves empty empty (got '', want '')
  PASS  sentence trims before deciding (got 'Tagline.', want 'Tagline.')
  PASS  the alternatives page never double-punctuates a tagline
  PASS  priceTextCurrency euro sign (got 'EUR', want 'EUR')
  PASS  priceTextCurrency EUR word (got 'EUR', want 'EUR')
  PASS  priceTextCurrency dollars (got 'USD', want 'USD')
  PASS  priceTextCurrency pound sign (got 'GBP', want 'GBP')
  PASS  priceTextCurrency GBP word (got 'GBP', want 'GBP')
  PASS  priceTextCurrency real Papertrail text (got 'GBP', want 'GBP')
  PASS  priceTextCurrency A$ is AUD (got 'AUD', want 'AUD')
  PASS  priceTextCurrency C$ is CAD (got 'CAD', want 'CAD')
  PASS  priceTextCurrency quote-gated falls back (got 'USD', want 'USD')
  PASS  priceTextCurrency empty falls back (got 'USD', want 'USD')
  PASS  a GBP price renders with a pound sign, not a dollar sign
  PASS  detector and renderer agree on the currency set
  PASS  vs-calc.js SYM covers every currency the detector returns
  PASS  siteBase trims trailing slash (got 'https://ownware.example', want 'https://ownware.example')
  PASS  canonical builds off site_url (got 'https://ownware.example/p/foo', want 'https://ownware.example/p/foo')
  PASS  demoTarget: product demo_url wins (got 'https://direct.example/x', want 'https://direct.example/x')
  PASS  demoTarget: template substitutes {slug} (got 'https://foo.demo.ownware.example', want 'https://foo.demo.ownware.example')
  PASS  demoTarget: non-http scheme rejected
  PASS  demoTarget: nothing configured -> null
  PASS  demo_click_sources table exists (M12 migration)
  PASS  demo clicks by source: bar=2, page=2 (unknown via counts as page), legacy total=4 — the total stays comparable
  PASS  no via row other than page/bar can exist
  PASS  demo click upsert: ONE row, count incremented to 2
  PASS  demo click: second product gets its own row (got 2, want 2)

== 17. landing page render (XSS + SEO + JSON-LD) ==
  PASS  landing renders (non-empty, has storefront chrome)
  PASS  NO raw <script>alert survives ANY injected field
  PASS  product name escaped in h1
  PASS  feature title + blurb escaped
  PASS  FAQ rendered as details/summary (no JS accordion)
  PASS  pain + limitations escaped through md_render
  PASS  thanks: no false email promise when SMTP unset
  PASS  thanks: promises the email only when SMTP configured
  PASS  claim: offers the email-verified recovery route
  PASS  claim: posts to itself with a CSRF field
  PASS  claim: promises no key on the page itself (email-verified only)
  PASS  claim: points the buyer at spam before support
  PASS  claim route is registered GET+POST
  PASS  exactly one h1 (sane heading hierarchy) (got 1, want 1)
  PASS  canonical link off site_url
  PASS  meta description + og:title + twitter card present
  PASS  demo CTA present (demo_base template active)
  PASS  vs cross-link ABSENT with no comparison rows
  PASS  JSON-LD block present
  PASS  JSON-LD decodes to a schema.org @graph
  PASS  graph Product node: matching Offer (49.00 USD, InStock) + raw product name
  PASS  FREE product JSON-LD: SoftwareApplication with $0 offer, never a bare Product node
  PASS  offerMerchantFields: no shippingDetails and no hasMerchantReturnPolicy on any Offer (08 #18)
  PASS  graph BreadcrumbList: Home › product, 2 positioned items ending at /p/xss-probe
  PASS  graph FAQPage: built from the REAL FAQ (question present, Answer typed), no raw <script> leaked
  PASS  faq source: the shared Q&A file returns categories
  PASS  /faq renders the Q&A (its canonical home)
  PASS  the help widget no longer inlines the generic answers into a page
  PASS  comparisons schema carries the per-competitor substance columns
  PASS  alternativesGrouped exposes the substance fields to the templates
  PASS  comparisons carries the source_dead flag
  PASS  UPGRADE PATH: migrate() re-adds the comparison-facts columns on an existing install
  PASS  UPGRADE PATH: alternativesGrouped() runs after the re-migration
  PASS  /faq is reachable from the site chrome, not just the widget (no orphan pages)
  PASS  /faq.json serves the same categories the widget renders
  PASS  home page now carries canonical + og:title + meta description (was missing entirely)
  PASS  home JSON-LD: Organization + WebSite graph, WebSite publisher-linked to the Organization

== 18. vs page render (cited rows ONLY) ==
  PASS  vs page renders with priced competitor (escaped) + price text
  PASS  vs page REFUSES the row with empty source_url
  PASS  vs page skips inactive rows
  PASS  quote-gated row renders qualitatively (name, no calculator entry)
  PASS  visible citation footnote: date + source link
  PASS  break-even line rendered with the right month count (ceil(4900/6200)=1), singular
  PASS  break-even never renders the "1 months" grammar defect
  PASS  calculator markup: data-own-cents from the edition
  PASS  calculator option carries the cited monthly cents
  PASS  calculator script included
  PASS  vs page links back to the landing page
  PASS  landing now cross-links the vs page (rows exist)
  PASS  vs page with zero renderable rows -> 404 page
  PASS  quote-gated-only vs page: NO calculator markup at all

== 18b. /alternatives — competitor-keyed inversion of /vs ==
  PASS  competitorSlug "When I Work" -> when-i-work (got 'when-i-work', want 'when-i-work')
  PASS  competitorSlug "Bill.com" -> bill-com (got 'bill-com', want 'bill-com')
  PASS  competitorSlug collapses/trims junk (got 'zoho-expense', want 'zoho-expense')
  PASS  competitorSlug all-punct -> "" (got '', want '')
  PASS  alternativesGrouped includes cited priced + quote-gated competitors
  PASS  alternativesGrouped enforces the honesty invariant (drops empty-source + inactive)
  PASS  alternativeBySlug(priced) -> product + break-even ceil(4900/6200)=1
  PASS  alternativeBySlug(quote-gated) -> no break-even (null monthly)
  PASS  alternativeBySlug(missing) -> null
  PASS  alternatives page ESCAPES the competitor name (no raw script)
  PASS  alternatives page renders break-even + the cited source link, singular
  PASS  alternatives page never renders the "1 months" grammar defect
  PASS  alternatives page cross-links the product + its /vs page
  PASS  alternatives page for an uncited competitor -> 404 (never surfaces)
  PASS  alternatives index lists cited, hides uncited

== 18c. /self-hosted — category & use-case landing pages ==
  PASS  selfhostedPages: 28 pages (>= the 14 shipped 2026-08), each fully formed (slug/label/h1/intro/slugs)
  PASS  selfhostedPages h1s carry the category keyword phrase
  PASS  productsBySlugs: active products only, with min price from editions
  PASS  productsBySlugs preserves caller order + drops unknowns
  PASS  selfhostedBySlug(unknown) -> null
  PASS  selfhostedPagesFor maps a product to its landing pages (pure map, no DB)
  PASS  /self-hosted hub renders with h1 + alternatives cross-link
  PASS  /self-hosted hub carries canonical + ItemList JSON-LD
  PASS  category page with no live products -> 404 (honesty: never an empty landing page)
  PASS  sitemap includes the /self-hosted hub + all 28 landing pages

== 18d. price-recheck — honest freshness (dates only, never prices) ==
  PASS  normalize folds tags/entities/unicode-dashes/whitespace/case
  PASS  quoteFragments splits the " ... " citation separator
  PASS  quoteStillPresent needs EVERY fragment (normalized match)
  PASS  recheck dry-run: 2 cited rows, 1 confirmed, 1 stale, dates untouched
  PASS  recheck --apply: confirmed row date bumped, stale row untouched, report stored
  PASS  recheck fetch-failure: rows counted, nothing flagged stale, no date moves (blocked vendor is not evidence)
  PASS  recheck reports a cited vendor that now answers on a different host
  PASS  recheck reports NO move when every citation lands where it pointed
  PASS  a moved citation is reported, never rewritten (the stored URL is untouched)
  PASS  OW3-TAIL: an inline "<" in a script no longer swallows the rest of the page
  PASS  OW3-TAIL: script and style content is not itself searchable (a price in a script is not on the page)
  PASS  OW3-TAIL: a comment is not searchable either (a withdrawn price left in the markup is not published)
  PASS  OW3-TAIL: unreadable() discriminates — well-formed page no, swallowed page yes, tiny page no
  PASS  OW3-TAIL: a page the matcher cannot read through is reported unreadable, NOT stale, and no date moves
  PASS  OW3-TAIL: finding the quote still confirms, even on a page that is otherwise unreadable
  PASS  B7b checks skipped (dist zip build - bin/quote-audit.php is a store-only tool)

== 19. sitemap + robots ==
  PASS  sitemap is valid XML (simplexml parses)
  PASS  sitemap contains home + landing + vs URLs and EXCLUDES the private /portal (GSC 2026-07-29)
  PASS  sitemap entries carry lastmod
  PASS  sitemap includes the /alternatives hub + a cited competitor page
  PASS  inactive product excluded from sitemap
  PASS  vs URL only for products WITH citable rows (+ /vs/cloud, only where this store hosts something)
  PASS  robots.txt allows all + sitemap line

== 20. savings-calculator JS (source-level) ==
  PASS  vs-calc.js exists, is vanilla JS (no PHP, no frameworks)
  PASS  vs-calc.js writes via textContent ONLY (no innerHTML injection surface)
  PASS  vs-calc.js braces/parens balanced
  PASS  node not available in this container - syntax proven by balanced-brace check above (node --check run in CI/dev)

== 21. admin guards: csrf + validation ==
  PASS  admin_landing_save requires login + csrf_check
  PASS  admin_comparison_create requires login + csrf_check
  PASS  admin_comparison_update requires login + csrf_check
  PASS  admin_comparison_delete requires login + csrf_check
  PASS  settings save validates site_url + demo_base schemes
  PASS  comparison input: EMPTY source_url rejected with friendly error
  PASS  comparison input: non-http source rejected
  PASS  comparison input: valid row passes, blank cents -> NULL
  PASS  comparison input: clip to column widths (190/300/500)
  PASS  comparison input: cents clamped inside INT (got 2000000000, want 2000000000)
  PASS  landing input: broken features JSON -> friendly error
  PASS  landing input: FAQ missing answer -> friendly error
  PASS  landing input: bad demo URL -> friendly error
  PASS  landing input: valid payload passes; 150KB pain_md survives clip(200000)

== 22. bundles schema migration (additive + idempotent) ==
  PASS  install() left bundles + bundle_items + order_items.bundle_id present
  PASS  pre-bundles shape reproduced
  PASS  migrate() restores bundles + bundle_items + order_items.bundle_id
  PASS  bundles migration keeps every order_items row (got 3, want 3)
  PASS  bundles migrate() is idempotent (re-runs are no-ops, no throw)

== 23. suite fulfillment: 1 checkout -> N licenses + N downloads + 1 order ==
  PASS  suite SKU namespace parses (got 'toolkit', want 'toolkit')
  PASS  non-suite SKUs stay out of the namespace
  PASS  components = each product's PRIMARY (cheapest) edition, in sort order
  PASS  suite webhook -> 200 (got 200, want 200)
  PASS  suite expands: 2 components fulfilled (got 2, want 2)
  PASS  ONE order, paid, at the SUITE price
  PASS  2 order items, each tagged with the bundle (suite badge source)
  PASS  suite items carry the PRODUCT sku (not the suite sku)
  PASS  2 licenses issued under the one order (got 2, want 2)
  PASS  each suite license is a real signed key that verifies
  PASS  suite licenses work with the license API per PRODUCT sku
  PASS  2 download tokens issued under the one order (got 2, want 2)
  PASS  ONE outbox email lists EVERY included product
  PASS  …and never a second license key, for any product in the suite
  PASS  …and each product's download link
  PASS  …and names the suite
  PASS  DUPLICATE suite webhook -> 200 + duplicate flag
  PASS  duplicate suite: identical state (orders/items/licenses/downloads/outbox unchanged) (got array (
  0 => 4,
  1 => 5,
  2 => 6,
  3 => 7,
  4 => 6,
), want array (
  0 => 4,
  1 => 5,
  2 => 6,
  3 => 7,
  4 => 6,
))
  PASS  suite refund -> 200 (got 200, want 200)
  PASS  suite order marked refunded (got 'refunded', want 'refunded')
  PASS  refund revokes ALL N suite licenses (got 2, want 2)
  PASS  unknown suite -> 200, order recorded, 0 fulfilled
  PASS  mixed order fulfills suite components + the plain item (3 licenses)
  PASS  mixed order: plain item stays untagged, suite items tagged

== 24. suite savings math is computed LIVE (never hardcoded) ==
  PASS  suiteTotals: sum of members' single prices (4900+5900) (got 10800, want 10800)
  PASS  suiteTotals: savings vs the 8900 suite price (got 1900, want 1900)
  PASS  suiteTotals: member count (got 2, want 2)
  PASS  …and the suite math CHANGES on the next read (data layer)
  PASS  suite page renders the LIVE figures ($128.00 separately, save $39.00)
  PASS  price restored -> page recomputes ($108.00 separately, save $19.00)

== 24b. cross-sell band: best suite per member product, live math, ONE band ==
  PASS  bestSuiteFor: member product -> its active suite with LIVE totals attached
  PASS  bestSuiteFor: product in NO active bundle -> null (no band data)
  PASS  member landing renders the band: BETTER TOGETHER eyebrow + names + live figures
  PASS  band is link-only: "See the suite" -> /s/{slug}; NO buy CTA inside the band
  PASS  …and the band math CHANGES at the data layer (sum 12800, save 3900 — never stored)
  PASS  …and the rendered band recomputes on the next request ($128.00 separately — save $39.00)
  PASS  product in TWO active suites -> the bigger absolute saving wins (2900 > 1900)
  PASS  multi-suite member renders a band for EVERY topic suite it belongs to, best saving FIRST (contract changed 2026-08-30: one band hid the Front-Desk Suite from half its members)
  PASS  deactivated bundle drops out of the ranking (band falls back to the active suite)

== 25. suite landing page (XSS + JSON-LD + structure) ==
  PASS  suite page renders (storefront chrome)
  PASS  NO raw <script>alert survives ANY injected bundle field
  PASS  suite name escaped in h1
  PASS  exactly one h1 on the suite page (got 1, want 1)
  PASS  per-product cards link to their /p/ pages
  PASS  combined honest-limitations note carries the members' limitations (escaped)
  PASS  suite FAQ present (details/summary, no JS)
  PASS  Buy CTA points at the suite checkout path
  PASS  suite JSON-LD decodes to schema.org Product
  PASS  suite JSON-LD Offer price = the BUNDLE price
  PASS  suite JSON-LD url is the canonical /s/ page
  PASS  deactivated suite -> 404 page (nothing leaks)

== 26. storefront suites strip + sitemap ==
  PASS  home renders the Suites strip with the suite (escaped) + live savings
  PASS  strip links to the /s/ landing page
  PASS  sitemap includes the /s/ suite pages
  PASS  sitemap is still valid XML with suites
  PASS  deactivated suite drops out of the sitemap
  PASS  deactivated suite drops off the home strip too

== 27. bundles admin: input clip/clamp + csrf + immutable slug ==
  PASS  bundle input: valid payload passes
  PASS  bundle slug sanitized to SKU-safe form (got 'my-suite', want 'my-suite')
  PASS  bundle input clipped to column widths (190/255/190/300/8)
  PASS  bundle price clamped inside INT (got 2000000000, want 2000000000)
  PASS  bundle items: only REAL product ids survive; sort clamped
  PASS  bundle input: no real products -> friendly error; missing slug -> error
  PASS  admin_bundle_create requires login + csrf_check
  PASS  admin_bundle_update requires login + csrf_check
  PASS  admin_bundle_delete requires login + csrf_check
  PASS  suite slug is IMMUTABLE on update (input without slug; UPDATE never touches slug)
  PASS  sold suites are protected from deletion (deactivate instead)

== 28. ownware.io storefront seed (dev tree only - excluded from dist) ==
  PASS  ownware-seed.php absent (dist zip build) - seed suite runs in the dev tree

== OW4-2 RIBBON (2026-09-14): the demo ribbon sells both ladders ==
  PASS  RIBBON checks skipped (dist zip build - store_instance.php absent)
  PASS  SAAS KIT LICENSE checks skipped (dist zip build - store_instance.php absent)
  PASS  KIT CATALOG checks skipped (dist zip build - store_instance.php absent)
  PASS  RELEASE GUARD checks skipped (dist zip build - store_instance.php absent)

== FIXLCP (2026-09-13): the banner AVIF, the route, the markup, the gate's warm-up ==
  PASS  FIXLCP checks skipped (dist zip build - store_instance.php absent)

== T6 unit 3 (2026-08-31): the answer to github.com, on the 11 gap pages + 14 hubs ==
  PASS  repo-answer coverage skipped (dist zip build - store_instance.php absent)
  PASS  llms.txt alternative-to coverage skipped (dist zip build)

== C7D (2026-09-07): derived prompt-map FAQs, 24 products ==
  PASS  C7D FAQ checks skipped (dist zip build - store_instance.php absent)

== 29. presentation: theme accents + shipped gallery ==
  PASS  install() left theme_color + images_json present
  PASS  pre-presentation shape reproduced
  PASS  migrate() restores theme_color + images_json (additive, data kept)
  PASS  presentation migrate() is idempotent (re-runs are no-ops, no throw)
  PASS  themeColor: valid hex normalized lowercase (got '#21b5cb', want '#21b5cb')
  PASS  themeColor: 3-hex / no-# / css-injection / attr-breakout / null all rejected
  PASS  themeStyle: invalid input -> empty string (no style attribute emitted) (got '', want '')
  PASS  themeStyle: exactly four derived accent vars, ALL locked to 6-hex values
  PASS  themeBlend midpoint math (got '#808080', want '#808080')
  PASS  storeImages: ONLY the existing whitelisted image survives (missing / traversal / .php dropped)
  PASS  storeImages: garbage JSON -> empty manifest (got array (
  'banner' => NULL,
  'shots' => 
  array (
  ),
), want array (
  'banner' => NULL,
  'shots' => 
  array (
  ),
))
  PASS  shotPath maps the manifest path onto the nosniff route (got 'shot/zz-test-probe/shot-01.jpg', want 'shot/zz-test-probe/shot-01.jpg')
  PASS  shotPath stamps a real image with its mtime — SKIP (dist zip: a store-instance fact)
  PASS  landing scopes the VALIDATED accent inline
  PASS  gallery renders ONLY the existing file via /shot/ (missing + traversal entries absent)
  PASS  demo button hidden when demo_url empty and no demo_base template
  PASS  INVALID stored theme_color never reaches the page (no accent vars, no attribute breakout)
  PASS  demo button reappears via the demo_base template (tracked /demo/ redirect)
  PASS  store_asset_serve: nosniff + realpath jail + whitelisted mime (source level)

== 30. launch-mode flags: coming-soon gating + notify waitlist ==
  PASS  pre-flags shape reproduced
  PASS  migrate() restores demos_live + sales_live + notify_signups
  PASS  flags migrate() is idempotent (re-runs are no-ops, no throw)

== notify double opt-in ==
  PASS  signup mints a 48-hex confirm token
  PASS  row starts UNconfirmed
  PASS  confirm by token succeeds
  PASS  row is now confirmed
  PASS  re-click is idempotent (still true, timestamp unchanged)
  PASS  garbage token rejected
  PASS  duplicate signup keeps the ORIGINAL token (no token churn)
  PASS  pre-opt-in row (empty token) gets a token minted on re-signup
  PASS  confirm route is registered
  PASS  welcome mail carries the confirm link

== deal-code redemption ==
  PASS  gate: /redeem is OFF while no codes exist
  PASS  normalize: lowercase/spaces/junk collapse to canonical code
  PASS  gate: codes seeded switches /redeem ON
  PASS  unknown code refused
  PASS  invalid email refused
  PASS  unfulfillable sku refused as config (code NOT burned)
  PASS  valid redeem succeeds via normalized input (lowercase in, RD- ref out)
  PASS  redeem created a paid $0 appsumo order
  PASS  redeem issued exactly one license + one download
  PASS  code row marked redeemed with buyer email + order id
  PASS  same code + same email re-surfaces the SAME order (idempotent, no new fulfillment)
  PASS  same code + DIFFERENT email refused
  PASS  redeem routes registered
  PASS  follow-up: fresh redemption (today) is NOT yet eligible
  PASS  follow-up: 8-day-old redemption IS eligible exactly once
  PASS  follow-up: refunded order is excluded (never ask a refunder for a review)
  PASS  follow-up: marked rows never re-send
  PASS  follow-up: script ships + sends exactly one mail, no drip
  PASS  gate: back OFF after cleanup (buyer stores never expose /redeem by accident)

== Own It 2.0 ==
  PASS  v2: tables + columns migrated
  PASS  v2: mintKey stores only the hash; revocation kills lookup
  PASS  v2: TOTP verify + drift + recovery consume-once
  PASS  v2: outbound webhook delivery logged with failure detail
  PASS  v2: routes registered (owner api, 2fa, security, backup, import, healthz)
  PASS  v2: login intercepted (2FA + rate limit) and fulfillment fires owner events
  PASS  v2: admin dark mode scoped away from the storefront + theme assets ship
  PASS  restored flag columns default OFF (got array (
  0 => 0,
  1 => 0,
), want array (
  0 => 0,
  1 => 0,
))
  PASS  home OFF: "Demo — coming soon" chips + ZERO internal /demo/ hrefs; grid never sells (no Codester links, cards funnel through "View details")
  PASS  home OFF (dist zip build): no external Try-demo links leak into a buyer store
  PASS  vendra landing checks skipped (dist zip build - ownware-seed.php absent)
  PASS  landing OFF: demo chip present, NO /demo/ href anywhere
  PASS  landing OFF: hero shows "Available soon — get notified", no Buy/own-it CTAs
  PASS  landing OFF: notify form carries csrf + honeypot + the product slug
  PASS  cross-sell band under sales_live=0: still renders, links ONLY the (gated) suite page
  PASS  vs page OFF: comparisons + citations + calculator STILL render
  PASS  vs page OFF: Buy CTAs replaced by get-notified, demo link replaced by chip
  PASS  suite page OFF: live savings math kept, /buy-suite/ gone, notify form with suite slug
  PASS  /demo/{slug} with demos_live=0 -> 404 page, NO redirect
  PASS  …and NO click row was recorded (got 7, want 7)
  PASS  /buy/{id} with sales_live=0 -> 404 page (no provider hand-off)
  PASS  /checkout/demo/{id} gated -> 404 (fake fulfillment unreachable from public UI)
  PASS  /buy-suite/{id} + /checkout/suite/{id} gated -> 404
  PASS  ALL six checkout entry handlers call the sales gate FIRST (source level)
  PASS  webhook endpoint is NOT gated by the launch flags (existing buyers/refunds)
  PASS  notifySignup stores a validated row (lowercased email)
  PASS  duplicate signup is a silent success and stays ONE row (idempotent)
  PASS  same email may follow a DIFFERENT product / the whole store
  PASS  invalid email rejected, nothing stored
  PASS  product_slug sanitized to SKU alphabet + clipped to 80
  PASS  notify endpoint: csrf + per-IP rate limit + honeypot drop (source level)
  PASS  notify CSV export goes through the guarded _csv_out/toCsv path
  PASS  notify export cells stay formula-guarded (numeric-aware)
  PASS  flags ON + wired edition: demo link + Buy button + own-it-once hero restored, notify UI gone
  PASS  home ON: live Try-demo links return, chips gone
  PASS  suite ON: Buy-the-suite CTA back, notify form gone
  PASS  payment webhook fulfills normally under both flags OFF
  PASS  /license/check unaffected by the flags
  PASS  buyer re-download still serves under both flags OFF (got 200, want 200)
  PASS  buyer portal still lists the order, key, and download link under both flags OFF

== 31. storefront narrative, title dedupe, favicon, editorial skin scope ==
  PASS  home <title> is the full SEO title, store name once (no "Store · Store")
  PASS  non-home pages keep "Page · Store" titles
  PASS  product pages keep their SEO title (name — tagline)
  PASS  assets/favicon.png ships (non-empty PNG)
  PASS  storefront layout links rel=icon + apple-touch-icon
  PASS  admin layout links the favicon too (source level)
  PASS  home narrative: hero → #idea → #compare → #pillars → #complete → #catalog → grid, in order
  PASS  rent-vs-own comparison renders (both columns + the illustrative range, no invented stats)
  PASS  three self-hosted pillars render
  PASS  storefront layout links store.css + carries body.storefront
  PASS  Signal CSS carries the approved token set (light-first + dark) and the legacy aliases read it
  PASS  editorial CSS: self-hosted fonts (no hotlink), mono accents, tag.soon chip, reduced-motion
  PASS  no glass surfaces outside the flagship block: backdrop blur ONLY on the sticky header; no feTurbulence; the glow radial is bounded
  PASS  the flagship exemption is one marked block, present once, and the only place feTurbulence appears
  PASS  no admin or data-grid selectors in store.css
  PASS  home hero is the Signal hero (sig-hero + Machine SVG + rack), no glass class anywhere
  PASS  the rack degrades on a buyer store (few/no shots): the section still renders without error
  PASS  Signal brand: wordmark link in the header, brand block in the footer columns, no blue dot
  PASS  admin stylesheet + admin layout stay skin-free (no backdrop-filter, store.css not linked)
  PASS  portal + admin views carry no glass class and no backdrop-filter
  PASS  storefront views carry no glass class either (editorial system replaced it)

== 32. guides engine: migrate, CRUD guards, published-only, XSS, links, JSON-LD, sitemap, nav ==
  PASS  install() left the guides table present
  PASS  legacy shape reproduced (guides table gone)
  PASS  migrate() restores the guides table with the full column set
  PASS  guides migration is idempotent (re-runs are no-ops, no throw)
  PASS  guide input: slug sanitized, title→190, description→300, body→200000, published=1
  PASS  guide input: unchecked publish box stays a draft (0) (got 0, want 0)
  PASS  admin_guide_create requires login + csrf_check
  PASS  admin_guide_update requires login + csrf_check
  PASS  admin_guide_delete requires login + csrf_check
  PASS  guides index: published guide listed (escaped), draft invisible
  PASS  guides index: canonical + storefront chrome
  PASS  draft guide page -> 404 view, nothing leaks
  PASS  guide body renders escape-first: NO script/onerror survives any field
  PASS  markdown structure renders: # -> h2, ## -> h3, bold, list
  PASS  exactly one h1 on a guide page (got 1, want 1)
  PASS  internal /p link renders as an anchor; external https link carries rel=noopener
  PASS  javascript: scheme is REFUSED — stays literal escaped text, never an href
  PASS  JSON-LD Article decodes with headline + canonical url + dates
  PASS  related band: ACTIVE linked product gets a pill; inactive linked product does not
  PASS  guide page ends with the store CTA block
  PASS  storefront nav + footer both link the guides index
  PASS  sitemap lists /guides + the published guide, never the draft
  PASS  sitemap is still valid XML with guides
  PASS  unpublishing 404s the page AND removes it from the sitemap
  PASS  with zero published guides the /guides index drops out of the sitemap too

== PWA ==
  PASS  pwaManifest() returns the required fields
  PASS  manifest name follows store_name setting (falls back to Deliora)
  PASS  manifest URLs are RELATIVE (subpath-install safe)
  PASS  manifest icons exist on disk (192 + 512 + apple-touch)
  PASS  sw.js never caches non-GET
  PASS  sw.js never caches page navigations
  PASS  sw.js cache versioned + purges old caches
  PASS  offline fallback ships
  PASS  both layouts (admin + store) link the manifest + register the SW
  PASS  the /manifest.json ROUTE is registered
  PASS  bin/demo.php loads StoreApi + OutWebhook (Deliora.php calls both)
  PASS  bin/flush-outbox.php loads StoreApi + OutWebhook (Deliora.php calls both)
  PASS  bin/redeem-codes.php loads StoreApi + OutWebhook (Deliora.php calls both)
  PASS  bin/review-followup.php loads StoreApi + OutWebhook (Deliora.php calls both)

== v3 ==
  PASS  v3: dropped v3 tables restored by migrate()
  PASS  v3: role/active/branding/token/oidc columns present
  PASS  v3: settings singleton ensured with EXPLICIT id=1
  PASS  v3: index.php migrates at boot
  PASS  v3: reference copy absent (buyer machine) — byte-identity skipped
  PASS  W16-8: the dev tree runs the REAL byte-identity branch, never the buyer-zip skip
  PASS  v3: healthz/ping/openapi all read App::VERSION (one source)
  PASS  v3: sw cache names the code version
  PASS  v3: README has New in 3.0 + API.md has the MCP section
  PASS  v3: backup redaction widened + shared (export uses DELIORA_BACKUP_REDACT, scheduled too)
  PASS  v3: GET /mcp transport-hint route registered
  PASS  v3: requireKey checks u.active (deactivated users keys die)
  PASS  v3: deploy kit filled (deliora/acme/8090, no placeholders)
  PASS  v3: kit healthcheck is the json_decode probe with $$ escape
  PASS  v3: mint core refuses unknown sku by name
  PASS  mint rule: an admin key may mint codes
  PASS  mint rule: a support key may not mint codes
  PASS  mint rule: a key row with no role is a pre-roles admin key, as the MCP door always read it
  PASS  POST /api/redeem-codes checks the mint rule before it mints
  PASS  the MCP mint tool uses the same rule
  PASS  v3: invite token verifies + expiry honored
  PASS  v3: last-admin guard math
  PASS  nav: every sidebar link is highlighted by the page it opens
  PASS  nav: no page highlights a sidebar item that does not exist
  PASS  ip: a direct visitor's forged headers are ignored
  PASS  ip: behind a trusted proxy the forwarded visitor is used
  PASS  ip: CF-Connecting-IP wins over the X-Forwarded-For chain
  PASS  ip: the chain is read right-to-left, past trusted hops
  PASS  ip: a Cloudflare edge is not a trusted proxy by default
  PASS  ip: CIDR maths (v4 boundaries + v6, no cross-family match)
  PASS  urls: a cron never derives a base path from its own script name
  PASS  urls: a cron-built URL keeps the host and path separate
  PASS  urls: a cron uses the canonical site_url when one is configured
  PASS  urls: cron-built download links are absolute and correct
  PASS  urls: a malformed site_url is ignored rather than emitted
  PASS  urls: an ordinary web request is unaffected

== UI kit v1.0 (2026-08-19): admin on the kit, resend delivery, dashboard ==
  PASS  resend: fixture order fulfilled
  PASS  resend: a paid order re-sends its delivery email (outbox +1) with a fresh download link and no second license key
  PASS  resend: an expired download link is re-minted (a usable token exists again)
  PASS  resend: a refunded order is refused with a sentence
  PASS  resend: unknown order is refused
  PASS  routes: POST admin/orders/{id}/resend registered → admin_order_resend (csrf + flash + redirect)
  PASS  kit: admin layout loads kit css+js + admin.css, grouped nav (≥4 groups), theme toggle, who-line; storefront layout untouched (style.css + store.css, no kit)
  PASS  kit: admin.css is an app layer with light + dark accent; no theme.js; versioned assets; revalidating worker; bundled fonts only
  PASS  dashboard: first-run checklist from the database (editions / files / provider / mail / orders), needs-action for unfulfilled orders, 30-day revenue sparkline
  PASS  order record: rail (customer, emails, portal), license cards with reissue/revoke/free-seat, usable/expired download rows, resend button
  PASS  licenses list: table with search + status counts; products/customers lists link into records

== v3.1: promo pass-through ==
  PASS  promo: the LS checkout URL carries the provider discount code (never repriced on-box)
  PASS  promo: an invalid code is IGNORED, never emitted
  PASS  promo: demo checkout stays untouched (no provider = no code)
  PASS  promo: settings save validates the code alphabet and the product page shows the banner only when set
  PASS  scope: api_keys gains a scope column defaulting to full — an upgrade never loosens
  PASS  scope: requireKey reads the key scope and refuses a write on a read-only key
  PASS  scope: mintKey persists the chosen scope, and anything but read is full access
  PASS  scope: /mcp hands the key scope to the core, which refuses write tools per tool
  PASS  scope: the key form lets an administrator mint a read-only key

== nexura unlisted (operator ruling 2026-08-24) ==

Warning: file_get_contents(tests/../bin/ownware-store.php): Failed to open stream: No such file or directory in tests/run.php on line 2664

Warning: file_get_contents(tests/../bin/ownware-seed.php): Failed to open stream: No such file or directory in tests/run.php on line 2665

Warning: file_get_contents(tests/../tests/store_instance.php): Failed to open stream: No such file or directory in tests/run.php on line 2666
  PASS  nexura: in the curation deactivation list (deploy runs curation after every seed) — SKIP (dist zip: a store-instance fact)
  PASS  nexura: NOT in any suite seed (was Compliance Suite; suite ships 6 tools now)
  PASS  nexura: test instance mirrors curation (nexura-only) before active-state invariants — SKIP (dist zip: a store-instance fact)

== /pricing: the page AI agents were guessing at (2026-08-24) ==
  PASS  pricing: route + controller exist and every figure is read from the DB (no typed prices)
  PASS  pricing: every guessed 404 spelling now lands — /plans, /plan, /prices, /plans-pricing, /pricing-plans
  PASS  pricing: per-product guesses land on the product page, unknown slugs on /pricing
  PASS  pricing: unwired products say "Available soon", never a stale price (same rule as llms.txt)
  PASS  pricing: in the sitemap and named in llms.txt Reference

== citable Observatory + guide capture (2026-08-23) ==
  PASS  observatory.csv: route registered and the exporter neutralizes leading =+-@ (external vendor text)
  PASS  observatory page: quote-gated means the price TEXT says so, never a missing normalized figure (the 7shifts CAD trap)
  PASS  observatory page: copy-paste citation + CSV and JSON download links
  PASS  sitemap: observatory entry carries lastmod = newest verified date, not an undated URL
  PASS  llms.txt: names the CSV download beside the JSON API
  PASS  guide capture: form posts to /notify with guide-<slug> tag, honeypot intact, csrf present
  PASS  notify handler: guide-<slug> errors bounce back to the guide, published-only

== conversion pass P2-P4 (2026-08-23) ==
  PASS  trust block: refund, tests-in-box and continuity lines at the buy button, source line stack-aware (the one product not written in PHP gets its own line)
  PASS  ladder line: Cloud names this product at its own price from cloudOffers(), with the 12-month license fact
  PASS  listedon partial: shared, honors a caller skip list, and reads it from legal.local
  PASS  post-Codester rule: this deployment excludes Codester on product pages — SKIP (dist zip: a store-instance fact)
  PASS  listedon partial: no directory name is hard-coded in the shipped view
  PASS  listedon callers: both includes are guarded (the partial is not in a buyer zip)
  PASS  suites hero: no suite slug is hard-coded in the shipped view
  PASS  suites hero: a store naming both gets hero + alternative
  PASS  suites hero: a store naming only the first gets a hero and no alternative
  PASS  suites hero: a buyer store naming none gets no hero — every suite falls to the list
  PASS  suites hero: THIS deployment still names its two suites — SKIP (dist zip: a store-instance fact)
  PASS  /security: no deployment fact is hard-coded in the shipped view
  PASS  /security: all three deployment facts read from legal.local
  PASS  /security: each fallback tells the operator which key to set, and claims nothing
  PASS  /security: THIS deployment still states all three — SKIP (dist zip: a store-instance fact)
  PASS  source-layout details: read-before-you-buy tree, skipped for the one product not written in PHP

== undeliverable editions (2026-08-30: a paid product whose edition had no file) ==
  PASS  an edition with no file at all is reported
  PASS  an edition naming a file that is not on disk is reported too — the same 404 to a buyer
  PASS  an edition whose file is present is NOT reported
  PASS  an INACTIVE product is not reported — nobody can buy it
  PASS  the setup checklist is green only when EVERY sellable edition can be delivered
  PASS  the dashboard warning is fed by the same check as the checklist

== /suites, /contact, /p/<x>-suite, bare /<slug>: what the first buyer's AI agent 404'd on (2026-08-30) ==
  PASS  routes registered: /suites, /bundles, /contact, /products, /apps
  PASS  bare /{slug} is the LAST GET route registered — every explicit route above it still wins
  PASS  aliases are 301s (permanent addresses, not temporary detours)
  PASS  /suites is in the sitemap and named in llms.txt Reference
  PASS  /suites view carries no typed price or saving — every figure comes from suiteTotals()
  PASS  /support carries the #contact anchor /contact redirects to
  PASS  /privacy, /terms, /refunds redirect into /legal at anchors the page really has
  PASS  /openapi.json redirects to the spec route that exists (/api/openapi.json)
  PASS  the four aliases are registered before the bare-slug catch-all
  PASS  bare /<product-slug> resolves to its /p/ page (got '/p/gw-probe', want '/p/gw-probe')
  PASS  bare /<suite-slug> resolves to its /s/ page (got '/s/gw-field', want '/s/gw-field')
  PASS  bare /<suite>-suite resolves to the suite (the agent guessed /p/field-service-suite) (got '/s/gw-field', want '/s/gw-field')
  PASS  suite_slug_for strips a -suite suffix and a suite- prefix (got 'gw-field', want 'gw-field')
  PASS  an unknown bare slug resolves to nothing (ordinary 404, no guessing)
  PASS  a slug with path characters never resolves (no open redirect)
  PASS  uppercase is tolerated (agents and humans type it)
  PASS  /suites lists the probe suite with its /s/ link and its member product
  PASS  /suites shows the live saving for the probe suite ($59 parts vs $49 suite = $10)
  PASS  an INACTIVE product no longer resolves from its bare slug
  PASS  an INACTIVE suite no longer resolves (neither bare nor -suite form)

== suites: every topic suite on the product page, and the suite page answers what the buyer asked (2026-08-30) ==
  PASS  suitesFor returns BOTH topic suites of a dual-membership product (got 2, want 2)
  PASS  suitesFor orders by live saving, best first ($59-$39 beats $59-$49) (got 'gs-beta', want 'gs-beta')
  PASS  the product page names BOTH suites (was: only bestSuiteFor, the second silently dropped)
  PASS  the suite page states what runs it (PHP 8, MySQL/SQLite) — the buyer question it did not answer
  PASS  the suite page states data ownership and source (never phones home, full source)
  PASS  the suite page carries the two FAQ answers the first buyer needed (already own one / start with one)
  PASS  suite page: a per-member demo link renders only through the same demosLive gate as the product page

== typed prices that drifted from the store (found by the growth wave, 2026-08-30) ==

Warning: file_get_contents(tests/../bin/ownware-seed.php): Failed to open stream: No such file or directory in tests/run.php on line 2960
  PASS  the Ownware OS guide no longer sells the OS at $499 or $999 (it is $1,299) nor claims 37 apps
  PASS  the whole-catalog bundle copy carries no typed "$499 + $249 = $699" arithmetic
  PASS  llms.txt derives the Observatory vendor count from the DB (it read "~74" while the dataset held 110)
  PASS  QuotaPath is cited with its platform fee ($525/month governs for five users or fewer), not $105 for three seats — SKIP (dist zip: a store-instance fact)
  PASS  Qminder is cited at its month-to-month price ($869) with the annual figure beside it, not the annual-equivalent as monthly — SKIP (dist zip: a store-instance fact)

== typed catalog counts (the OS guide's 37/38 were not alone — sweep 2026-08-30) ==

Warning: file_get_contents(tests/../bin/ownware-seed.php): Failed to open stream: No such file or directory in tests/run.php on line 2981
  PASS  the OS guide no longer claims one of the apps is free (Tokora is $69) nor counts 38
  PASS  the 3.1 release notes no longer say "across all 37"
  PASS  ruling 5: the affiliates page is absent from this build and /affiliates answers 404
  PASS  /go/cloud: a store that is not ownware.io answers 404 (no hop, no script)
  PASS  /go/cloud: the hop page is absent from this build and the route answers 404 even as the flagship
  PASS  robots.txt keeps the referral hop out of search (Disallow: /go/)

== short product names in buttons and running text (2026-08-30) ==
  PASS  shortName cuts at the em-dash (got 'Fixora', want 'Fixora')
  PASS  shortName cuts at the en-dash (got 'Examplia', want 'Examplia')
  PASS  shortName cuts at a colon (got 'Approva', want 'Approva')
  PASS  shortName leaves a plain name alone (got 'Menura', want 'Menura')
  PASS  alternatives page: the CTA and running text use the short name, the product list keeps the full one

== L1.3 + L5.20 (2026-08-31): gap pages carry the buyer's phrase; llms Optional; press traction block; derived press desc ==

Warning: file_get_contents(tests/../bin/ownware-seed.php): Failed to open stream: No such file or directory in tests/run.php on line 3124

Warning: file_get_contents(tests/../bin/ownware-seed-w3.php): Failed to open stream: No such file or directory in tests/run.php on line 3124
  PASS  gap page opens with the buyer's phrase: self-hosted invoicing software — one time payment — SKIP (dist zip: a store-instance fact)
  PASS  gap page opens with the buyer's phrase: self-hosted CMMS maintenance software you buy once — SKIP (dist zip: a store-instance fact)
  PASS  gap page opens with the buyer's phrase: self-hosted fixed asset depreciation register — SKIP (dist zip: a store-instance fact)
  PASS  gap page opens with the buyer's phrase: self-hosted purchase order approval system — SKIP (dist zip: a store-instance fact)
  PASS  gap page opens with the buyer's phrase: Self-hosted review request automation — SKIP (dist zip: a store-instance fact)
  PASS  gap page opens with the buyer's phrase: self-hosted visitor sign-in system for reception — SKIP (dist zip: a store-instance fact)
  PASS  gap page opens with the buyer's phrase: self-hosted helpdesk request tracker, small business — SKIP (dist zip: a store-instance fact)
  PASS  llms.txt has an Optional section holding the guides, after Reference
  PASS  press: SEO description is derived (no typed "37" or "$34–$129")
  PASS  press checks skipped (dist zip build - views/press.php is ownware.io's own, not shipped)

== L1.4 (2026-08-31): agent-readiness surfaces — /status /changelog /security /docs /for-agents /llms-full.txt ==
  PASS  route /status → status_page registered
  PASS  route /changelog → changelog_page registered
  PASS  route /security → security_page_public registered
  PASS  route /docs → docs_page registered
  PASS  route /for-agents → for_agents_page registered
  PASS  /status renders the live checks and the catalog counts
  PASS  /changelog lists current versions read from editions
  PASS  /security names the report address and the merchant-of-record fact
  PASS  /docs and /for-agents share one surface list (llms.txt, openapi, observatory present on both)
  PASS  /for-agents documents the guessed addresses and how to verify
  PASS  llms-full.txt carries one section per active product with its URL and price
  PASS  view status.php carries no typed dollar figure
  PASS  view changelog.php carries no typed dollar figure
  PASS  view docs.php carries no typed dollar figure
  PASS  view for_agents.php carries no typed dollar figure
  PASS  view repo_answer.php carries no typed dollar figure
  PASS  the six agent pages are in the sitemap and llms.txt Reference names all six surfaces

== F19 SHIP (2026-09-13): the commerce door is advertised only where it is routed ==
  PASS  flag off: llms.txt advertises no /agent/v1 address
  PASS  flag off: the agent card carries no commerce endpoint
  PASS  flag off: the card note does not name a door this install has not opened
  PASS  flag off: the card still parses and still carries its open surfaces
  PASS  the llms.txt door line exists and is gated on DELIORA_AGENT_DOOR
  PASS  the card endpoint and the buying note are gated on the same constant
  PASS  the card drops a gated key rather than printing a null (array_filter on endpoints and buying)
  PASS  the fourteen routes are still inside the flag, not beside it

== Release package 2 (2026-09-02): /badge + /your-ai-can-vet-us ==
  PASS  /badge renders through the public layout with its canonical
  PASS  /your-ai-can-vet-us renders through the public layout with its canonical
  PASS  badge asset runs-on-ownware.svg exists and is well-formed SVG — SKIP (dist zip: a store-instance fact)
  PASS  badge asset runs-on-ownware.svg carries no script, no external reference, no comment — SKIP (dist zip: a store-instance fact)
  PASS  /badge shows runs-on-ownware.svg and its embed code linking to https://ownware.io — SKIP (dist zip: a store-instance fact)
  PASS  badge asset runs-on-ownware-dark.svg exists and is well-formed SVG — SKIP (dist zip: a store-instance fact)
  PASS  badge asset runs-on-ownware-dark.svg carries no script, no external reference, no comment — SKIP (dist zip: a store-instance fact)
  PASS  /badge shows runs-on-ownware-dark.svg and its embed code linking to https://ownware.io — SKIP (dist zip: a store-instance fact)
  PASS  /badge says what the mark does NOT claim (no certification, license independent) — SKIP (dist zip: a store-instance fact)
  PASS  /your-ai-can-vet-us carries the receipt '/connect-your-ai'
  PASS  /your-ai-can-vet-us carries the receipt '/legal'
  PASS  /your-ai-can-vet-us carries the receipt '/support'
  PASS  /your-ai-can-vet-us carries the receipt '/.well-known/security.txt'
  PASS  /your-ai-can-vet-us carries the receipt '/contact'
  PASS  /your-ai-can-vet-us carries the receipt '/suites'
  PASS  /your-ai-can-vet-us carries the receipt '/api/openapi.json'
  PASS  /your-ai-can-vet-us carries the receipt '/for-agents'
  PASS  /your-ai-can-vet-us carries the receipt 'POST /mcp'
  PASS  /your-ai-can-vet-us carries the receipt 'read-only'
  PASS  /your-ai-can-vet-us: the MCP receipts name a REAL demo host from the database
  PASS  view vet_us.php hard-codes no absolute host — every URL comes from $B() or $demoHost
  PASS  /your-ai-can-vet-us counts are read from the DB (tool count = active products minus the flagship, the one definition since the site survey)
  PASS  /your-ai-can-vet-us: the flagship row renders for the product legal.local names
  PASS  /your-ai-can-vet-us: with no demo host the MCP rows are skipped, not printed empty
  PASS  /your-ai-can-vet-us: with no flagship product the sealed-mode row is skipped
  PASS  /your-ai-can-vet-us: the generic receipts still render for a store with neither
  PASS  /your-ai-can-vet-us holds the customer story as an HTML comment placeholder only — no link, no slug

Warning: file_get_contents(tests/../views/badge.php): Failed to open stream: No such file or directory in tests/run.php on line 3282
  PASS  view badge.php carries no typed dollar figure
  PASS  view vet_us.php carries no typed dollar figure
  PASS  agent_surfaces() lists your-ai-can-vet-us, so /docs, /for-agents and llms.txt all carry it
  PASS  llms.txt points at /your-ai-can-vet-us
  PASS  /for-agents points at /your-ai-can-vet-us
  PASS  agent_surfaces(): one bundle reads "The bundle, its members", never "The 1 bundles"
  PASS  agent_surfaces() reads the suite count from the DB (no typed "twelve")
  PASS  ruling 5: a store that is not ownware.io lists neither our badge nor our press kit in its sitemap
  PASS  sitemap carries /badge and /your-ai-can-vet-us
  PASS  portal offers the badge once, only to buyers with purchases, with no obligation

Warning: file_get_contents(tests/../views/badge.php): Failed to open stream: No such file or directory in tests/run.php on line 3298
  PASS  the badge and vet pages use no class the other brand's stylesheet defines (fingerprint firewall)

== Release package 3 (2026-09-02): guide seed set 9 + footer/press addendum ==
  PASS  release package 3 guide checks skipped (dist zip build - store_instance.php absent)
  PASS  footer Help column carries "Your AI can vet us" on every public page
  PASS  press badge-link check skipped (dist zip build - views/press.php is ownware.io's own)

== Package 4 phase B, batch 1 (2026-09-02): back-catalogue retrofit ==
  PASS  retrofit batch-1 checks skipped (dist zip build - store_instance.php absent)

== Package 4 phase B, batch 2 (2026-09-02): back-catalogue retrofit ==
  PASS  retrofit batch-2 checks skipped (dist zip build - store_instance.php absent)

== Package 4 phase B, batch 3 (2026-09-02): back-catalogue retrofit ==
  PASS  retrofit batch-3 checks skipped (dist zip build - store_instance.php absent)

== Package 4 phase B, batch 4 (2026-09-02): back-catalogue retrofit ==
  PASS  retrofit batch-4 checks skipped (dist zip build - store_instance.php absent)

== Package 4 phase B, batch 5 (2026-09-02): back-catalogue retrofit ==
  PASS  retrofit batch-5 checks skipped (dist zip build - store_instance.php absent)

== Package 4 phase B, batch 6 (2026-09-02): back-catalogue retrofit ==
  PASS  retrofit batch-6 checks skipped (dist zip build - store_instance.php absent)

== Package 4 phase B, batch 7 (2026-09-02): back-catalogue retrofit ==
  PASS  retrofit batch-7 checks skipped (dist zip build - store_instance.php absent)

== Package 4 phase B, batches 8+9 (2026-09-02): back-catalogue retrofit, final ==
  PASS  retrofit batch-8/9 checks skipped (dist zip build - store_instance.php absent)

== Package 5 (2026-09-02): the flagship product page ==
  PASS  flagship page checks skipped (dist zip build - store_instance.php absent)

== The customer story (operator GO 2026-09-02): published under the reshaped slug ==
  PASS  customer-story checks skipped (dist zip build - store_instance.php absent)

== Doorman close-out (2026-09-03): the agent pack ==
  PASS  agent-pack checks skipped (dist zip build - store_instance.php absent)
  PASS  WS-4 pack 1 skipped (dist zip build - store_instance.php absent)
  PASS  WS-14 guards skipped (dist zip build - store_instance.php absent)

== TAIL GUIDES: every money figure in the rent-vs-own guides, asserted against the database ==
  PASS  tail-guide figure checks skipped (dist zip build - store_instance.php absent)
  PASS  FIX-F3 freshness checks skipped (dist zip build - store_instance.php absent)
  PASS  OW3B-SHIP cloud checks skipped (dist zip build - store_instance.php absent)
  PASS  OW4-11 GUIDECTA checks skipped (dist zip build - store_instance.php absent)
  PASS  C8P measured-page checks skipped (dist zip build - store_instance.php absent)
  PASS  LLMS feed checks skipped (dist zip build - store_instance.php absent)
  PASS  FAQ2 checks skipped (dist zip build - store_instance.php absent)
  PASS  DEMAND guide-set checks skipped (dist zip build - store_instance.php absent)
  PASS  ORGANIC guide-set checks skipped (dist zip build - store_instance.php absent)
  PASS  LEAVING guide-set checks skipped (dist zip build - store_instance.php absent)
  PASS  REMINDERS guide checks skipped (dist zip build - store_instance.php absent)
  PASS  F3 store-copy checks skipped (dist zip build - store_instance.php absent)

== M8 + M9 (2026-09-15): the register code and the demo capture ==

== Unit 19 (2026-09-15): the German landing, § 16 HinSchG ==
  PASS  German landing checks skipped (dist zip build - ownware.io's own page)
  PASS  M8/M9 checks skipped (dist zip build — src/Register.php absent)
  PASS  license: every font ships the SIL OFL text for its own family beside it
  PASS  license: LICENSE.txt is present and carries the IF WE STOP (CONTINUITY) clause
  PASS  reach: the home catalog is an OfferCatalog (an ItemList) of the priced products
  PASS  reach: every home Product CARRIES its Offer with price + currency + availability (the GSC critical)
  PASS  reach: the home Offer carries seller + priceValidUntil like the product page, and (08 #18, 2026-09-26) no US-only shipping/return fields
  PASS  reach: a free product is a SoftwareApplication with a $0 offer, never a Product without one
  PASS  reach: no aggregateRating and no review is ever invented (2 buyers, no ratings to mark up)
  PASS  reach: no Product node anywhere in the home graph lacks offers
  PASS  reach: suite Offer carries sku, name, seller, priceValidUntil (C5 §2: 13 suites, one code path)
  PASS  reach: /faq FAQPage from the help_qa sections, incomplete pairs skipped
  PASS  reach: /guides ItemList lists the published guides by url + name, in order
  PASS  reach: /suites ItemList carries each suite as the Product+Offer its page carries
  PASS  reach: /pricing ItemList carries both license Offers per wired product and the suites, and skips "Available soon"
  PASS  reach: a page that brings no richer markup still gets a WebPage node from its own head
  PASS  reach: no WebPage node without a canonical
  PASS  reach: a product Offer carries validFrom from the edition date (GSC warning ×25)
  PASS  reach: no validFrom is asserted when no date is on the row
  PASS  reach: validFromDate accepts a datetime and refuses garbage
  PASS  reach: the product hero image carries fetchpriority="high" and banner dimensions
  PASS  reach: the layout preloads the two above-the-fold fonts and falls back to a WebPage graph
  PASS  reach: the router sends a slashed GET to the slashless address with a 301 and keeps the query string

== email list: exit token, welcome once, audience, buyer invite, broadcasts ==
  PASS  list: migration added the row columns
  PASS  list: migration added broadcasts + broadcast_sends + the buyer_invite_live flag (default 0)
  PASS  list: signup records its source and mints a 48-hex exit token distinct from the confirm token
  PASS  list: an unconfirmed row is not in any audience
  PASS  list: the welcome mail goes ONCE across two confirm clicks (welcomed_at)
  PASS  list: the welcome mail carries THIS row's one-click unsubscribe URL and no unfilled placeholder
  PASS  list: a confirmed row is in the audience; store audience sees it, guides audience does not
  PASS  list: one address on two tags is ONE recipient
  PASS  list: garbage exit tokens are refused
  PASS  list: one click leaves EVERY list of that address
  PASS  list: signing up again after leaving re-opens the row as UNconfirmed (the new click is the consent)
  PASS  list: List-Unsubscribe headers are emitted and a CRLF in a value cannot inject a header
  PASS  list: the buyer opt-in token is stateless and tamper-evident
  PASS  list: the click creates ONE confirmed buyer row and the buyers audience sees it
  PASS  list: the delivery mail carries the invite ONLY when buyer_invite_live = 1 (default 0 = no invite)
  PASS  list: the form partial states the purpose, the ceiling, the confirm step and the exit; posts source + honeypot
  PASS  list: the home page footer carries the form with source=footer
  PASS  list: mail template confirm fills every placeholder and names no other brand
  PASS  list: mail template welcome fills every placeholder and names no other brand
  PASS  list: mail template footer fills every placeholder and names no other brand
  PASS  list: mail template buyer_invite fills every placeholder and names no other brand
  PASS  list: broadcast.php refuses without the operator's approval line and needs BOTH --send and --go; caps a run at the provider's daily free limit — SKIP (dist zip: a store-instance fact)
  PASS  list: routes for the exit and the buyer yes exist

== 18b2. /catalogue — every tool by the job it does (F51 D-8 carry-over, 2026-09-27) ==
  PASS  /catalogue renders with an h1 and the breadcrumb
  PASS  /catalogue lists every active product once
  PASS  /catalogue has at least one group and every jump chip points at a group on the page
  PASS  /catalogue (this store): at least two groups, each reachable from a jump chip — SKIP (dist zip: a store-instance fact)
  PASS  /catalogue carries canonical + BreadcrumbList JSON-LD
  PASS  /catalogue names no product by a raw slug in the visible headings

== SC. Store conversion (F51) ==
  PASS  SC1: Storefront::proof() reads assets/store/{slug}/proof.json from an overridable dir
  PASS  SC1: no proof.json -> null (a missing proof is never a number)
  PASS  SC1: proof.json read: 140 passed (got 140, want 140)
  PASS  SC1: a proof with failures is refused (null) — the strip never shows a red suite as proof
  PASS  SC1: a corrupt proof.json is refused (null), never a warning on the page
  PASS  SC1: the product hero carries the proof strip
  PASS  SC1: strip: "140 tests" links to the real run log /p/{slug}/tests
  PASS  SC1: strip: the manual links to the manual rendered on the store
  PASS  SC1: strip: "API + MCP" links to the rendered API.md
  PASS  SC1: strip: the version is the edition's (v1.0.0) and links to the quickstart
  PASS  SC1: strip: "Source included" links to the license answer on the page
  PASS  SC1: strip: the demo item links the demo and says it resets itself
  PASS  SC1: proof-first: the strip sits ABOVE the price box
  PASS  SC1: without proof.json the strip is absent — no typed fallback number
  PASS  SC1: a product with no shipped document at all (no tree) says so in its strip — the pre-release line, never a number
  PASS  SC1: the route /p/{slug}/{doc} exists and the controller product_doc() is defined
  PASS  SC1: /p/{slug}/manual renders the manual through the escape-first md renderer
  PASS  SC1: the manual page names the product and links back to it
  PASS  SC1: /p/{slug}/tests renders the run log verbatim-escaped with its run date
  PASS  SC1: the manual page does not claim to be in the download; the test-run page still says it ships
  PASS  SC1: an unknown doc name is the 404 page
  PASS  SC1: a product without a shipped manual is the 404 page, never an empty page
  PASS  SC1: bin/ownware-proof-collect.php exists, runs each product suite itself and refuses to write a proof for a failing or unparseable run — SKIP (dist zip: a store-instance fact)
  PASS  SC3: fixture: sc-invoicer sits in an active suite
  PASS  SC3: fixture: the paid order exists
  PASS  SC3: mail: subject says start here
  PASS  SC3: mail: the download link is first
  PASS  SC3: mail: the QUICKSTART travels inline
  PASS  SC3: mail: the manual and Connect-your-AI links
  PASS  SC3: a suite-upgrade code is minted at fulfillment (one per order)
  PASS  SC3: the code is worth exactly the paid amount (suite arithmetic, no typed money) (got 4900, want 4900)
  PASS  SC3: the code targets the best suite for the product (got 4, want 4)
  PASS  SC3: the code lives 30 days
  PASS  SC3: mail: the suite bridge names the suite, the code, the computed difference and the expiry
  PASS  SC3: mail: the difference is suite price minus what was paid
  PASS  SC3: mail: portal link + the license-key ruling line survive
  PASS  SC3: minting twice returns the same code (idempotent)
  PASS  SC3: a product in no suite mints no code and its mail carries no bridge
  PASS  SC3: a suite order mints no upgrade code
  PASS  SC2: /thanks: the quickstart renders inline under the download
  PASS  SC2: /thanks: manual + Connect-your-AI links
  PASS  SC2: /thanks: the suite bridge with the computed difference and the code
  PASS  SC2: /thanks: "the other N" is counted from the catalog
  PASS  SC2: /thanks/{unknown numeric ref} is the recording state, not a 404 (the webhook can arrive after the redirect)
  PASS  SC2: the recording state refreshes itself and offers /claim as the fallback
  PASS  SC2: the recording state is noindex
  PASS  SC2: a non-numeric unknown ref is still the 404 page
  PASS  SC3: routes /upgrade GET+POST exist
  PASS  SC3: /upgrade renders a code form
  PASS  SC3: lookup: a live code reports its suite, amount and the difference
  PASS  SC3: lookup: an unknown code is null
  PASS  SC3: request: marks the code requested and mails the owner the exact figure
  PASS  SC3: request: a second request on the same code is refused
  PASS  SC3: request: the wrong email is refused
  PASS  SC3: an expired code reads expired and is refused
  PASS  SC4: a guide that mentions a product links its demo, tagged guide/cta/{guide-slug}
  PASS  SC4: the demo link promises sample data and no sign-up
  PASS  SC4: the guide ENDS with the demo: the closer band carries the demo of the product it teaches
  PASS  SC4: gated — with demos_live = 0 a guide links no /demo/
  PASS  SC5: Deliora::numbersPayload() parses the form field: calc:<slugs> keeps only real calculator rows
  PASS  SC5: numbersPayload(): vs:<slug> for a product with priced rows; garbage -> []
  PASS  SC5: the numbers block is computed from the cited rows: rent/month, 3 years, own once, break-even
  PASS  SC5: the vs block lists every priced competitor of the product with its source
  PASS  SC5: notifySignup() stores the payload on the row
  PASS  SC5: the confirmation mail carries the numbers when a payload is stored — and only then
  PASS  SC5: the calculator carries the "email me these numbers" form with source=calculator and a numbers field
  PASS  SC5: calculator.js fills the numbers field from the ticked rows
  PASS  SC5: the rent-vs-own table carries the form with numbers=vs:{slug}
  PASS  SC5: the controller accepts the two new sources
  PASS  SC6: the price block carries the rent-vs-own anchor from the top cited row
  PASS  SC6: the anchor sits inside the price box
  PASS  SC6: the buy button answers the license question: Single license · one site
  PASS  SC6: overlay flag ships OFF
  PASS  SC6: with the flag off there is no lemon.js and no overlay class
  PASS  SC6: flag on + LS: the buy button is an overlay button on the LS URL and loads lemon.js
  PASS  SC6: flag on: the click still counts (beacon to /buy/{id}?ov=1) and Checkout.Success lands on /thanks/{order id}
  PASS  SC6: buy_target(): the beacon answers 204 and never redirects; a plain click is the 302 to the provider
  PASS  SC6: flag back off: no overlay markup
  PASS  SC6: the admin settings form saves the flag
  PASS  magnet: with no code set, Deliora::magnet() is null
  PASS  magnet: with no code set, the store pitch is empty — the form promises nothing
  PASS  magnet: with no code set, the welcome mail block is empty
  PASS  magnet: the welcome mail mentions no discount and leaves no dangling placeholder
  PASS  magnet: the subscribe form advertises no discount while no code exists
  PASS  magnet: a code with 0% is not an offer
  PASS  magnet: a percent with no code is not an offer
  PASS  magnet: an out-of-range percent is not an offer
  PASS  magnet: a real code + percent produces the offer
  PASS  magnet: the ruled offer is 10% off a FIRST purchase
  PASS  magnet: the mail block carries the real code, never a placeholder
  PASS  magnet: the mail block states the one-use-per-order terms honestly
  PASS  magnet: the code is delivered by the POST-CONFIRMATION welcome mail
  PASS  magnet: the welcome mail still carries its one-click unsubscribe
  PASS  magnet: the CONFIRM-LINK mail carries no code — the discount is earned by confirming
  PASS  magnet: with a code set the subscribe form states the offer plainly
  PASS  magnet: the form never prints the code itself — it is earned by confirming
  PASS  magnet: notifyWelcome() is what passes the block, so the code rides the confirmed-only mail
  PASS  magnet: the settings form exposes the code and the admin save validates it
  PASS  magnet: no LS API client exists on the box, so the code is never machine-created here
  PASS  magnet: the suite leaves the offer OFF, so nothing ships switched on by a test

== agent door ==
  PASS  agent: carts + cart_items exist after migrate()
  PASS  agent: cart_items refuses a duplicate (cart_id, edition_id) line
  PASS  agent: migrate() is idempotent for the cart tables
  PASS  agent: AgentDoor class exists (src/AgentDoor.php)
  PASS  agent search: two families match "agentic" (name beats tagline) (got array (
  0 => 'agentic-app',
  1 => 'other-tool',
), want array (
  0 => 'agentic-app',
  1 => 'other-tool',
))
  PASS  agent search: a family is one result with its option values in display order
  PASS  agent search: family price = lowest SELLABLE variant (single 49.00; extended unwired) (got 49.0, want 49.0)
  PASS  agent search: family in stock while any variant is; currency and tagline carried
  PASS  agent search: nothing matches → [] (got array (
), want array (
))
  PASS  agent search: max_price filter on the family price (got array (
), want array (
))
  PASS  agent search: min_price filter keeps the dearer family only (got array (
  0 => 'agentic-app',
), want array (
  0 => 'agentic-app',
))
  PASS  agent search: limit is honored (got 1, want 1)
  PASS  agent search: price_asc puts the cheaper family first (got array (
  0 => 'other-tool',
  1 => 'agentic-app',
), want array (
  0 => 'other-tool',
  1 => 'agentic-app',
))
  PASS  agent search: price_desc puts the dearer family first (got array (
  0 => 'agentic-app',
  1 => 'other-tool',
), want array (
  0 => 'agentic-app',
  1 => 'other-tool',
))
  PASS  agent search: empty query lists the catalog (families, by name)
  PASS  agent details: id resolves case-insensitively to the family
  PASS  agent details: every variant listed, cheapest first (got array (
  0 => 'agentic-app:single',
  1 => 'agentic-app:extended',
), want array (
  0 => 'agentic-app:single',
  1 => 'agentic-app:extended',
))
  PASS  agent details: a variant carries its option value, its family, its own price and stock
  PASS  agent details: a variant title names the product and the license
  PASS  agent details: long description is the product copy
  PASS  agent details: a variant id returns that variant
  PASS  agent details: unknown id → null (got NULL, want NULL)
  PASS  agent details: unknown tier → null (got NULL, want NULL)
  PASS  agent details: an inactive product is unknown (got NULL, want NULL)
  PASS  agent details: cited comparisons become specs; an uncited row never reaches the agent
  PASS  agent details: no image → image_url null
  PASS  agent stock: family price follows the lowest sellable variant when the single goes unwired (got 99.0, want 99.0)
  PASS  agent stock: an LS variant id with a store name is sellable (URL built, not stored)
  PASS  agent stock: sales_live = 0 → nothing is in stock, prices still shown
  PASS  agent category: a catalog slug maps to its home-page section — SKIP (dist zip: a store-instance fact)
  PASS  agent category: an unlisted slug has none (got NULL, want NULL)
  PASS  agent cart: a fresh session has an empty cart (got array (
  'items' => 
  array (
  ),
  'currency' => 'USD',
), want array (
  'items' => 
  array (
  ),
  'currency' => 'USD',
))
  PASS  agent cart: adding twice merges into one line whose quantity is the sum
  PASS  agent cart: the line carries the variant id, title, price, option value and family
  PASS  agent cart: another session sees nothing (got array (
), want array (
))
  PASS  agent cart: update sets the quantity (got 1, want 1)
  PASS  agent cart: updating a line the cart does not hold leaves the cart as it is (got array (
  0 => 'agentic-app:single',
), want array (
  0 => 'agentic-app:single',
))
  PASS  agent cart: removing a line the cart does not hold leaves the cart as it is (got array (
  0 => 'agentic-app:single',
), want array (
  0 => 'agentic-app:single',
))
  PASS  agent cart: a second variant is a second line, in insertion order (got array (
  0 => 'agentic-app:single',
  1 => 'other-tool:single',
), want array (
  0 => 'agentic-app:single',
  1 => 'other-tool:single',
))
  PASS  agent cart: remove takes the line out (got array (
  0 => 'other-tool:single',
), want array (
  0 => 'other-tool:single',
))
  PASS  agent cart: remove the last line → empty (got array (
), want array (
))
  PASS  agent cart: a family id is refused (422 family) — the cart takes a variant
  PASS  agent cart: an unsellable variant is refused (409 unavailable) and the detail names ids only
  PASS  agent cart: an unknown id is refused (404 not_found)
  PASS  agent cart: quantity 0 is refused (422 quantity)
  PASS  agent cart: quantity above 99 is refused (422 quantity)
  PASS  agent cart: nothing was written by the refused calls (got array (
), want array (
))
  PASS  agent cart: the buyer the host resolved is recorded on the cart (got 42, want 42)
  PASS  agent cart: with sales_live = 0 nothing can be added (unavailable, no siblings in stock)
  PASS  agent prefs: no customer → a guest profile (got array (
  'user_id' => 'guest',
  'display_name' => 'Guest',
), want array (
  'user_id' => 'guest',
  'display_name' => 'Guest',
))
  PASS  agent prefs: an id that names nobody → a guest profile (got array (
  'user_id' => 'guest',
  'display_name' => 'Guest',
), want array (
  'user_id' => 'guest',
  'display_name' => 'Guest',
))
  PASS  agent prefs: a customer → their id and name (got array (
  'user_id' => 'cust:6',
  'display_name' => 'Agent Buyer',
), want array (
  'user_id' => 'cust:6',
  'display_name' => 'Agent Buyer',
))
  PASS  agent prefs: a customer without a name is "Customer", never their email (got array (
  'user_id' => 'cust:7',
  'display_name' => 'Customer',
), want array (
  'user_id' => 'cust:7',
  'display_name' => 'Customer',
))
  PASS  agent orders: a guest is asked to sign in (401 sign_in_required), not shown an empty list
  PASS  agent order: a guest is asked to sign in (401 sign_in_required)
  PASS  agent orders: the customer's own orders, newest first (got array (
  0 => 'AG-ORDER-3',
  1 => 'AG-ORDER-2',
  2 => 'AG-ORDER-1',
), want array (
  0 => 'AG-ORDER-3',
  1 => 'AG-ORDER-2',
  2 => 'AG-ORDER-1',
))
  PASS  agent orders: limit is honored (got 2, want 2)
  PASS  agent orders: paid → delivered (a download was issued), refunded → refunded (got array (
  0 => 'refunded',
  1 => 'delivered',
  2 => 'delivered',
), want array (
  0 => 'refunded',
  1 => 'delivered',
  2 => 'delivered',
))
  PASS  agent orders: placed_at is ISO 8601 in UTC
  PASS  agent orders: an item is the variant the order holds
  PASS  agent orders: total, currency, instant delivery, no tracking link
  PASS  agent orders: someone with no orders gets an empty list (got array (
), want array (
))
  PASS  agent order: one order resolves case-insensitively
  PASS  agent order: another customer's order is unknown (got NULL, want NULL)
  PASS  agent order: an unknown ref is null (got NULL, want NULL)
  PASS  agent account: a guest has none (got NULL, want NULL)
  PASS  agent account: active licenses and the products owned (got array (
  'licences_active' => 1,
  'products_owned' => 
  array (
    0 => 'agentic-app',
  ),
), want array (
  'licences_active' => 1,
  'products_owned' => 
  array (
    0 => 'agentic-app',
  ),
))
  PASS  agent account: a customer with nothing active (got array (
  'licences_active' => 0,
  'products_owned' => 
  array (
  ),
), want array (
  'licences_active' => 0,
  'products_owned' => 
  array (
  ),
))
  PASS  agent policies: "refund" leads with the refunds section of /legal, as plain text
  PASS  agent policies: license tiers are passages from the one place the terms are written
  PASS  agent policies: nothing matches → [] (got array (
), want array (
))
  PASS  agent policies: an empty query returns the standing corpus — three licenses and three legal sections, titled
  PASS  agent policies: a published guide is a passage, clipped
  PASS  agent policies: an unpublished guide never appears
  PASS  agent disclosure: a facts box per product — one row per license with price and sites
  PASS  agent disclosure: a license not on sale says so in its note
  PASS  agent disclosure: no comparisons → no sources, no footnotes
  PASS  agent disclosure: a cited comparison is a row with the read date and its source; an uncited one is not
  PASS  agent disclosure: unknown → null (got NULL, want NULL)
  PASS  agent handoff: an empty cart has nowhere to go (got array (
), want array (
))
  PASS  agent handoff: one hosted checkout per line, in cart order, labeled by product and license — the very URL the Buy button uses (LS custom data included) (got array (
  0 => 
  array (
    'url' => 'https://example.lemonsqueezy.com/buy/abc?checkout%5Bcustom%5D%5Bsku%5D=agentic-app&checkout%5Bcustom%5D%5Btier%5D=single',
    'label' => 'Pay for Agentic App — Single license',
  ),
  1 => 
  array (
    'url' => 'https://example.lemonsqueezy.com/buy/def?checkout%5Bcustom%5D%5Bsku%5D=other-tool&checkout%5Bcustom%5D%5Btier%5D=single',
    'label' => 'Pay for Other Tool — Single license',
  ),
), want array (
  0 => 
  array (
    'url' => 'https://example.lemonsqueezy.com/buy/abc?checkout%5Bcustom%5D%5Bsku%5D=agentic-app&checkout%5Bcustom%5D%5Btier%5D=single',
    'label' => 'Pay for Agentic App — Single license',
  ),
  1 => 
  array (
    'url' => 'https://example.lemonsqueezy.com/buy/def?checkout%5Bcustom%5D%5Bsku%5D=other-tool&checkout%5Bcustom%5D%5Btier%5D=single',
    'label' => 'Pay for Other Tool — Single license',
  ),
))
  PASS  agent handoff: the hosted URL is https and carries the sku the webhook fulfills by
  PASS  agent handoff: a line whose edition lost its checkout is left out rather than linked nowhere (got 1, want 1)
  PASS  agent handoff: an LS variant id + store name builds the hosted URL (never stored, always the Buy button's)
  PASS  agent handoff: the demo provider hands off to the store's own absolute checkout route (every edition is sellable there)
  PASS  agent door: controllers/agent.php exists
  PASS  agent door: index.php requires the controller
  PASS  agent door: index.php loads src/AgentDoor.php on every request (the suite loads it itself, a server does not)
  PASS  agent door: route GET /agent/v1/health is registered
  PASS  agent door: route GET /agent/v1/products is registered
  PASS  agent door: route GET /agent/v1/products/{pid} is registered
  PASS  agent door: route GET /agent/v1/cart is registered
  PASS  agent door: route POST /agent/v1/cart/add is registered
  PASS  agent door: route POST /agent/v1/cart/update is registered
  PASS  agent door: route POST /agent/v1/cart/remove is registered
  PASS  agent door: route GET /agent/v1/checkout-handoff is registered
  PASS  agent door: route GET /agent/v1/preferences is registered
  PASS  agent door: route GET /agent/v1/account is registered
  PASS  agent door: route GET /agent/v1/orders is registered
  PASS  agent door: route GET /agent/v1/orders/{oid} is registered
  PASS  agent door: route GET /agent/v1/policies is registered
  PASS  agent door: route GET /agent/v1/disclosure/{pid} is registered
  PASS  agent door: fourteen handlers are defined
  PASS  agent door: agent_health authenticates with the store's API key before anything else
  PASS  agent door: agent_products authenticates with the store's API key before anything else
  PASS  agent door: agent_product authenticates with the store's API key before anything else
  PASS  agent door: agent_cart authenticates with the store's API key before anything else
  PASS  agent door: agent_cart_add authenticates with the store's API key before anything else
  PASS  agent door: agent_cart_update authenticates with the store's API key before anything else
  PASS  agent door: agent_cart_remove authenticates with the store's API key before anything else
  PASS  agent door: agent_checkout_handoff authenticates with the store's API key before anything else
  PASS  agent door: agent_preferences authenticates with the store's API key before anything else
  PASS  agent door: agent_account authenticates with the store's API key before anything else
  PASS  agent door: agent_orders authenticates with the store's API key before anything else
  PASS  agent door: agent_order authenticates with the store's API key before anything else
  PASS  agent door: agent_policies authenticates with the store's API key before anything else
  PASS  agent door: agent_disclosure authenticates with the store's API key before anything else
  PASS  agent door: no handler asks requireKey for a scope of none (read-only keys must be refused on writes)
  PASS  agent door: no handler reads a user or customer id from the query or the body
  PASS  agent door: the session header is a 64-hex digest, validated before use
  PASS  agent door: the customer header is a positive integer, validated before use
  PASS  agent door: handlers never call csrf_check (the key is the credential, like /api/*)
  PASS  agent door: door errors map to their status code (guarded), never to a 500
  PASS  agent door: the search limit is clamped to the contract ceiling of 25

== OW4-9 CONNPREP: MCP tool annotations meet directory review ==
  PASS  CONNPREP: tools/list returns every tool (got 3, want 3)
  PASS  CONNPREP: EVERY tool carries a title (directory review rejects a missing one)
  PASS  CONNPREP: a read tool is annotated readOnlyHint (got true, want true)
  PASS  CONNPREP: a WRITE tool is annotated destructiveHint (it carried no annotation at all before) (got true, want true)
  PASS  CONNPREP: a write tool is never mislabeled read-only
  PASS  CONNPREP: a spec-supplied title wins over the derived one (got 'A hand-written title', want 'A hand-written title')
  PASS  CONNPREP: a derived title is the tool name made readable (got 'List things', want 'List things')
  PASS  CONNPREP: every shipped tool name is 64 characters or fewer ()
  PASS  CONNPREP: the store ships no catch-all api_request tool with a method parameter

== CLOUDMETERS: hosted in the door, intent on the list ==
  PASS  hosted: SKIPPED — no readable plane offering in this checkout
  PASS  hosted: a Cloud-card signup lands unconfirmed, tagged cloud, against the app slug
  PASS  hosted: unconfirmed is not mailable — the click is the consent, not the POST
  PASS  hosted: confirming makes it mailable for that app only
  PASS  hosted: the forward endpoint fails closed without a secret, uses hash_equals and is rate limited
  PASS  ATTRIB: campaignFromRequest reads the three UTMs and lower-cases them
  PASS  ATTRIB: a request with no UTMs is organic, not a campaign named empty
  PASS  ATTRIB: a UTM outside [A-Za-z0-9._-]{1,64} is dropped, not sanitized into something else
  PASS  ATTRIB: the campaign key is source|medium|campaign, and organic is the empty string
  PASS  ATTRIB: withCampaign respects an existing query string and leaves organic links alone
  PASS  ATTRIB: the campaign rides the SAME checkout[custom] channel the sku rides
  PASS  ATTRIB: an absent UTM sends NO key at all (an empty key would be a campaign named "")
  PASS  ATTRIB: organic checkout URLs are byte-identical to what they were before this unit
  PASS  ATTRIB: suiteCheckoutUrl now carries the signed-checkout guard its sibling always had
  PASS  ATTRIB: the campaign comes home off the webhook, lower-cased, junk keys ignored
  PASS  ATTRIB: a webhook with no campaign yields organic, never a partial key
  PASS  ATTRIB: orders carries the campaign column, defaulted so every existing row reads organic
  PASS  ATTRIB: both buy routes pass the request campaign into the checkout builder
  PASS  ATTRIB: store_product.php hangs the campaign on every buy link it renders
  PASS  ATTRIB: the flagship view — SKIP (dist zip: a view package.sh holds back)
  PASS  ATTRIB: suite_page.php hangs the campaign on every buy link it renders

Warning: file_get_contents(tests/../bin/export-attribution.php): Failed to open stream: No such file or directory in tests/run.php on line 4143
  PASS  ATTRIB: the attribution export selects no personal column (an order id is not a person)
  PASS  M6: the three beacon kinds resolve to 204
  PASS  M6: any other kind resolves to 404, so the log cannot be seeded with invented stage names
  PASS  M6: the kinds the store publishes are exactly the stages the funnel defines
  PASS  M6: the beacon writes nothing — no DB, no session, no cookie in its own body
  PASS  M6: the route is registered
  PASS  M6: the handler now answers the bare 'close' string lemon.js forwards on dismissal
  PASS  M6: a close carries how long the overlay was open, so the <10s split is answerable
  PASS  M6: opening beacons too, and the edition id is taken from the buy URL rather than re-derived
  PASS  M6: Checkout.Success still redirects to /thanks — the existing behavior is not traded away
  PASS  M6: the whole instrument sits inside the checkoutOverlay() block — dormant while the flag is 0
  PASS  M7: every path in the trust set is a real route on this store
  PASS  M6: the beacon URL the overlay builds matches the route the router compiles
  PASS  N9: the continuity clause is found in LICENSE.txt and is not empty
  PASS  N9: the quoted clause carries the successor binding the other copies dropped
  PASS  N9: it carries the license's own MIT wording, not legal.php's paraphrase
  PASS  N9: it is BYTE-FOR-BYTE the license — every sentence appears in LICENSE.txt itself
  PASS  N9: a license with no continuity heading yields null, never a guess
  PASS  D8: a product not on Cloud gets no sentence rather than an invented one
  PASS  D8: a product with no price gets no sentence
  PASS  D8: the year is twelve times the LIVE monthly, computed here and not typed
  PASS  D8: the sentence states the trade in the license's favor, not the subscription's
  PASS  D8: no hardcoded Cloud multiple or money figure in the new methods
  PASS  N8: a product with no collected artifact renders no peek at all
  PASS  N8: a collected peek carries a real tree and names the file it excerpts
  PASS  N8: the peek lists the tree as the ZIP ships it — no packager-deleted leftovers
  PASS  N8: the excerpt is never one of the shared cores
  PASS  N8: the page prints the rule it used to choose the file, rather than asking for trust
  PASS  TRUSTBLOCK: all three blocks render on the product page
  PASS  TRUSTBLOCK: the clause and the ladder sit AT the buy area — after the trust list, before the gallery
  PASS  N8: the peek renders BELOW the demo CTA, so it cannot eat the agent-first 2KB
  PASS  TRUSTBLOCK: no JavaScript — details/summary, the same pattern the FAQ uses
  PASS  TRUSTBLOCK: every collected value is escaped before it reaches the page
  PASS  TRUSTBLOCK: the continuity clause block stays OFF the buy area (ruling 2026-09-15)

== OW4-20 CATMCP: the public unkeyed catalog MCP ==
  PASS  CATMCP: exactly the seven tools (M1's five, plus compare and guides) (search_catalogue product quickstart checkout_link hosted_offer compare guides)
  PASS  CATMCP: NOT ONE tool is a write tool ()
  PASS  CATMCP: the spec declares scope=read (got 'read', want 'read')
  PASS  CATMCP: a write tool added to this door is NOT LISTED (scope=read refuses it)
  PASS  CATMCP: …and CALLING it is refused, not merely hidden
  PASS  CATMCP: all seven list (got 7, want 7)
  PASS  CATMCP: every listed tool carries a title AND readOnlyHint
  PASS  CATMCP: every tool name is 64 chars or fewer
  PASS  CATMCP: no handler selects from an orders/customers/licence/key table ()
  PASS  CATMCP: the door reads only products/editions
  PASS  CATMCP: the keyed /mcp door still requires a key (this unit added a door, it did not open one)
  PASS  CATMCP: the public door is registered on its own path, not on /mcp
  PASS  CATMCP: the POST route is rate limited through the existing limiter
  PASS  CATMCP: the limiter uses its own bucket, so it cannot exhaust another surface
  PASS  CATMCP: a GET learns the method WITHOUT being rate limited first (B13 lesson, comments stripped)
  PASS  CATMCP: the comment stripper is doing real work here
  PASS  CATMCP: GET answers 405 (got 405, want 405)
  PASS  CATMCP/GET: an Accept naming text/html is treated as a browser
  PASS  CATMCP/GET: an explicit JSON or SSE Accept keeps the byte-identical JSON error
  PASS  CATMCP/GET: a generic or empty Accept gets the page (link scanners, curious humans)
  PASS  CATMCP/GET: the page says what the door is
  PASS  CATMCP/GET: the page tells a human where to point an agent
  PASS  CATMCP/GET: the page links /connect-your-ai
  PASS  CATMCP/GET: the page is noindex — it is an endpoint, not a marketing page
  PASS  CATMCP/GET: every tool in the spec appears on the page ()
  PASS  CATMCP/GET: the count in the prose is computed, not typed
  PASS  CATMCP/GET: the tool name is escaped with e() before printing
  PASS  CATMCP/GET: the tool description is escaped with e() before printing
  PASS  CATMCP/GET: the POST handler is untouched — it still rate-limits then hands to Mcp::handle
  PASS  CATMCP/GET: nothing about HTML leaked into the POST path
  PASS  CATMCP/GET: the GET route still answers 405 and still sends Allow: POST
  PASS  CATMCP: search_catalogue returns products for a real word
  PASS  CATMCP: product returns the record for a real slug — SKIP (dist zip: a store-instance fact)
  PASS  CATMCP: product refuses an unknown slug with a readable error
  PASS  CATMCP: quickstart says so rather than returning an empty string
  PASS  CATMCP: hosted_offer lists the hosted products
  PASS  CATMCP: hosted_offer answers honestly for a product Cloud does not host

== VENDORRISK (2026-09-20): /if-we-disappear — the five questions, both shapes ==
  PASS  /if-we-disappear carries 'any legal successor'
  PASS  /if-we-disappear carries 'MIT license'
  PASS  /if-we-disappear carries 'no license server'
  PASS  /if-we-disappear carries 'escrow'
  PASS  /if-we-disappear carries 'mysqldump'
  PASS  /if-we-disappear carries 'What support covers'
  PASS  /if-we-disappear carries '/legal#continuity'
  PASS  /if-we-disappear carries 'does not phone home'
  PASS  vendor_risk neutral shape: no dated re-check line is invented
  PASS  vendor_risk neutral shape: the export sentence is not inherited
  PASS  vendor_risk neutral shape: the universal rows stand
  PASS  view vendor_risk.php hard-codes no absolute host — URLs come from $B() or App::url
  PASS  index.php routes /if-we-disappear
  PASS  index.php routes the guessed /vendor-risk to the alias
  PASS  index.php routes the guessed /continuity to the alias
  PASS  llms.txt advertises /if-we-disappear (C-T1)

== W3-U2: conditional requests on the public pages (Cache-Control + Last-Modified + ETag + 304) ==
  PASS  U2: a listing plan carries Cache-Control public max-age=600, an ETag and a Last-Modified
  PASS  U2: a content-page plan carries Cache-Control public max-age=1800 (the ruled 300-3600 range)
  PASS  U2: If-None-Match with the page's own ETag answers 304 (and a different ETag answers 200)
  PASS  U2: If-Modified-Since after the page date answers 304, before it answers 200
  PASS  U2: a page with NO stored date sends no Last-Modified — the clock is never invented
  PASS  U2: public_validators() adds the validators and leaves Cache-Control to the route (crawler files)
  PASS  BUILD: a build newer than the page date moves Last-Modified to the build, so a refresh holding the old date gets 200
  PASS  BUILD: a refresh already holding the build's date still gets the cheap 304
  PASS  BUILD: a page date newer than the build keeps its own date
  PASS  BUILD: a new build is a new ETag; no build (0) leaves the ETag exactly as before
  PASS  BUILD: no stamp in this tree, so app_build_ts() is 0 and nothing here moves
  PASS  EDGE: every public Cache-Control tells the shared (Cloudflare) cache to re-ask us every time (s-maxage=0), so the origin log sees every visit
  PASS  BUILD: deploy-store.sh writes .build-stamp after the sync and before the image is rebuilt
  PASS  U2: /guides renders in full, then answers a matching If-None-Match with an EMPTY body
  PASS  U2: /llms.txt answers a matching If-None-Match with no page content, its crawler_cache(3600) untouched
  PASS  U2: all fourteen named public routes answer conditional requests (got array (
), want array (
))
  PASS  agencies: views/agencies.php is ownware.io's own and absent from this build (dist zip) - skipped
  PASS  U2: nothing outside the public-page allow-list is cacheable (no checkout/cart/account/portal/licence route) (got array (
), want array (
))
  PASS  U2: the named checkout/portal/licence/admin handlers carry no public cache header at all (got array (
), want array (
))
  PASS  U2: every crawler_cache() second is exactly what it was before this wave (26 calls) (got array (
  0 => 
  array (
    60 => 1,
    300 => 2,
    1800 => 3,
    3600 => 17,
    86400 => 2,
    604800 => 1,
  ),
  1 => 26,
), want array (
  0 => 
  array (
    60 => 1,
    300 => 2,
    1800 => 3,
    3600 => 17,
    86400 => 2,
    604800 => 1,
  ),
  1 => 26,
))

== W3-U3: honest schema on the /alternatives pages (Product + Offer + dated WebPage + a real FAQ) ==
  PASS  U3: the catalog offers an /alternatives page whose product carries a stored FAQ (fixture found) — SKIP (dist zip: a store-instance fact)
  PASS  U3: the alternatives page's JSON-LD parses and carries BreadcrumbList + Product + an Offer — SKIP (dist zip: a store-instance fact)
  PASS  U3: the Product node is the SAME node as on /p/{slug} — one @id, not two products — SKIP (dist zip: a store-instance fact)
  PASS  U3: dateModified on the page node equals the newest STORED price-check date behind the page (got '', want '')
  PASS  U3: the rival is named as text only — the page's about Thing and its description, never our Product — SKIP (dist zip: a store-instance fact)
  PASS  U3: FAQPage is present, and every marked-up question is VISIBLE on the page — SKIP (dist zip: a store-instance fact)
  PASS  U3: with no visible Q/A the page carries NO FAQPage (the markup never invents a question) — SKIP (dist zip: a store-instance fact)
  PASS  U3: no AggregateRating and no Review anywhere on an alternatives page — we have none to show

== W3-U4: the /vs comparison pages — Product + Offer + the visible as-of stamp ==
  PASS  U4: the catalog has a /vs page whose cited rows carry a verification date (fixture found)
  PASS  U4: the /vs JSON-LD parses with Product (the same @id as /p/{slug}) + an Offer + BreadcrumbList
  PASS  U4: the VISIBLE as-of stamp, the stored verification date and dateModified are one value (got array (
  0 => '2026-09-01',
  1 => '2026-09-01',
), want array (
  0 => '2026-09-01',
  1 => '2026-09-01',
))
  PASS  U4: the as-of anchor id exists and points at the Sources list it is citing
  PASS  U4: the compared product is named as text (the about Thing), never as our Product or Offer

== W3-U5: the /vs index — the 50 comparison pages finally have one ==
  PASS  U5: /vs answers through the bare-slug route (200, not the 404 page) and lists every comparison page
  PASS  U5: the index's ItemList holds one item per comparison page, each a real /vs/{slug} URL (got array (
  0 => 1,
  1 => 1,
  2 => 1,
), want array (
  0 => 1,
  1 => 1,
  2 => 1,
))
  PASS  U5: /vs is canonical to itself, carries a dated WebPage, and is in the sitemap
  PASS  U5: every /vs/{slug} page and the store footer link the index
  PASS  U5: a bare slug that is neither product, suite nor the index still 404s
  PASS  U5: vs_index() is one of the conditional public routes (U2's allow-list member)

== W3-U6: internal links — the related blocks, the guides' products, the orphan, the link ceiling ==
  PASS  U6: every /alternatives and every /vs page gets 3-5 related links, none rival-to-rival, none empty (got array (
  0 => true,
  1 => true,
  2 => 0,
  3 => 0,
), want array (
  0 => true,
  1 => true,
  2 => 0,
  3 => 0,
))
  PASS  U6: the block is RENDERED on an alternatives page and on a /vs page, with its links present — SKIP (dist zip: a store-instance fact)
  PASS  U6: the related links are DERIVED, not random — the same page twice gives the same block
  PASS  U6: a guide links every product its body names, as a real /p/{slug} anchor
  PASS  U6: no content page (product, guide, alternatives, vs — worst case of each) carries more than 150 anchors (got array (
), want array (
))
  PASS  U6: the footer links all five indexes — /vs, /alternatives, /guides, /self-hosted, /suites
  PASS  U6: /docs — the store's one orphan — is now linked from every page's footer

== W3-U7: page weight — inline script out of the page, every image dimensioned, /guides under 80 KB ==
  PASS  U7: the home page's inline JavaScript is under 8 KB (was 8,673 B in six blocks)
  PASS  U7: the two home scripts are deferred files under assets/js/, plain and fetch-free, and the page loads them — SKIP (dist zip: a store-instance fact)
  PASS  U7: the behavior the scripts drive is still on the page — the filter, the FREE toggle, the chips, the carousel — SKIP (dist zip: a store-instance fact)
  PASS  U7: every <img> on the home page carries width and height, read from the file itself — SKIP (dist zip: a store-instance fact)
  PASS  U7: every home image below the fold is lazy-loaded
  PASS  U7: the dimensions are READ, never typed — a missing file yields no attribute at all — SKIP (dist zip: a store-instance fact)
  PASS  U7: /guides is under 80 KB of HTML and still links every published guide
  PASS  U7: /guides leads its title with the category set W2 measured it missing
  PASS  U7: home HTML stays under its measured ceiling of 240 KB (the 61 KB OfferCatalog is 26% of it)

== W3-U8: sitemap lastmod = each page's own newest dated fact, never the build date ==
  PASS  U8: every sampled page's <lastmod> is exactly the date its own Last-Modified is built from — SKIP (dist zip: a store-instance fact)
  PASS  U8: no <lastmod> in the sitemap is in the future or malformed, and the undated pages carry none — SKIP (dist zip: a store-instance fact)
  PASS  U8: the Last-Modified the route sends decodes back to the very date the sitemap published (got array (
), want array (
))
  PASS  U8: the four-date clustering W1 measured is gone — the sitemap now carries many distinct dates — SKIP (dist zip: a store-instance fact)

== W3-U8b: the two buyer-question pages join the sitemap ==
  PASS  U8b: /if-we-disappear and /what-self-hosting-costs are in the sitemap, each with its own checked date

== W3-U9: llms.txt — every guide, comparison and alternatives page, each with its date ==
  PASS  U9: the four new sections exist, with one line per page — counts read from the database, not typed (got array (
  0 => 1,
  1 => 1,
  2 => 1,
  3 => 1,
), want array (
  0 => 1,
  1 => 1,
  2 => 1,
  3 => 1,
))
  PASS  U9: every guide line carries its updated date and every comparison line its as-of date
  PASS  U9: every alternatives line names the Ownware product that replaces the rival
  PASS  U9: every page URL in the three new inventories is published in the sitemap (got array (
), want array (
))
  PASS  U9: /vs — absent from llms.txt entirely before this wave — is advertised, and the route keeps crawler_cache(3600)
  PASS  U9: the new lines are US English and name no revenue
  PASS  U9: /llms.txt stays under 96 KB and carries a line for every guide, comparison and alternative page

== FIX WAVE 2026-09-24: the cookieless visitor count ==
  PASS  HIT: POST /hit is routed to visitor_hit
  PASS  HIT: the endpoint answers with an empty body (204) and stores nothing
  PASS  HIT: every store page loads the first-party signal.js that sends the ping
  PASS  HIT: the ping skips automated browsers, sends only the path and the referring host, and omits credentials
  PASS  HIT: the privacy page says the store counts its own page views, with no identifier and no cookie

== W3-U2b: Content-Signal as a response header on every public page ==
  PASS  U2b: one helper emits the robots line as a header, with the three ruled values verbatim
  PASS  U2b: every public response passes through it — the crawler files via crawler_cache(), the pages via public_cache_apply() — and nothing else emits it

== W3-U3b: meta descriptions cut at a sentence boundary, never mid-word ==
  PASS  U3b: a description that fits is untouched; a long one is cut at the sentence end; a hand-written one is exempt
  PASS  U3b: with no sentence end in range it cuts at a WORD boundary inside the limit, marked, and a decimal point is not a sentence end
  PASS  U3b: on every template — home, product, vs, alternatives, guide, and the six indexes — no description runs past 155 characters — SKIP (dist zip: a store-instance fact)
  PASS  U3b: and none of them ends mid-sentence — every one closes on a sentence or a marked cut — SKIP (dist zip: a store-instance fact)

== W3-U6b: links into the robots-disallowed paths are nofollowed; /demo/ answers noindex ==
  PASS  U6b: the filter marks internal /demo/ and /buy/ anchors, merges an existing rel, is idempotent, and never touches an external /buy/ URL
  PASS  U6b: on the rendered home, product and comparison pages, every /demo/ and /buy/ anchor is nofollowed (got array (
  0 => true,
  1 => 0,
), want array (
  0 => true,
  1 => 0,
))
  PASS  U6b: /demo/{slug} answers X-Robots-Tag: noindex, and robots.txt still disallows both paths
  PASS  U6b: the flagship template is the single documented exception — named in the layout, and its own buy anchors left as the existing assertions match them
  PASS  U6b: the checkout exception is honored — buy_redirect() sets no response header of ours

== W3FU-FU2: the two vendor-risk pages leave the ADMIN layout ==
  PASS  FU2: both pages render in the STORE layout — 0 /admin links, 0 "Sign out", exactly one notify form, the store header and footer present (got array (
), want array (
))
  PASS  FU2: every content byte the views carry is still on the page
  PASS  FU2: Last-Modified, the ETag and the sitemap all come from the page's own checked date (got array (
  0 => false,
  1 => '',
  2 => '',
), want array (
  0 => false,
  1 => '',
  2 => '',
))
  PASS  FU2: both answer a matching If-None-Match with no page content, keeping crawler_cache(3600)
  PASS  FU2: neither page pulls in views/layout.php any more — the navlien redeclare that aborted this suite cannot happen

== W3FU-FU1: the gate cannot print PASSED on a fatal ==
  PASS  FU1: the fatal handler inspects error_get_last() for the four fatal types and exits 2
  PASS  FU1: it is registered BEFORE the summary handler, so a fatal can never reach the PASSED line

== W3FU-FU3: the eighteen public pages U2's list did not name ==
  PASS  FU3: all eighteen handlers are now cacheable — none was before this wave (got 18, want 18)
  PASS  FU3: every one of the eighteen calls public_cache() in its own body (got array (
), want array (
))
  PASS  FU3: the dated pages send public max-age + the page's own Last-Modified + an ETag, and answer a match with an empty 304 — SKIP (dist zip: a store-instance fact)
  PASS  FU3: the nine static pages are cacheable for an hour and claim neither a date nor an ETag — SKIP (dist zip: a store-instance fact)
  PASS  FU3: a plan with no seed carries no ETag, so no request can be answered 304 by accident
  PASS  FU3: /privacy/{slug} — the printed update date is what the validator carries (got '2026-08-03', want '2026-08-03')
  PASS  FU3: /manifest.json is cacheable for a day, and the two exiting routes are wired at source

== W3FU-FU4: the directory strip's rel values, from the vendor reads ==
  PASS  FU4 strip checks skipped (dist zip build - store_instance.php absent)

== W3FU-FU5: the measured retitles (twenty until 2026-09-26; eighteen since O1 lifted p:complia and p:safora to the copy file) ==
  PASS  FU5: all eighteen measured pages render a title, and the map holds exactly eighteen keys (got array (
  0 => 18,
  1 => 18,
  2 => 0,
), want array (
  0 => 18,
  1 => 18,
  2 => 0,
))
  PASS  FU5: every one of the eighteen <title> elements is 60 characters or fewer (got array (
), want array (
))
  PASS  FU5: every one of them is US English (got array (
), want array (
))
  PASS  FU5: the category word on the one remaining "category first" row (tokora; complia and safora lifted to the copy file 2026-09-26) is the label of the /self-hosted page that lists the product — SKIP (dist zip: a store-instance fact)
  PASS  FU5: the counts and the date inside the index titles are READ from the page's own data, never typed
  PASS  FU5: a page outside the twenty keeps the title it had — the blast radius is the measured set — SKIP (dist zip: a store-instance fact)
  PASS  FU5: the price record and the report carry their dates in the graph — Dataset dateModified and a Report with datePublished

== W3c stateless CSRF ==
  PASS  W3c: the stateless token has the ruled shape s0.<bucket>.<64 hex>
  PASS  W3c: it verifies at the instant it was minted
  PASS  W3c: it verifies across its whole 12 h bucket, first second to last
  PASS  W3c: it still verifies through the NEXT bucket — a rendered form lives 12 to 24 hours
  PASS  W3c: at the 24 h mark it is refused — two buckets on is out of the window
  PASS  W3c: another install's secret cannot verify it, and an EMPTY secret verifies nothing
  PASS  W3c: one altered hex digit is refused
  PASS  W3c: the empty string, a bare prefix and a malformed bucket are all refused
  PASS  W3c: a SESSION token is never accepted by the stateless check
  PASS  W3c: csrf_token() mints a session token while a session is active — unchanged for every other form
  PASS  W3c: only /notify can take the stateless branch, and only for an s0. token

== HONESTCOSTS (2026-09-20): /what-self-hosting-costs — the buyer's half of the price ==
  PASS  /what-self-hosting-costs carries 'you are the sysadmin'
  PASS  /what-self-hosting-costs carries 'mysqldump'
  PASS  /what-self-hosting-costs carries 'When NOT to buy from us'
  PASS  /what-self-hosting-costs carries 'rather lose that sale'
  PASS  /what-self-hosting-costs carries '/if-we-disappear'
  PASS  /what-self-hosting-costs carries 'What support covers'
  PASS  /what-self-hosting-costs carries '/calculator'
  PASS  /what-self-hosting-costs carries 'no queue workers'
  PASS  /what-self-hosting-costs types NO money figure
  PASS  honest_costs neutral shape: no dated line invented
  PASS  honest_costs neutral shape: the universal rows stand
  PASS  view honest_costs.php hard-codes no absolute host
  PASS  index.php routes /what-self-hosting-costs
  PASS  index.php routes the two guessed cost addresses
  PASS  llms.txt advertises /what-self-hosting-costs (C-T1)

== AGENT ACCESS (2026-09-24): markdown pages, the catalog MCP's card and its two new tools ==
  PASS  AGENT ACCESS store-instance checks skipped (dist zip build - store_instance.php absent)

== AGENT U2: markdown on the public content pages -- the branch, the handlers, the builders ==
  PASS  AGENT U2: ten addresses answer markdown, each named with its handler (got 10, want 10)
  PASS  AGENT U2: every listed handler asks wanted(), answers before public_cache(), and returns straight after send() (got array (
), want array (
))
  PASS  AGENT U2: each listed address is routed to the handler the list names (got array (
), want array (
))
  PASS  AGENT U2: guide_show answers a draft 404 BEFORE the markdown branch (a draft is a 404 in both formats)
  PASS  AGENT U2: wanted() is true only when markdown is named and wins on q
  PASS  AGENT U2: send() under CLI prints exactly the body and records what a server would send
  PASS  AGENT U2: off the CLI, send() still sends every header from headers() and exits
  PASS  AGENT U2: datedRows() keeps cited AND dated rows only, in order (got array (
  0 => 'Dated Rival',
  1 => 'Gone Rival',
), want array (
  0 => 'Dated Rival',
  1 => 'Gone Rival',
))
  PASS  AGENT U2: /vs markdown drops the undated and the uncited row and keeps the dated ones
  PASS  AGENT U2: a withdrawn source is said to be withdrawn and its address is never printed
  PASS  AGENT U2: a pipe inside a vendor price cannot split the table cell
  PASS  AGENT U2: money keeps its cents ($49.50) and drops only a round .00
  PASS  AGENT U2: a guide is its title, description, address and written date, then the stored body as-is
  PASS  AGENT U2: llms.txt lists every markdown address from the one list, and no longer says "any address"
  PASS  AGENT U2: the catalog markdown points an agent at every other markdown address

== AGENT U3: a markdown reply is never cacheable; the HTML twin's cache headers are untouched ==
  PASS  AGENT U3: every markdown reply says Cache-Control: private, no-store (the net under the zone's bypass rule)
  PASS  AGENT U3: /guides as markdown carries private, no-store and computes no public cache plan (no ETag, no Last-Modified)
  PASS  AGENT U3: /guides as HTML keeps exactly its public plan: max-age=600, an ETag, a Last-Modified, and no markdown reply
  PASS  AGENT U3: off the CLI, send() adds the Content-Signal the HTML twin carries, from the one helper, inside the headers_sent() guard

== AGENT U4: the catalog MCP's server card at /.well-known/mcp/server-card.json ==
  PASS  AGENT U4: the card's tool names are the live tools/list names, in order (got array (
  0 => 'search_catalogue',
  1 => 'product',
  2 => 'quickstart',
  3 => 'checkout_link',
  4 => 'hosted_offer',
  5 => 'compare',
  6 => 'guides',
), want array (
  0 => 'search_catalogue',
  1 => 'product',
  2 => 'quickstart',
  3 => 'checkout_link',
  4 => 'hosted_offer',
  5 => 'compare',
  6 => 'guides',
))
  PASS  AGENT U4: the card's version is serverInfo.version (got '1.1.0', want '1.1.0')
  PASS  AGENT U4: serverInfo, capabilities and protocol are the server's own initialize answer
  PASS  AGENT U4: the description is the server's own instructions, with the product count read from the DB
  PASS  AGENT U4: every tool carries its title, description and read-only annotation, as tools/list gives them
  PASS  AGENT U4: transport streamable-http at this store's own /catalogue/mcp, no authentication, docs at the door
  PASS  AGENT U4: the route is registered beside the door, and its pattern matches the address and nothing near it
  PASS  AGENT U4: the route answers JSON, cacheable like the other discovery files, readable cross-origin
  PASS  AGENT U4: the served body parses and is the card
  PASS  AGENT U4: the surface table lists the door and its card (so /docs and /for-agents carry them)
  PASS  AGENT U4: llms.txt advertises the catalog MCP and its card (C-T1)
  PASS  AGENT U4: /.well-known/agent.json lists the catalog MCP and its card beside the keyed doors
  PASS  AGENT U4: the agent card names the markdown addresses from the one list instead of "any address"

== AGENT U5: the catalog MCP's compare and guides tools (1.1.0) ==
  PASS  AGENT U5: compare and guides are listed, each with a title and readOnlyHint
  PASS  AGENT U5: the server says 1.1.0, and the card says what the server says (got array (
  0 => '1.1.0',
  1 => '1.1.0',
), want array (
  0 => '1.1.0',
  1 => '1.1.0',
))
  PASS  AGENT U5: one version literal, read by the spec and by the GET stub
  PASS  AGENT U5: compare returns every cited, dated row the /vs page shows (sc-invoicer: 1)
  PASS  AGENT U5: every compare row carries its read date, and a source address unless the page is withdrawn
  PASS  AGENT U5: compare on a product with no cited rows returns an empty list and the reason (uikit-pro)
  PASS  AGENT U5: compare refuses an unknown slug with a readable error
  PASS  AGENT U5: guides lists the published guides, and only those (1)
  PASS  AGENT U5: guides honors its limit and still reports the total
  PASS  AGENT U5: guides filtered by a word ('sc') returns matches only, and at least the guide it came from
  PASS  AGENT U5: guides by slug returns that guide in full, its body as stored
  PASS  AGENT U5: an unpublished guide returns nothing -- no title, no body -- by slug or by search
  PASS  AGENT U5: compare reads the /vs rows through comparisonsFor and datedRows; guides reads only through the published-guide helpers
  PASS  AGENT U5 store-instance checks skipped (dist zip build - store_instance.php absent)

== SEO JOB 2 (2026-09-26, research 08 §11 #1 #5 #6 #12 #13 #17 #18) ==
  PASS  J2 #6: robots.txt disallows /buy-suite/ beside /buy/ and /demo/
  PASS  J2 #1: the four shipped documents of a product with a tree are in the sitemap, dated with the product
  PASS  J2 #1: a product without a shipped document lists no document URL (sc-booking has no tree)
  PASS  J2 #12: /support is in the sitemap
  PASS  J2 #12: /changelog carries a lastmod = the newest shipped edition or release note (pageDate changelog)
  PASS  J2 #12: the state report carries the price record's checked date (the date its route answers with)
  PASS  J2 #12 (U8 kept): pages with no stored dated fact stay undated — /faq, /legal, /security, /docs, /status
  PASS  J2 #1: the API page title is product name + page type (got 'SC Invoicer REST API and webhooks: reference', want 'SC Invoicer REST API and webhooks: reference')
  PASS  J2 #1: the manual page title is product name + page type (got 'SC Invoicer manual: setup, settings and daily use', want 'SC Invoicer manual: setup, settings and daily use')
  PASS  J2 #1: the quickstart page title (got 'SC Invoicer quickstart: install in minutes', want 'SC Invoicer quickstart: install in minutes')
  PASS  J2 #1: the test-run page title carries the count from proof.json, never a typed one (got 'SC Invoicer test run: 140 checks, as shipped', want 'SC Invoicer test run: 140 checks, as shipped')
  PASS  J2 #1: the API description names the product and the agent line; the test-run description carries 140 passed / 0 failed
  PASS  J2 #1: the four titles differ from each other and from the product page's
  PASS  J2 copy rule: none of guaranteed/must/should/compliant/eligible/required/obliged in a document title or description (got 0, want 0)
  PASS  J2 #17: a document page ships BreadcrumbList Home › product › API ending at /p/sc-invoicer/api, plus the Organization node
  PASS  J2 #13: a ##-only document renders h2/h3 (top level normalized), a # inside a fence is not a heading
  PASS  J2 #13: a #-topped document renders exactly as before (h2 / h3 / h4)
  PASS  J2 #13 (07 #8): on a guide written with ## sections the first heading after the h1 is an h2, not an h3
  PASS  J2 #5: guide Article author = Organization @id, never a Person; image set; publisher carries logo + @id
  PASS  og-home: the share image URL is stamped with the file's time (?v=<mtime>), so the CDN never serves an old card
  PASS  J2 #5: the guide page ships the Organization node the Article's @id points at (same graph)
  PASS  J2 #17: /s/{slug} ships BreadcrumbList Home › Suites › suite ending at /s/j2-suite, plus the Organization node, beside the Product node
  PASS  J2 #18: the product page's JSON-LD carries no shippingDetails and no hasMerchantReturnPolicy

== SPEED follow-up (2026-09-26, O1 08:4x): every store image the home page serves is a WebP variant ==
  PASS  SPEED: no image under assets/store/ on the home page is served as the original JPEG (all are -400/-800 WebP) (got array (
), want array (
))
  PASS  SPEED (store): the flagship block on the home page serves its 800 px WebP shot and the variant file exists — SKIP (dist zip: a store-instance fact)

== SEO COPY WIRE-IN (2026-09-26): 5.1-c's titles/descriptions from views/seo.local.csv; /vs descriptions built at render time ==
  PASS  COPY: a row is found by PATH whatever the host, from a full URL or a bare path
  PASS  COPY: a page with no row gets null
  PASS  COPY: the product page <title> is the copy row (got 'Wire Probe — a title from the copy file', want 'Wire Probe — a title from the copy file')
  PASS  COPY: the product page description is the copy row, verbatim (got 'A description from the copy file for the product page.', want 'A description from the copy file for the product page.')
  PASS  COPY: og:title and og:description carry the same copy
  PASS  COPY: a product with no row keeps its own title and description
  PASS  COPY: an ORGANIC measured title stays on top of the file; the file still supplies the description
  PASS  COPY: a controller-built description (desc_dynamic) is never replaced by the file
  PASS  COPY: the guide <title> is the copy row (got 'Live guide: a shorter title from the copy file', want 'Live guide: a shorter title from the copy file')
  PASS  COPY: the guide h1 and the Article headline keep the STORED title (the file changes the tag, not the guide)
  PASS  COPY (store): the /self-hosted page <title> is the copy row — SKIP (dist zip: a store-instance fact)
  PASS  COPY (store): the /self-hosted description is the copy row — SKIP (dist zip: a store-instance fact)
  PASS  COPY /vs: the description is BUILT from the row the page prints — rival, its first priced clause, the read date, our price, then the file's tail (got 'RivalCo Starter $12 per user/month billed annually, read 2026-09-20. Wire Probe: $99.00 once, self-hosted, full source, no per-user fee.', want 'RivalCo Starter $12 per user/month billed annually, read 2026-09-20. Wire Probe: $99.00 once, self-hosted, full source, no per-user fee.')
  PASS  COPY /vs: the price clause, the read date and our price in the description are printed on the page itself
  PASS  COPY /vs: ≤ 155 characters
  PASS  COPY /vs: the <title> is the copy row (got 'Wire Probe vs RivalCo: one-time wiring', want 'Wire Probe vs RivalCo: one-time wiring')
  PASS  COPY /vs: a price refresh in the data moves the description with the page — the file's example price never shows
  PASS  COPY /vs: an over-long price text is cut at word boundaries to keep the description ≤ 155
  PASS  COPY /vs: without a copy row the tail is the plain one
  PASS  COPY (store): views/seo.local.csv parses — 230 rows, every title ≤ 60, every description ≤ 155, no duplicate url/title/description, only /p /vs /self-hosted /guides paths (and the two indexes), no banned word, every product /vs row carries an "once, " tail — SKIP (dist zip: a store-instance fact)
  PASS  COPY (store): tools/package.sh holds back views/seo.local.csv beside legal.local.php — SKIP (dist zip: a store-instance fact)
  PASS  ST setup: a paid order through the webhook issued a license for the test product

== ST-1. license-gated update check (POST /license/update) ==
  PASS  same version: valid, no update, no download link
  PASS  newer version on the edition: update_available with a /d/ link and its expiry
  PASS  a second check hands back the same usable link (no link minted per poll)
  PASS  once the order's links are used up, a fresh link is minted
  PASS  the minted link resolves to the edition's file
  PASS  "v"-prefixed versions compare as versions (v1.0.9 < 1.1.0)
  PASS  an installed version newer than the store's is not an update
  PASS  no version sent: the latest version and a download link, update_available false
  PASS  a domain the license is not activated on is refused (got 'not_activated', want 'not_activated')
  PASS  the same call from an activated domain answers
  PASS  wrong sku -> invalid_key, as an unknown key (4.0 A4, F5: the endpoint is no oracle) (got 'invalid_key', want 'invalid_key')
  PASS  forged key -> invalid_key (got 'invalid_key', want 'invalid_key')

== ST-1a. one key (4.0 A4, the operator's ruling D-A4-1: the store's key and the Lemon Squeezy key both work) ==
  PASS  A4: the Lemon Squeezy key recorded for a license answers for its edition, as the store's own key does
  PASS  A4 (F5): a key for another product, either kind, answers exactly as an unknown key (got array (
  0 => 'invalid_key',
  1 => 'invalid_key',
  2 => 'invalid_key',
), want array (
  0 => 'invalid_key',
  1 => 'invalid_key',
  2 => 'invalid_key',
))
  PASS  A4: an empty key never matches a license with no Lemon Squeezy key recorded
  PASS  A4 (F5): a refunded order's keys, both kinds, answer as unknown
  PASS  A4: one Lemon Squeezy key on a two-product order answers for each product in it
  PASS  A4: that key for a product outside the order still answers as an unknown key (got 'invalid_key', want 'invalid_key')

== ST-1b. signed manifests on /license/update (the 4.0 in-app updater) ==
  PASS  an edition with no stored manifest: the link as before, manifest and signature null
  PASS  release day: a manifest signed offline by a release key, for exactly this file, is accepted
  PASS  …signed by another key: refused
  PASS  …for another sku or version: refused
  PASS  …describing another file: refused
  PASS  …no release keys configured: refused
  PASS  the stored manifest and signature are handed to the updater with the link, byte for byte
  PASS  no update, so no link: no manifest either
  PASS  a refused license carries no manifest
  PASS  an edition with no file: still valid, says no_download, gives no link

== ST-1c. the handoff on the order page and the receipt (4.0 A4 Task 3) ==
  PASS  A4: a film is read from legal.local only when its address is https, and only for its product
  PASS  A4: a release without the Update button (no signed manifest) and no film: neither line, on the page or in the receipt
  PASS  A4: a signed 4.0 release: the order page and the receipt say where the key goes and that the app never checks by itself
  PASS  A4: a published film: its title, minutes and https link on the order page, and "Watch it done" with the link in the receipt
  PASS  A4: a resent receipt keeps the Updates line
  PASS  A4: a film whose address is not https is left out of the page

== ST-1d. Lemon Squeezy test-mode orders are never sales (4.0 A4) ==
  PASS  A4: test orders are off by default (a new column, 0)
  PASS  A4: a signed Lemon Squeezy TEST-mode purchase is answered and ignored: no order, no license
  PASS  A4: a real (live-mode) purchase is recorded and fulfilled as before
  PASS  A4: a test-mode refund cannot touch a real order
  PASS  A4: an unsigned test-mode payload is still refused (the guard sits after the signature check)
  PASS  A4: with test orders switched on (while an owner sets a store up), a test-mode purchase fulfills
  PASS  A4: Settings offers the switch, and saving the form writes it

== ST-1e. Lemon Squeezy's own license key is recorded (4.0 A4, D-A4-1; the real event of 2026-09-29) ==
  PASS  A4: a paid Lemon Squeezy order keeps Lemon Squeezy's order number (the license event names the order by it)
  PASS  A4: license_key_created records the key on the order's license, in capitals
  PASS  A4: the recorded key answers for its product in either case, and the store's own key still does
  PASS  A4: a key that arrives before its order waits, and the order takes it when it is fulfilled
  PASS  A4: a key Lemon Squeezy disabled stops working here too
  PASS  A4: a test-mode key event changes nothing
  PASS  A4: the order page says the Lemon Squeezy key works too only once that key is recorded
  PASS  A4: the receipt names both keys and prints neither
  PASS  A4 (F5 R1 a): a stranded key is healed by the next event for it, with nothing left waiting
  PASS  A4 (F5 R1 c): a key parked while the order was still being written is applied once it commits, with nothing left waiting
  PASS  A4 (F5 R1 b): the order fulfilled between the key's look-up and its parking still takes the key, with nothing left waiting
  PASS  A4 (F5 A-2): a resent receipt says "either works" only when the Lemon Squeezy key is recorded; else it names the order-page key alone

== ST-2. self-serve deactivation (POST /license/deactivate) ==
  PASS  two domains activated: one slot left of three (got 1, want 1)
  PASS  deactivating shop.example: valid, the slot comes back, 2 of 3 self-serve deactivations left
  PASS  its activation row is gone and the deactivation is logged as self-serve
  PASS  the same domain again -> not_activated (a slot is never given back twice) (got 'not_activated', want 'not_activated')
  PASS  a domain it never held -> not_activated (got 'not_activated', want 'not_activated')
  PASS  garbage domain -> invalid_domain (got 'invalid_domain', want 'invalid_domain')
  PASS  re-activating after a move takes a slot again
  PASS  cap of 1 already used this 30 days -> deactivation_limit, and b.example stays activated
  PASS  a deactivation older than 30 days no longer counts
  PASS  cap 0 -> self-service off (deactivation_off) (got 'deactivation_off', want 'deactivation_off')
  PASS  activations_used never goes below zero

== ST-3. offline license documents (POST /license/offline, GET /license/public-key) ==
  PASS  a domain that is not activated gets no document (got 'not_activated', want 'not_activated')
  PASS  an activated domain gets a signed document; the public key is the one /license/public-key serves
  PASS  the document verifies with the public key alone and names the key, sku, tier and domain
  PASS  it expires after the store's offline days (30 by default)
  PASS  a changed byte fails verification
  PASS  past its expiry it fails verification
  PASS  the key pair is stable across calls (derived, not stored)
  PASS  another install's secret gives another key pair
  PASS  no license_secret (a demo install): no public key, reason offline_unavailable
  PASS  offline days 0 -> offline_off (got 'offline_off', want 'offline_off')
  PASS  a revoked license gets no update (answered as an unknown key, 4.0 A4), no deactivation and no document
  PASS  index.php routes the four license endpoints to handlers that exist
  PASS  the license endpoints share /license/check's per-IP rate limit

== ST-4. discount codes ==
  PASS  create: a bad code is refused (got false, want false)
  PASS  create: SPRING25 for all products
  PASS  create: the same code in another case is a duplicate (got 'That code already exists.', want 'That code already exists.')
  PASS  create: an unknown product is refused (got 'Unknown product.', want 'Unknown product.')
  PASS  create: a malformed date is refused (got 'Dates are YYYY-MM-DD.', want 'Dates are YYYY-MM-DD.')
  PASS  create: an end before the start is refused (got 'The end date is before the start date.', want 'The end date is before the start date.')
  PASS  create: APPONLY for the test product only
  PASS  create: a code with a window in the past and one in the future
  PASS  find: any case finds the stored code, and the stored spelling is what is used
  PASS  find: a product-only code applies to its product, not another, not a suite
  PASS  find: an all-products code applies to a suite (product 0)
  PASS  find: outside its dates a code does not apply
  PASS  find: a code turned off does not apply
  PASS  apply (Lemon Squeezy): checkout[discount_code] and checkout[custom][code] carry it
  PASS  apply (Stripe): prefilled_promo_code, the field Stripe documents (got 'https://buy.stripe.com/x?prefilled_promo_code=SPRING25', want 'https://buy.stripe.com/x?prefilled_promo_code=SPRING25')
  PASS  apply (Stripe): a code Stripe would refuse (not letters and digits) is left off (got 'https://buy.stripe.com/x', want 'https://buy.stripe.com/x')
  PASS  apply: a URL that already names a discount is untouched (got 'https://st.lemonsqueezy.com/buy/abc?checkout[discount_code]=X', want 'https://st.lemonsqueezy.com/buy/abc?checkout[discount_code]=X')
  PASS  apply: the built-in demo checkout is untouched (got '/checkout/demo/1', want '/checkout/demo/1')
  PASS  checkout without ?code= carries the store-wide promo code, as before
  PASS  checkout with ?code=apponly carries APPONLY instead, and counts one checkout
  PASS  a code that does not apply falls back to the store-wide code and counts nothing
  PASS  store-wide promo on Stripe is now prefilled_promo_code (it was checkout[discount_code], which Stripe ignores) (got 'https://buy.stripe.com/x?prefilled_promo_code=STOREWIDE', want 'https://buy.stripe.com/x?prefilled_promo_code=STOREWIDE')
  PASS  buy links carry ?code= from the page (and drop a malformed ref) (got '/buy/7?utm_source=mail&code=SPRING25', want '/buy/7?utm_source=mail&code=SPRING25')
  PASS  buy links are unchanged on a page with neither (got '/buy/7', want '/buy/7')
  PASS  the paid order records the code the store filled in and the discount the provider reported
  PASS  a malformed code or a negative discount from the webhook is not stored
  PASS  the codes page counts that paid order and the discount given, and links to ?code=
  PASS  a product-only code links to that product page
  PASS  Lemon Squeezy webhook: the code and ref come back from custom data, the discount from discount_total
  PASS  Stripe webhook: the affiliate comes back as client_reference_id aff-CODE, the discount from total_details; no code name

== ST-5. download log ==
  PASS  network: IPv4 keeps the /24 (got '203.0.113.0/24', want '203.0.113.0/24')
  PASS  network: IPv6 keeps the /48 (got '2001:db8:abcd::/48', want '2001:db8:abcd::/48')
  PASS  network: anything else is empty (got '', want '')
  PASS  a served download is recorded with its network and browser, never the full address
  PASS  used up and expired attempts are recorded as such (got array (
  0 => 'served',
  1 => 'served',
  2 => 'used_up',
  3 => 'expired',
), want array (
  0 => 'served',
  1 => 'served',
  2 => 'used_up',
  3 => 'expired',
))
  PASS  an unknown token is not recorded (there is no order to attach it to) (got 5, want 5)
  PASS  order summary: 2 served from 2 networks, 2 refused (got array (
  'served' => 2,
  'refused' => 2,
  'networks' => 2,
), want array (
  'served' => 2,
  'refused' => 2,
  'networks' => 2,
))
  PASS  the log searches by buyer email
  PASS  a customer's subject-access export carries their download log
  PASS  the GDPR export and anonymize handlers call the download log
  PASS  with the log off nothing is recorded (got 5, want 5)

== ST-6. card-testing guard ==
  PASS  3 buyers (one of them twice) under the threshold of 4: no flag, no email
  PASS  an order over the cheap limit does not count
  PASS  the 4th buyer flags every cheap order in the burst (5 orders), and nothing else (got array (
  0 => 'st-c1',
  1 => 'st-c2',
  2 => 'st-c3',
  3 => 'st-c3b',
  4 => 'st-c4',
), want array (
  0 => 'st-c1',
  1 => 'st-c2',
  2 => 'st-c3',
  3 => 'st-c3b',
  4 => 'st-c4',
))
  PASS  the owner gets one email for the burst (got 1, want 1)
  PASS  a 5th order joins the burst: flagged, and no second email
  PASS  record, never determine: every flagged order is still paid and its license still active
  PASS  clearing a flag records who and when, and leaves the order alone
  PASS  threshold 0 turns the guard off

== ST-7. affiliates ==
  PASS  program off: no attribution at all
  PASS  create: a bad code is refused (got false, want false)
  PASS  create: a bad email is refused (got 'That email address is not valid.', want 'That email address is not valid.')
  PASS  create: over 100% is refused (got 'The commission is a percentage from 0 to 100.', want 'The commission is a percentage from 0 to 100.')
  PASS  create: ann at 20%, self at 12.5%, the default when empty
  PASS  create: codes are unique in any case (got 'That code is taken.', want 'That code is taken.')
  PASS  commission rates are stored in basis points (got array (
  'ann' => 2000,
  'bob' => 1250,
  'cat' => 2000,
), want array (
  'ann' => 2000,
  'bob' => 1250,
  'cat' => 2000,
))
  PASS  a ?ref= visit counts one click and remembers the code
  PASS  the same visitor with the same code is not a second click (got 1, want 1)
  PASS  later pages: the cookie still attributes the visit (got 'ann', want 'ann')
  PASS  ?ref= on the page wins over the cookie (got 'bob', want 'bob')
  PASS  apply (Lemon Squeezy): checkout[custom][ref] (got 'https://st.lemonsqueezy.com/buy/x?checkout%5Bcustom%5D%5Bref%5D=ann', want 'https://st.lemonsqueezy.com/buy/x?checkout%5Bcustom%5D%5Bref%5D=ann')
  PASS  apply (Stripe): client_reference_id=aff-CODE (got 'https://buy.stripe.com/x?a=1&client_reference_id=aff-ann', want 'https://buy.stripe.com/x?a=1&client_reference_id=aff-ann')
  PASS  apply (Stripe): a link that already has a client_reference_id is left alone (got 'https://buy.stripe.com/x?client_reference_id=z', want 'https://buy.stripe.com/x?client_reference_id=z')
  PASS  apply: the demo checkout is left alone (got '/checkout/demo/1', want '/checkout/demo/1')
  PASS  a checkout from a visit that came by ann's link carries ref=ann
  PASS  a paid order with ref=ann records 20% as held, due after the 30-day hold, and the order names the affiliate
  PASS  a duplicate webhook records no second commission (got 1, want 1)
  PASS  the affiliate buying through their own link: recorded as rejected (self-referral), with the rounded-down amount
  PASS  an unknown or switched-off affiliate earns nothing (got 0, want 0)
  PASS  a refund during the hold reverses the commission (got 'reversed', want 'reversed')
  PASS  past the hold the commissions are payable, and the payout CSV has one line for ann: 2 commissions, 26.00 USD
  PASS  the CSV guards against formulas (the name "=Ann" is written as text)
  PASS  mark paid: only approved commissions in the list are paid (a held one slipped in is not) (got 2, want 2)
  PASS  paid commissions carry a paid date
  PASS  a refund after payout leaves the commission paid, with a note (getting it back is the seller's call)
  PASS  stats: ann has 4 commissions, 26.00 paid and 1.00 held
  PASS  the stats page token finds the affiliate; anything else finds nothing

== ST-8. the new pages render ==
  PASS  Selling tools renders its flags, download log and settings with no PHP warning
  PASS  Discount codes renders the codes with their links, with no PHP warning
  PASS  Affiliates renders affiliates, commissions and the self-referral note, with no PHP warning, and escapes the name
  PASS  an affiliate's own page shows their link and totals, nobody else's orders or emails
  PASS  order page: an open card-testing flag shows, and says nothing was refunded
  PASS  order page: a cleared flag does not show
  PASS  order page: the download log line (served, networks, refused) links to the log for this order
  PASS  order page: the discount code and the discount the provider reported
  PASS  order page: the affiliate and the commission
  PASS  /admin/selling: support may read it (flags and download log), with no Clear button and no settings form
  PASS  /admin/selling: an admin gets the page and its settings form
  PASS  admin_discounts (GET): support is refused, an admin gets in
  PASS  admin_affiliates (GET): support is refused, an admin gets in
  PASS  POST /admin/selling/flags/{id}/clear: support is refused and the flag stays open (W7 8a)
  PASS  POST /admin/selling/flags/{id}/clear: an admin clears it, recorded against the admin
  PASS  the other 7 POSTs of the selling tools refuse support
  PASS  the payout CSV (GET) refuses support
  PASS  every admin POST routed to controllers/selling.php (8) checks the CSRF token and the signed-in user
  PASS  every admin handler there (12) requires a signed-in user
  PASS  index.php captures ?ref= before routing and routes the 13 new pages
  PASS  anonymizing the customer blanks the network and browser, and keeps the attempts
  PASS  ownware.io keeps the download log off (O1, 2026-09-26): bin/ownware-store.php, run on every store deploy, sets it to 0 and keeps no rows — SKIP (dist zip: a store-instance fact)
  PASS  the product default stays on for a buyer's install (a new settings row reads download_log 1) (got 1, want 1)

== 3.2.1: the shipped views and assets name no store ==
  PASS  no shipped view, script or stylesheet names a store (STRICT_SOURCE) (got array (
), want array (
))
  PASS  the store-only list above is the packager's own (every name is removed by tools/package.sh) — SKIP (dist zip: a store-instance fact)
  PASS  views/legal.local.php carries the facts the shipped views now read (cloud, badge, OS suite, footer, FAQ) — SKIP (dist zip: a store-instance fact)

== 3.2.1: the shareable backup leaves out license keys, tokens, session keys and redeem codes ==
  PASS  backup: every licenses.license_key is redacted (39 rows)
  PASS  backup: every carts.session_key is redacted (1 rows)
  PASS  backup: every downloads.token is redacted (41 rows)
  PASS  backup: every affiliates.token is redacted (3 rows)
  PASS  backup: every redeem_codes.code is redacted (1 rows)
  PASS  backup: the same column names blank the public discount and affiliate codes too (the core matches by column name)
  PASS  backup: the shared core is untouched — the list is Deliora's own (DELIORA_BACKUP_REDACT), applied by BackupExport and BackupRestore alike
  PASS  restore onto a working install: every license key, token, session key and code (discount and affiliate ones included) keeps its live value
  PASS  docs/MANUAL.md says where keys and codes come back from on a rebuild (the SQLite file or mysqldump) — SKIP (dist zip: a store-instance fact)

== 3.2.1: the product import's dry run agrees with the import ==
  PASS  dry run: 1 ready, 3 skipped (existing slug, a slug repeated in the file, a row without a name) (got array (
  0 => 1,
  1 => 3,
), want array (
  0 => 1,
  1 => 3,
))
  PASS  dry run writes nothing (got 11, want 11)
  PASS  the import takes exactly what the dry run said (got array (
  0 => 1,
  1 => 3,
), want array (
  0 => 1,
  1 => 3,
))
  PASS  and skips the same lines for the same reasons
  PASS  the imported row is an inactive draft, the existing product untouched
  PASS  the upload handler dry-runs through the import's own handler, not run()'s dry-run mode

== SITE SURVEY PHASE 0 (2026-09-27): no sideways scroll, no word cut in half, one address for prices ==
  PASS  phase 0: a code block and a doc table scroll inside their own box, an inline code chip wraps — the page never scrolls sideways
  PASS  phase 0: excerpt() cuts on a word boundary and marks the cut; short text and one long word are handled
  PASS  phase 0: no view shortens visible text with clip() (the DB column guard); /self-hosted, search, /vs, alternatives and the catalog use excerpt()
  PASS  phase 0: /prices (and /plans, /plan, /plans-pricing, /pricing-plans) answer 301 to /pricing

== D-15 REDESIGN behind a switch: home, product, catalog, pricing ==
  PASS  switch off: the home renders today's layout (store.css, no site.css)
  PASS  switch on: the home renders the redesign (site.css, body class "site", the hero, one h1)
  PASS  switch on: the redesigned pages load neither style.css nor store.css (speed; the partials' utilities live in site.css), and request the two fonts before any stylesheet
  PASS  help widget (operator 19:59): redesigned pages carry help_widget_v2 (quick actions, search, /faq.json), never the old one; the license question is in the shared list
  PASS  the help launcher is the green Q&A bubble the thank-you page names (round, the site green, labeled Q&A)
  PASS  the two designs never share an ETag (a browser cannot revalidate one into the other)
  PASS  hero deck (operator 18:01): one card per app with a dashboard shot, the first loaded at once and never lazy, the rest deferred until they come near (no shots on this store: no deck rendered)
  PASS  switch on: the real-dashboards rack carries every active app that has a dashboard shot (operator 17:33), the flagship outside it (no shots on this store: no rack rendered)
  PASS  switch on: the home shows every active suite, whole-catalogue bundles included (operator 17:35 "shouldnt all suites be there?")
  PASS  switch on: the home links every active app on the shelf
  PASS  product (sc-invoicer), switch on: the redesign renders (site.css, the sticky buy card #pricing, #licence, #zip, one h1)
  PASS  product (sc-invoicer): every wired edition buys through its own /buy/{id} link, and the license choice names each one
  PASS  product (sc-invoicer): the buy box says updates are included, and Runs on names Docker (4.0 A4 Task 4)
  PASS  product (sc-invoicer): the one-line install names this product and the store's own address, and only where the store serves /install.sh (4.0 A4)
  PASS  product (sc-invoicer), launch mode: no /buy/ link anywhere, and the notify card is there instead
  PASS  product (sc-invoicer), switch off: today's page, and a different ETag from the redesign
  PASS  A4: every product this store sells ships a Dockerfile, as its page says
  PASS  catalog, switch on: one card per active app (the flagship outside the groups), each linking its page
  PASS  pricing, switch on: every active app has a row with its live Single price (or "Available soon" when unwired)
  PASS  the checkout-overlay script has one copy, the partial, and both product templates include it
  PASS  every public validator carries the design (one helper, not per route), and every cache path marks a preview private
  PASS  switch on: a page with no redesigned template (/legal) renders its own body in the new chrome, restyled by site-legacy.css (never store.css); switch off: unchanged
  PASS  guides, switch on: the redesigned index lists every published guide once, in the new chrome without the old styles
  PASS  guide, switch on: the article renders its body, its Article JSON-LD and a side panel of the apps it mentions
  PASS  suites, switch on: the redesigned index links every active suite, in the new chrome without the old styles
  PASS  suite, switch on: the redesigned page shows every member app, the buy card (#buy) and the suite FAQ, prices read live
  PASS  comparisons, switch on: the redesigned index has one card per comparison page, without the old styles
  PASS  comparison (sc-invoicer), switch on: every cited row has its numbered source, and the calculator's ids are all there
  PASS  alternatives, switch on: the savings wall has one card per cited rival, without the old styles
  PASS  alternative (rentco), switch on: the page cites its source (#src-1) and renders without the old styles
  PASS  by kind of business, switch on: the redesigned index has one card per hub with a live product, without the old styles
  PASS  no unprefixed class in the redesign is styled on its own by style.css (got array (
), want array (
))
  PASS  product (sc-invoicer): the demo sits under the name and price, before the screenshots, in its own color
  PASS  home rack: every card has a button to its page, and a demo button only while demos are live
  PASS  every product gallery shot is a real screen: no flagged defect, no near-blank capture (got array (
), want array (
))
  PASS  the switch exists only where the store has chosen a design: ?design=v2 does nothing here (no design chosen, as in a buyer's copy)
  PASS  ST cleanup: the test zip it stored in data/files is gone

== 2026-09-26 09:2x: O1's ruling on research 08 #7 (two ORGANIC keys lifted) + O2's admin nav links ==
  PASS  RULING: p:complia and p:safora are no longer measured; the other eight product/suite/alt/guide keys and p:tokora stay
  PASS  RULING (store): the copy file now titles /p/complia and /p/safora — both name what the product records, carry "self-hosted", never "compliance", and fit 60 — SKIP (dist zip: a store-instance fact)
  PASS  RULING (store): with the key lifted, seoCopyApply lets the file title /p/complia and /p/safora; /p/approva keeps its ORGANIC title — SKIP (dist zip: a store-instance fact)
  PASS  NAV (O2 3.2.0): the admin layout links Selling tools, Discount codes and Affiliates in the Sell group, after Downloads
  PASS  NAV (O2 3.2.0): the three links render only when controllers/selling.php exists, and the flags badge only when CardTestGuard is loaded — a tree without 3.2.0 selling tools shows no dead link
  PASS  NAV (audit 8b): Discount codes and Affiliates are inside if ($isAdm) — after Selling tools, before the Catalog group — and Selling tools is outside it
  PASS  NAV (O2 3.2.0): the three icons exist in ui_icons.php (security, tag, team) and the links carry no data-key shortcut that could clash

== JOB 3 (2026-09-26): research 08 #9 #10 #20 + 07 §4 #4 #6 #10 ==
  PASS  J3 #9: guidesMentioning with cap 0 returns EVERY guide whose body links the product (7 more than before), cap 5 still returns 5 (got array (
  0 => 7,
  1 => 5,
), want array (
  0 => 7,
  1 => 5,
))
  PASS  J3 #9: the product page links all 7 mentioning guides — five in full and the rest in the "Also covered in" row (got array (
  0 => 7,
  1 => 1,
), want array (
  0 => 7,
  1 => 1,
))
  PASS  J3 #9 (store): /for-agents links /badge — SKIP (dist zip: a store-instance fact)
  PASS  J3 #10: relatedLinks(product) carries the product's own /vs page, kind "vs", right after its category pages
  PASS  J3 #10: on the /vs page itself the block never links the page it is on
  PASS  J3 #10: the rendered /vs page carries the related block without a self-link, and 3–5 links (U6 kept)
  PASS  J3 #20: a document page carries og:image = Storefront::coverUrl(product)
  PASS  J3 07#4: the store nav carries the CSS-only Menu control before its links, and Prices + Guides are the kept links
  PASS  J3 07#4: at ≤760 px the nav no longer scrolls sideways, links are 44 px tall, non-kept links hide until the Menu checkbox is checked
  PASS  J3 07#4: the rendered home carries the Menu control once and the two kept links
  PASS  J3 07#6: the flagship grid on the classic home carries its grid class and store.css stacks it below 640 px
  PASS  J3 07#10: with ours + one rival (two cards) no "Show all" control is rendered
  PASS  J3 07#10: with ours + two rivals the checkbox precedes .pricetbl and the "Show all 3 plans" label follows the cards
  PASS  J3 07#10: store.css hides the cards after the second at ≤390 px until the control is checked, and .tiercard .t is at least 12 px

== COPY-RISK (2026-09-26): research 10 findings applied at source; pinned old sentences never render again ==
  PASS  COPY-RISK checks skipped (dist zip build - store_instance.php absent)
  PASS  COPY-RISK F-86 meta check skipped (dist zip build - store_instance.php absent)

== 4.0: the Update button is wired ==
  PASS  updates: the release public key is pinned
  PASS  updates: Database::migrate() creates the updater table (in code, not in a comment)
  PASS  updates: the updater table holds exactly one state row
  PASS  updates: index.php clears stale compiled code before anything else loads
  PASS  updates: index.php finishes or rolls back an update before any controller loads, with this app's canary
  PASS  updates: route get /admin/updates is updates_page()
  PASS  updates: route post /admin/updates/key is updates_key_save()
  PASS  updates: route post /admin/updates/check is updates_check()
  PASS  updates: route post /admin/updates/apply is updates_apply()
  PASS  updates: updates_page() opens with the restore page's admin gate
  PASS  updates: updates_key_save() opens with the restore page's admin gate
  PASS  updates: updates_check() opens with the restore page's admin gate
  PASS  updates: updates_apply() opens with the restore page's admin gate
  PASS  updates: every POST checks the CSRF token
  PASS  updates: the page is told why updates are off here (Updater::whyOff, F5 A1 pass)
  PASS  updates: the menu links to the page
  PASS  updates: bin/update.php refuses the web and finishes in a fresh process
  PASS  updates: where updates are off (Cloud, the demo, a missing extension) the page says why and offers no button
  PASS  updates: with a key and a newer signed release the page offers "Back up and update"
  PASS  updates: the ownware.io store never registers the Update routes (views/legal.local.php marks it)
  PASS  updates: bin/update.php refuses to run on the ownware.io store

== 4.0 A2: sample data, backups, health ==
  PASS  A2: the owner's-install checks ran to the end
  PASS  A2: an owner's own install (no Cloud config, no demo marker)
  PASS  A2: a fresh install's sample tables are all empty, so the installer's sample data can go in
  PASS  A2: switched off: an install without "Start with sample data" gets none
  PASS  A2: first request: the marker is used once and the sample business is there
  PASS  A2: every one of the owner's settings is theirs, not the sample's
  PASS  A2: the sample logins cannot sign in: no password matches, and switched off where the product can
  PASS  A2: the owner's own login is untouched
  PASS  A2: the load is in the audit log
  PASS  A2: a second marker on a Deliora with records seeds nothing and changes nothing
  PASS  A2: real use: one of the owner's own rows among the sample
  PASS  A2: remove: the owner's own row stays, and a sample row it points at is kept
  PASS  A2: remove: the report says what went, and what was kept because the owner's records use it
  PASS  A2: remove: the rest of the sample is gone
  PASS  A2: remove: every kept sample row keeps all its own parts
  PASS  A2: remove: no row is left pointing at a row that was removed
  PASS  A2: remove: the sample logins are gone but the ones kept rows use, the owner stays, and nothing counts as sample any more
  PASS  A2: remove: the sample's files are gone from disk (8 written; one a row that stays names stays with it)
  PASS  A2: remove: the owner's own uploaded file is untouched
  PASS  A2: "Load demo data" on a Deliora with records refuses and deletes nothing
  PASS  A2: backup: written, sent to the bucket (a signed PUT), and read back
  PASS  A2: backup: the bucket secret is not in the backup it protects
  PASS  A2: backup: under the standard keep count (14) every backup is kept
  PASS  A2: switched off: with automatic backups off, a backup is never due however old the last one
  PASS  A2: the backup lines for Backup & restore: last backup fine, offsite fine, read-back fine
  PASS  A2: an offsite endpoint must be https for an owner (plain http only in tests)
  PASS  A2: all 25 owner's-install checks reported
  PASS  A2: Database::migrate() creates the sample-data and backup tables (in code, not in a comment)
  PASS  A2: index.php loads the three cores and the wiring
  PASS  A2: the first request seeds the installer's sample, and an owner's install backs itself up when due
  PASS  A2: route post /admin/sample-data/remove is sample_data_remove()
  PASS  A2: route post /admin/restore/backups is backups_save()
  PASS  A2: route post /admin/restore/backup-now is backup_now()
  PASS  A2: route get /admin/health is health_page()
  PASS  A2: sample_data_remove() opens with the restore page's admin gate
  PASS  A2: backups_save() opens with the restore page's admin gate
  PASS  A2: backup_now() opens with the restore page's admin gate
  PASS  A2: health_page() opens with the restore page's admin gate
  PASS  A2: every POST checks the CSRF token
  PASS  A2: off an owner's install (Cloud, the demo) the backup settings and "Back up now" refuse in words
  PASS  A2: the installer offers sample data, ticked, and only leaves the first-run marker (with every table's row count)
  PASS  A2: the first run is strictly once (a marker that cannot be removed seeds nothing), and treats the installer's rows as its defaults only while nothing has changed
  PASS  A2: the scheduled-backup URL runs the same backup (offsite copy and read-back included)
  PASS  A2: "Load demo data" outside the public demo goes through SampleData (only into an empty Deliora, removable)
  PASS  A2: the menu links to Health
  PASS  A2: the sample-data banner shows only to an admin, only while sample data is there, with its Remove button, and stays put (nested: the UI kit toasts a bare .main > .alert away)
  PASS  A2: Backup & restore shows the automatic backups, "Back up now" and the bucket settings (none on Cloud)
  SKIP  A2: the Health page render (this suite loads no view helpers in its own process; the owner's-install check and update_e2e.sh run the real app)
  PASS  erase planting · sqlite: the subject is created and a marker is planted in all 8 personal columns
  PASS  erase planting · sqlite: the app's own erase runs through its route and reports no failure
  PASS  erase planting · sqlite: no planted marker survives anywhere (48 tables dumped)

== 4.1.2: Stripe fulfils only what was paid, and a refund takes it back (S-1, S-2) ==
  PASS  S-1: a completed checkout whose payment is still unpaid fulfils nothing (HTTP 200)
  PASS  S-1: ... and when that payment fails, still nothing (HTTP 200)
  PASS  S-1: a delayed payment that settles (async_payment_succeeded) is fulfilled once (HTTP 200)
  PASS  S-1: ... a resend of it fulfils nothing twice
  PASS  S-1: a completed checkout that is paid is fulfilled as before (HTTP 200)
  PASS  S-1: a completed checkout with no payment required (a full discount the seller made) is fulfilled
  PASS  S-1: a completed checkout without payment_status (older Stripe API versions) is fulfilled as before
  PASS  S-2: the paid order keeps its payment_intent
  PASS  S-2: a Stripe refund marks the order refunded (HTTP 200, {"ok":true,"refunded":true})
  PASS  S-2: ... and revokes its licenses
  PASS  S-2: ... and no other order changed
  PASS  S-2: a refund for a payment the store never fulfilled changes nothing (HTTP 200)

————————————————————————————————————————————————————
ALL 1831 TESTS PASSED (0 failed)

← Back to Deliora · Manual · Quickstart · API