Skip to content

Client reports: verified accuracy fixes, Clients tab, and fleet fixes for replacing ManageWP - #63

Merged
aaronreimann merged 68 commits into
mainfrom
claude/festive-clarke-lz9f4z
Nov 6, 2026
Merged

aaronreimann merged 68 commits into
mainfrom
claude/festive-clarke-lz9f4z

Conversation

@aaronaltman

Copy link
Copy Markdown
Collaborator

Why

The first scheduled run of Control's client reports (Nov 1) compiled a month of drafts and held every one for review, which is intended. We audited those drafts against a copy of production data to see whether the reports are good enough to replace ManageWP's. Several reports contained wrong or misleading content, and some of the causes were fleet bugs rather than report bugs. This PR fixes them and adds the UI the team needs to review, navigate and send reports.

How we verified:

  • Each fix was checked against production-shaped data, recompiling and rendering every held draft without sending anything.
  • Every figure in every rendered email was then recomputed independently from the raw tables.
  • No client data is in this PR: tests and fixtures use invented values.

Client reports: content

  • Malware
    • A malware scan that stopped early reads "Partial scan", never "No malware found".
    • Review reasons name the finding kinds and the scan id.
    • Matches on the scanner's own signature file are ignored.
    • Operators can mark review-tier findings reviewed, which shows the client "Reviewed — no threat found". The mark survives Recompile.
  • Partial months
    • Sites added, or put on a care plan, part-way through the month are measured from that date and held for review.
    • HTTP 429 (the monitor being rate-limited) is not downtime.
    • Outages of an hour or more are held for a person to add context.
  • Updates
    • Dates use the report timezone.
    • Updates applied outside Control (WordPress auto-updates, other tools) are listed.
    • "No updates were needed" is never claimed.
    • The agency's own Companion/Renegade plugins and updates that didn't stick are left out.
    • A month with updates waiting but none applied is held.
  • Security
    • "Malicious visitors blocked" counts only real bans, and only when lockouts reached Control all month.
    • The core-files row is omitted on hosts that can't run the check.
    • Vulnerable-plugin holds match by slug.
  • Backups and traffic
    • Suspiciously small database backups, failing or late-starting off-site relay archives, and too few backup days are held.
    • Traffic gets a status (ok / partial / suspect / no data / unsupported) and is shown only when complete and believable, e.g. not when every request comes from a load balancer.
  • Presentation
    • The greeting uses the actual recipient, otherwise "Hi there,".
    • The support URL never falls back to Control's private URL.
    • Periods are whole days.
    • The From name is explicit.
    • Uptime is coloured by value.
    • The plain-text email matches the HTML.
    • The preview, printout and public page use the same phrases as the email.
  • Safety
    • Recompile, as a button and as clockwork:recompile-held-client-reports, rebuilds an unsent report from current data and never sends.
    • Reports compiled under older rules (schema < 7) can't be sent until recompiled.
    • wp-admin no longer shows reports as active for sites without a Control schedule.

Client reports: UI

  • Clients tab (/client-reports, new landing page):
    • Clients grouped by care plan. Care-plan clients come first, with those needing attention at the top; clients without a care plan are collapsed.
    • Each site's care-plan badge, schedule, recipients, and a six-month report history whose cells link to each report.
    • A page per client with its full report history, previous/next client, and a switcher.
  • Reports list (now /client-reports/reports):
    • Period, status, hold-reason and search filters, with summary counts.
    • Reason chips, labelled actions, and "Recompile all outdated / selected".
  • Report review:
    • A toolbar with previous/next, Email version, Recompile, Print, Test to me, and Send. Send explains who gets the email and what a first send changes. Keyboard shortcuts are included.
    • Review reasons are grouped, with links to the relevant site tab.
    • The email version page uses the app layout, with HTML and plain-text tabs.
  • Scheduling:
    • Care-plan schedules come first.
    • One-click (and "Create all") schedules for care-plan sites that have none.
  • Site dashboard: a "Client reports" link in the site header.

Fleet fixes found through the reports

  • Uptime: HTTP 429 moves a site to a probe_blocked state instead of down. Real outages stay open through 429s, so down alerts aren't re-sent.
  • Plugin updates:
    • Advisories are matched on the plugin directory.
    • Auto-paused plugins are retried once per new release or security fix.
    • A successful update lifts the pause.
    • Real upgrader errors are recorded with a reason_code.
    • Repeated file-permission failures pause the site once.
    • The nightly run happens once per local date (DST).
  • Redaction: licence keys, tokens and signed download URLs are stripped before storage, chat, or the Companion mirror.
  • Vulnerabilities: exact-version advisories (== X) no longer match every older release. A migration repairs existing rows.
  • Theme identity guard: the nightly updater won't replace a custom or child theme with an unrelated wordpress.org theme that shares its folder name.
  • Observed updates: updates made outside Control are recorded from snapshot changes as local-only activity.
  • Backup relay:
    • Relay can't be enabled without Companion backup-create.
    • Per-site last error and failing-since are stored.
    • Stale archive hashes are cleared.
  • Malware scans:
    • Finding paths are normalized so suppressions work.
    • New PHP files in uploads alert once.
    • SSH scans must prove each check finished.
  • Other fixes:
    • Contact-form tests no longer skip days.
    • Pressable traffic is recorded before the report run without zero-filling history.
    • Bill.com matching handles www. and every fleet TLD.
    • Standalone sites are flagged as WordPress.
    • Checksum runs skip hosts with no transport.
    • The performance rotation is stable, with a month-end catch-up.

Testing

  • Full Pest suite: 3,476 / 3,479 pass. The 3 failures happen only in the sandbox: StorageWritableCheck "real permission error" (it runs as root) and two InstallerWizardTest "executes full installation" tests, which need a live MySQL server. All three fail the same way on main there.
  • The merge of latest main (v1.12.0 and dependency bumps) was tested on its own as well.
  • Every rendered email was checked for:
    • leaked internals (scan ids, file paths, review reasons);
    • localhost or private URLs;
    • unmeasured zeros;
    • partial scans shown as clean;
    • figures recomputed from the raw tables.

Upgrade notes

  1. php artisan migrate. New columns on sites, plugin_update_jobs and plugin_update_failure_streaks, a backfill of sites.is_wordpress for standalone Companion sites, and a repair of exact-version vulnerability rows.
  2. npm run build and clear the view cache.
  3. php artisan queue:restart.
  4. Run clockwork:sync-bill-customers --dry-run and clockwork:sync-bill-care-plans --dry-run and review them before the next real sync. Normalized matching can link www. sites and change care-plan flags.
  5. Run php artisan clockwork:recompile-held-client-reports --outdated --dry-run, then again without --dry-run. It never sends.
  6. Set CLOCKWORK_GTMETRIX_DAILY_CREDITS, and optionally CLOCKWORK_REPORTS_TIMEZONE. The app host needs HTTPS access to api.wordpress.org.
  7. /client-reports now opens the Clients tab. The reports list moved to /client-reports/reports.
  8. Rows stored before this release in plugin_update_jobs.messages and action_logs.details still contain unredacted upgrader output. Scrub them separately.

Not in this PR

  • Operational items found during the audit are tracked separately, outside the repo: backup relay permissions, host file permissions, plugin licences, and Gatekeeper lockouts no longer arriving since the Companion 1.40 capability change.
  • A follow-up to self-host the printable report page's CSS and icons. It currently loads the Tailwind Play CDN.

Generated by Claude Code

claude added 30 commits November 3, 2026 19:52
…engths, explicit From

Branding: the compiler fell back to config('app.url') for the stored
support_url whenever both the reports and the shared Companion support URLs
were blank. Control runs on a private host, so every report carried a
localhost address that would leak the moment any view rendered it. The
support URL now falls back to the agency website and then null. The dead
non-branding-manager branch (and its unused Settings dependency) is gone.
The branding manager itself is unchanged: a blank Companion support URL
still hides Companion's support button.

Settings: the reports support URL and the shared support / agency website
URLs must now be public http(s) addresses. Localhost, loopback, private and
reserved IPs, single-label and internal-only names, and the app's own URL
are rejected; blank is still accepted. The reports tab help text states the
real fallback order.

Metadata: Carbon 3's diffInDays returns a float, and the period end is the
last microsecond of the day, so meta.period_days was stored as 31.99... for
a 31-day month and uptime total minutes came out one short. Both are now
whole numbers (31 / 44,640 for a 31-day month, 30 / 43,200 for 30 days).
SCHEMA_VERSION is bumped to 4.

Email: the envelope now sets From to the configured sending address under
the report brand's company name, so the inbox no longer depends on
MAIL_FROM_NAME (by default the internal product name). The operator's
"Email version" page shows that From line. The plain-text part now prints
the footer text when one is set, matching the HTML part.

Wording: template section descriptions promised data the report never
contains (referrers, page views, TTFB, translation upgrades, restore
readiness, form deliverability). They now describe what is compiled. The
contact-form testing docs no longer claim inbox delivery verification:
scheduled tests run in lab mode with the email suppressed, and live runs
only record whether the site reported the email as sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
The scheduled form-test run starts at a fixed time each day, but each
test's last_test_at is stamped when the test finishes, a few minutes
later and at a slightly different minute every day. The due filter used
a strict 24-hour / 7-day cutoff, so whenever today's run reached the
filter earlier in the day than the previous test had finished, the row
was treated as not yet due and skipped. Daily tests ended up running
roughly every other day and weekly tests every eighth day, which also
under-counted the checks shown in monthly client reports.

Daily rows are now due once last_test_at is more than 20 hours old and
weekly rows once it is more than 7 days minus 4 hours old. The margins
absorb the run-time drift without letting a row run twice in the same
daily window or turning weekly into a six-day cadence.

Adds a regression test frozen just after the scheduled start time with
the previous runs stamped a few minutes later in the day, and updates
the cadence step in the contact-form testing docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…ut zero-filling history

The Pressable traffic job ran at 06:37 UTC, after the 06:00 client-report
run, so a month's report compiled from a last-day row holding only the
first hours of that day. It now runs at 04:45 UTC: Pressable's buckets
are UTC days, so the previous day is complete by then, and the ~15-20
minute run ends well before 06:00. A test pins that ordering.

The command also upserted the zero-filled 30-day Companion payload into
site_traffic_daily, so any day the API had no bucket for (for example
before a site moved to Pressable) overwrote earlier history, including
the previous host's access-log rows, with zeros. It now writes only the
days, and per day only the metric family (requests or uniques), that
Pressable actually returned. Empty buckets count as no data. Only the
last 28 days are written, because the API's "Past 1 month" window can
begin part-way through a day as recent as 28 days ago. The Companion
payload stays zero-filled over 30 days, as its chart expects.

The history write now happens before, and independently of, the
Companion capability check and push, and covers every Pressable site
with a Pressable site id rather than only Companion-equipped ones. A
Companion outage or a site without Companion no longer leaves its
client report without traffic. Top paths are fetched only for sites
that get the push.

Docs updated for the new time and behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
A site behind a host rate limiter or a security plugin's anti-bot
challenge can answer the uptime monitor with 429 Too Many Requests while
real visitors load it fine. The prober treated 429 as a plain failure, so
two in a row moved the site to down and a long block showed up as a
multi-day outage in uptime figures and client reports.

- UptimeProber returns UptimeProbeResult::blocked() for 429 (not a
  success, not a plain failure; Retry-After kept, capped).
- UptimeStateUpdater routes blocked probes to a new probe_blocked state:
  the failure streak is not incremented, last-up is not set, and the site
  never goes down on a 429. Entering the state records a probe_blocked
  site_uptime_events row and an action log entry, so stats and the event
  feeds never count it as downtime. A down site that starts answering 429
  has its outage closed (recovery is not claimed). A real outage while
  blocked still goes down after the usual threshold; a success,
  maintenance or a down transition ends the blocked stretch.
- After N hours blocked (Monitoring -> Settings, default 6) one
  uptime_probe_blocked ops chat notice is sent per stretch through
  ChatNotifier. It honours the per-site ignore flag, is skipped for
  inactive sites by the dispatcher, can be switched off per channel like
  other events, and never reaches client Slack or SMS.
- New sites columns uptime_probe_blocked_since and
  uptime_probe_blocked_alerted_at (migration).
- Monitoring board, site widget, sites list and recheck endpoint show
  the blocked state instead of down/unknown; docs updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
… once per local date

Vulnerable-first ordering never matched. The nightly runner looked up
each Companion slug ("dir/file.php") in a map keyed by the vulnerability
matcher's directory-only slug, so is_vulnerable was false for every
plugin in a folder. It now compares the directory part, and it keeps the
highest patched_in per directory for the pause logic below.

Auto-failure pauses never expired. Once a plugin failed N nightly runs it
was dropped before any vulnerability check, so later releases, including
security fixes, were never attempted. filterIgnored still honours manual
ignores, but lets an auto_failure pause through when:
  - the offered version differs from the streak's last_target_version
    (one attempt per new release, because a failed attempt records it as
    the new last target), or
  - the installed version is vulnerable and the offered version is at or
    above the highest patched_in of the matching advisories.
A successful update that reaches its target version now deletes the
slug's auto_failure ignore, clears the streak's pause markers, and
re-pushes the exceptions list so the wp-admin "paused" notice clears.

The chained 01:30 America/New_York nightly fired twice on the DST
fall-back night, so failures were counted twice in one day. The command
now skips (with a log line) when a fleet-wide nightly batch was already
queued during the current local date, judged by created_at so 23/25-hour
days are handled. --force overrides. --site runs are not guarded and get
a "nightly-<date>-site-" batch id, so they never block the fleet run.
Batch ids now carry the local date (the same as before for the
scheduled run).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…se site on fs errors

Companion and Renegade builds in the field return ok=true and
upgrade_completed=true even when the WordPress upgrader logged an
"Error: ..." line (could not copy file, could not create directory,
download unauthorized, bad zip). AbstractRunUpdate treated every such
stalled no-op as "WordPress's own update-checker no longer offered this
update (premium/licensed)", hiding the real cause from the job error,
the failure streak, the auto-ignore and the ops chat alert, and still
ran post-update verify/repair.

Inside the stalled no-op branch the job now looks for the last
"Error:" line in the upgrader output. When there is one, the error
records that line plus the line before it (never a download line),
upgrade_completed is set to false so verify/repair is skipped, and a
reason_code is stored (fs_permission, download_unauthorized,
bad_package, no_package, upgrader_error). Without an Error: line the
old explanation is kept with reason_code not_offered. When Companion
reports ok=false itself, its error text is kept and the reason code is
still derived from the output. The action-log summary now follows the
job's verdict, so a failed upgrade is no longer logged as "Plugin
updated: 1.0 -> 1.0". reason_code is a new nullable column on
plugin_update_jobs and plugin_update_failure_streaks.

The upgrader's "Downloading update from <url>" lines carry licence
keys, vendor tokens and signed download URLs. A new UpgraderOutput
helper redacts every update result before it is stored or sent
(job messages and error, action_log details/summary/error and their
Companion activity mirror, streaks, ignores, chat): download URLs are
cut to scheme://host/[redacted], other URLs lose credentials, query
string and fragment, and JWTs, bearer tokens and secret-named
key=value pairs are masked. The single-plugin update endpoint applies
the same redaction.

A file-permission failure is a site problem, not a plugin one. When an
fs_permission streak reaches the threshold and no plugin or theme
update on the site has succeeded since the streak began, the recorder
now sets the site's existing auto_updates_paused flag once (with a
"Filesystem not writable" reason), writes one auto_updates_toggled
action log and sends one ops alert, instead of auto-ignoring each
plugin and pushing client-visible pause notices. An already-paused
site is left alone. Other reason codes keep the per-plugin auto-ignore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…p stale hashes

A site whose Companion build predates the backup-create capability could
have off-site relay switched on (enrolment turned it on for every
standalone site), and then failed every night with an error nobody could
see: the direct-only pass only logs and chats its failures, and its output
is not stored.

- CompanionBackupRelayAdapter refuses the direct upload before calling the
  plugin when backup-create is missing, with an actionable message
  ("Companion <version> lacks the backup-create capability; update
  Companion or switch to Renegade"). The run counts the site as failed
  instead of skipping it silently.
- Relay can no longer be switched on for such a site: the per-site toggle
  returns 422 / a warning, the settings single toggle refuses it, the bulk
  form enables the other sites and lists the refused ones, and enrolment
  only turns relay on when /health lists backup-create. A site that is
  already on stays on so its failures stay visible.
- New sites columns backup_relay_last_attempt_at, backup_relay_last_error
  and backup_relay_failing_since. ArchiveSiteBackupJob records every
  failure (also for queued runs), RunBackupRelayNow records failures in its
  per-site catch, and a successful archive clears them. The site Backups
  card shows the error and the start of the failure streak, and Backup Now
  stops polling as soon as a failure is recorded.
- markArchived now sets backup_relay_last_sha256 to null when an archive is
  recorded without a hash (an object adopted after a timeout or gateway
  error), so a previous archive's hash is never paired with a newer key
  and one-click restore cannot fail the checksum on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…ds PHP

The Companion malware probe could miss things, alert too often, or not
alert at all:

- Hosts report finding paths in different shapes: relative, absolute from
  a managed host's docroot, or with Windows separators. Suppression lists
  and the own-scanner filter compared exact strings, so an entry stored
  one way never matched a finding reported another way. Add
  CompanionMalwareScanner::normalizeFindingPath(): backslashes become '/',
  and a known docroot prefix is stripped (the site's wp_path, an ABSPATH
  in the Companion snapshot, /srv/htdocs/ and /wordpress/core/). Only whole
  known prefixes are stripped, so root-level files normalize too and paths
  outside every known root stay absolute. Findings are normalized before
  dedup and the own-scanner filter, and stored suppression entries are
  normalized at compare time so existing relative and absolute entries
  both keep matching.
- Suppression is now per kind + path. clockwork:suppress-malware-path
  takes --kind (php_in_uploads by default, or preg_replace_e_modifier for
  a reviewed legacy vendor file), stores the normalized path, and refuses
  the positive signature kinds. php_in_uploads keeps its original state
  key.
- The SSH grep now also excludes clockwork-renegade, which ships the same
  scanner source as clockwork-companion.
- Companion scans never set has_malware_hit, so the clean-to-hit chat
  alert never fired for them. buildResult now sets it when any finding
  kind is outside WARNING_KINDS. The recorder compares against the latest
  non-failed scan of the type, so a failed scan in between no longer
  counts as "clean" and re-alerts.
- php_in_uploads is review-tier and never reached chat or /issues, so a
  newly dropped PHP file could sit unnoticed. The recorder now posts a new
  phpInUploadsDetected chat notice (own toggle, default on, not gated on
  is_inactive) for uploads paths missing from the previous non-failed
  scan, compared normalized. Each file alerts once, on the scan where it
  first appears. File names are defanged before posting.
- The SSH transport hard-coded scan_aborted=false even though it cuts
  each check off with head. Each check now asks for one line past its cap.
  A check that goes over sets scan_aborted, abort_reason=ssh_result_limit
  and details.truncated_checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…invoices to sites

Invoice lines and Site rows were compared as raw strings. The extractor
dropped a leading "www." from invoice text, but both syncs then looked the
domain up exactly, so a site stored as "www.example.com" never linked to
its Bill.com customer and never got a care-plan domain hit. The extractor
also only accepted a fixed list of eight TLDs, so fleet sites on other TLDs
(.tv, .law, .media, .art) could never be recognised in an invoice.

- Add App\Support\DomainNormalizer: lowercase, strip scheme, path, query,
  port, trailing dot and a single leading "www.".
- Add Modules\BillCom\SiteDomainIndex, built once per sync: non-archived
  sites keyed by normalized domain, archived-site keys, and the TLDs the
  non-archived fleet uses. When two non-archived sites share a key, the
  exact-match site wins (then lowest id) and the key is reported in a new
  domain_collisions stat instead of being picked silently.
- InvoiceDomainExtractor normalizes every match and accepts extra TLDs
  passed in by the caller (only well-formed alphabetic TLDs are used).
- CustomerSyncService and CarePlanSyncService use the index for lookups and
  pass the fleet TLDs to the extractor.
- Domains that only belong to an archived site are no longer reported as
  unmatched; they are listed separately (archived_site_domains /
  archived_care_plan_domains) and labelled "(archived)" in console output.
- Document the matching rules in the Bill.com integration docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
… text and preview

The HTML email, the plain-text email and the preview page each decided on
their own which sections to show, and they had drifted. The preview (which
is what prints as the PDF and is also the public report page) showed an
empty "0.00 hours" work log the email hides, printed backups the email
leaves out, listed only plugin updates under a count that included themes
and core (so a theme-only month read "1 update" then "No updates were
applied"), and had no contact-forms section at all.

Visibility: a new Support\ReportSections decides, from the stored report,
which sections the client sees, and all three views use it. On the
operator preview a hidden section shows as a short no-print note saying
why ("Work log hidden from the client: no hours were logged this period").

Preview: the updates table and its tile and heading counts come from
ClientReportMail::updateRows() (core, themes and plugins), keeping the
existing empty-state wording. It gains a Contact forms section with the
email's wording, and the forms tile covers lab checks ("18/19"). Operator
details (staff names on work-log rows, the backup check error, "no blocker
reports to Control", the non-WordPress core-files card, records-start note)
appear only on the operator preview and never print or reach the public
view. The uptime blurb uses the email's wording.

Wording (Support\ReportPhrases, shared by all three views):
- Outage start times are shown in the report timezone instead of raw UTC,
  from a new config value clockwork.reports.timezone
  (CLOCKWORK_REPORTS_TIMEZONE, default America/New_York), with the zone
  abbreviation. An outage already open when the period began shows only
  the date, since its stored start is the period start.
- "Known plugin vulnerabilities (as of <date>)" now reads "(checked <date>)",
  since the date is when the plugin list was checked, and the affected
  plugins are named when any are found.
- The backup destination is stored and rendered as a whole phrase ("by your
  host, Pressable", "to independent off-site storage (Amazon S3)"), so the
  templates no longer prepend a dangling "by". Reports compiled earlier,
  which stored only the place, still read correctly.

Older stored reports keep rendering: every new read has a default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…n, and keep rate-limited probes out of downtime

A site enrolled part-way through a month was reported as monitored for
the whole month: uptime divided its downtime by every minute of the
period, so a few days of watching read as a month of near-perfect
availability, and nothing told the reader or the reviewer.

The compiler now works out when monitoring began: the earliest uptime
seed transition (written by the first successful probe), or, when there
is none or it was backfilled (a backfilled seed is dated when the
backfill ran), the site's creation time if that falls inside the period.
Starting on the period's first day still counts as the whole period, as
it does for backups. When monitoring began later:
- uptime is measured from then (denominator and interval clamp);
- uptime.monitored_since and meta.coverage_start record it;
- both emails and the preview say "Monitoring began <date>, so this
  report covers <date> – <end>", and the uptime section notes it;
- the report is held for review with a reason saying it covers only
  part of the month;
- monitoring that began only after the period hides the uptime section
  and says why.

A host that rate-limits the monitor answers HTTP 429, which the probe
recorded as the site going down. For data already recorded, an interval
opened by a 429 down event is now kept out of downtime and the outage
list and reported internally as uptime.probe_blocked_minutes (shown only
on the operator preview). When the only interval open at the end of the
period is a 429, the review reason now says the monitor was blocked
rather than that the site was still down. A real outage still open at
the end keeps the existing "still down" reason.

ReportPhrases::timezone() now also falls back to UTC when there is no
app config, so the review flags stay plain functions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…core-file and blocked rows, slug-joined vulnerability holds

Malware: the security section copied the newest malware scan's stored
status, so a scan that stopped at its file or time limit, or a clean SSH
scan (whose output is capped and which can't count the files it covered),
read "No malware found", and malware found earlier in the month vanished
once a later scan came back clean. A clean result from a partial scan is
now "incomplete", shown as "Partial scan" in both emails and the preview
and held for review; a partial scan that flagged something still counts
as flagged. The compiler records the worst result of the period's
Companion scans, how many days found malware and when last, and holds the
report when an earlier scan found malware the latest one doesn't.
SiteCheck fallbacks and rows without recorded details keep their stored
status. The review reason for a flagged scan now names the finding kinds
with counts and the scan id; only kinds are stored, never file paths.

Core files: on hosts with no way to run the core checksum check (no server
SSH, no Pressable API, no command runner), "Not checked this month" was
true every month. The compiler now records checksum_supported, mirroring
the verifier's transport choice. When false, both emails leave the row out
and only the operator preview says "Not checked (no checksum path for this
host)". When supported but no check ran, the report is held. Reports
without the key render as before.

Threats blocked: a capability string or an active login limiter was enough
to show "0 blocked", even when none of the site's lockouts ever reached
Control. The count is now shown only with evidence: bans in the period,
review-queue rows for the site in the period, or a blocker on a server
with a provisioned fail2ban jail whose lockouts were pulled since the
period began. "At the server" is said only where such a jail exists;
elsewhere the copy says the site blocks malicious visitors.

Vulnerable plugins: the hold matched vulnerable and waiting plugins by
display name, which repeat (backup copies, same-named plugins) and say
nothing about whether the waiting version fixes anything. The compiler
now stores each vulnerable plugin by file slug with the version that
fixes it, the update offered and any update pause read at compile time,
plus the waiting plugins by slug. The report is held when a waiting
update reaches the fixed version, or when a vulnerable plugin's updates
are paused, naming the plugin, the fix and the pause. Older reports keep
the name match.

SCHEMA_VERSION is now 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…affic and failed form checks; log care-plan skips

Backups: a source can list a backup every day that holds next to nothing.
compileBackups now compares the newest database (db or full) backup in the
period with the database size Companion reports. Under about 1% of it (or
under 100 KB of a database over 10 MB), the status is 'suspect': the section
is hidden from the client like the other unverified statuses, and the review
flags give both sizes. A verified month from a daily source (Pressable, the
S3 relay, or SpinupWP when it has back-to-back days) that covers the period
but has more than three days without a backup is also held, saying "Backed up
on only N of M days this month". The client wording is unchanged and never
promises daily backups.

Traffic: compileTraffic records days_covered, an integer period_days, the
source (pressable or nginx), the two busiest IPs' share of requests, and a
status. 'partial' when more than one day is missing, or the last day's row is
missing or was written before that day was over (a host feed that fills in
late). 'suspect' when two IPs carry over 90% of an access-log source's
requests (a proxy or gateway in front of the site), or a busy site has almost
no visits. 'no_data' for a host we collect from with nothing for the period,
'unsupported' for one we don't. Zero rows count as no data. Only 'ok' is shown
to the client; reports compiled before the status existed render as before.
Partial, suspect and no_data hold the report with the reason; the preview tells
the operator the coverage and source. The period query is half-open so a last
day stored with a time part still matches.

Forms: lab_accepted counts runs whose form accepted the submission, not runs
where Companion merely answered. A report is held when lab checks were not all
accepted or live delivery tests fell short of 100%, so the client never gets
an unexplained "18 of 19".

Runner and import: the scheduled run writes one action log entry listing the
schedules it skipped for having no care plan (the console line alone is lost
for scheduled runs); the advance-on-skip behaviour is unchanged. The recipient
importer creates schedules for sites without an active care plan disabled,
and says so in the dry run, unless --include-no-care-plan is passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…ksums, stabilise performance rotation

Standalone (custom-host) enrolment created sites without is_wordpress,
so Companion and Renegade sites sat out every WordPress-only job: core
checksums, lockout and firewall pulls, plugin detection.

- SitesController::store sets is_wordpress: the /health handshake only
  answers from the WordPress plugin.
- A Site saving hook sets is_wordpress whenever the Companion snapshot
  reports a WordPress core, whichever code path writes the snapshot. It
  never clears the flag.
- A data migration backfills custom-host sites with Companion installed
  and a WordPress core in their snapshot.

clockwork:verify-wp-core-checksums now skips sites that have no way to
run wp-cli (no root SSH, not Pressable, no host command runner), using a
new WpCoreChecksumVerifier::hasTransport() that mirrors verify()'s
dispatch. Before, such a site wrote a failed 'no_command_runner' row
every night and turned the whole run red. Skipped sites are counted in
the summary and logged by id. This ships with the backfill so the newly
flagged sites don't add more failures.

The weekly performance rotation bucketed sites by their position in
the domain-sorted list, so every enrolment moved every site's weekday.
Some sites then went more than a week without a scan, and a month could
end with no in-period speed test for a client report. Changes:

- Each site's weekday is now crc32(domain) % 7, which doesn't depend on
  any other site.
- Month-end catch-up: in a month's last 7 nights, rotation sites with no
  OK scan since the 1st join the run, within the daily GTmetrix credit
  budget (new config clockwork.gtmetrix.daily_credits, default 10).
  Sites whose own weekday won't come round again that month go first.
  If credits are short, sites in tonight's bucket that already have a
  scan this month give up their slot. Anything left waits for the next
  night instead of falling back to PSI.
- Standalone enrolment and switching a care plan on each start one
  detached baseline scan (--site), on their own background lock.

The test base class now binds a no-op DetachedShell, so no Feature test
can start a real background artisan process through BackgroundArtisan.
Tests that check the launched command still mock it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
Brings the branch up to date with main (Companion v1.43.0 standard-plugin
install) and merges the fleet-side fixes found while auditing the held
October client-report drafts: uptime 429 handling, nightly update retries
and ordering, upgrader error classification and redaction, backup relay
capability checks and failure state, malware path normalization and
new-file alerts, contact-form test cadence, Pressable traffic timing,
Bill.com domain matching, and checksum/performance scan coverage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…imestamps

They were created in parallel with the same 120000 timestamp. They don't
depend on each other, but distinct timestamps make the run order explicit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…iling

The backup relay now keeps its failure streak on the site row
(backup_relay_failing_since, backup_relay_last_error), but the report only
looked at recorded archives, so a relay site with no archive in the period
read as "none found" or "could not check" with no hint of why.

- compileBackups: a relay (s3_archive) site with no archive in the period
  whose current failure streak began on or before the period end gets
  status 'failing'. Like the other unverified statuses it is hidden from
  the client. The streak start and the last error are kept internally
  (relay_failing_since, relay_error). The error is trimmed to about 200
  characters, secrets are masked and every URL loses its query string and
  fragment before it is stored.
- ReportReviewFlags holds it: "Off-site backups have been failing since
  <date>: <error>". A relay whose archives stopped part-way through the
  period keeps the "Backups stopped" reason, now followed by the same
  sentence. A streak that began after the period is ignored.
- The operator preview's hidden-section note shows a red "Failing" badge
  and the relay's last error. Neither reaches the PDF or the public view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…s it was cut short

The report treated every clean SSH-transport malware scan as partial because
SSH scans always store scanned_files_count = 0, so SSH-only sites would be
held as "Partial scan" every month. Since the scanner now notices an SSH
check hitting its result cap (scan_aborted, abort_reason ssh_result_limit,
truncated_checks), the file count is no longer the signal.

- CompanionMalwareScanner stores details.truncated_checks on every SSH scan,
  empty when no check hit its cap. Companion-transport scans are unchanged.
- compileSecurity: a clean SSH scan is "incomplete" only when it was
  aborted, or when it has no truncated_checks. Rows without the key were
  recorded before cut-short SSH scans were detected and always said
  scan_aborted = false, so a clean one cannot vouch for the whole site;
  keeping those partial is the safe reading and only affects older rows.
  The capped check names are kept internally (malware_truncated_checks).
- Review reasons now say which applies: an SSH scan that hit its result
  limit (naming the checks), or one recorded before truncation was tracked.
- Security scans docs describe the marker and how reports read it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…s blocked time

The uptime monitor now records a rate-limited probe (HTTP 429) as a
probe_blocked event and site state instead of a down event with status 429.
compileUptime only knew the old shape: a probe_blocked event closed an open
outage but opened nothing, so the blocked time was not counted and a block
still open at the end of the month no longer produced the "monitor was
blocked" review reason.

compileUptime now treats a probe_blocked event, and a down event with status
429 from before the change, as the start of a blocked stretch. Blocked time is
never downtime and is reported to the operator as probe_blocked_minutes.
- down -> probe_blocked -> up: the outage ends at the probe_blocked event and
  the rest is blocked time.
- probe_blocked -> down: the blocked stretch ends and a real outage starts.
- An old 429 down followed by a probe_blocked event (deployed mid-block) is
  one stretch, dated from the old row, so probe_blocked_since and the
  end-of-month reason name when the block really began.
- A probe_blocked stretch carried in from before the period counts as
  blocked, not down.

Tests cover each sequence, including events written by the real
UptimeStateUpdater.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…orts that never sends

Held drafts could only be refreshed with the schedule's Send now, which
compiles last month (so from Dec 1 it no longer reaches October), and which
emails a clean report straight away once the schedule has sent before.

- POST /client-reports/{report}/recompile (client-reports.recompile), for
  scheduled reports that have not reached anyone: status generated and an
  empty sent_to (ClientReport::canRecompile). Sent, partly sent, superseded
  and hand-generated reports are refused with a reason.
- ScheduledReportRunner::recompile() compiles the report's own
  period_start/period_end from current data, supersedes the old copy, keeps
  any custom note, and runs the same review check as a scheduled run
  (firstReport when an auto schedule has never sent; a draft schedule gets
  no first-report reason, as in run()). It never calls the sender, never
  stamps last_sent_at and never moves next_run_at. It re-reads the report
  under a lock inside a transaction, so a failed compile leaves the old copy
  live and a double click can't recompile a superseded copy.
- "Recompile" buttons on unsent scheduled reports in the history table and
  on the operator preview, never on the public view. The preview's review
  box points to it, and the reports index and the operator preview now show
  the status messages actions return.

Tests assert Mail::fake() saw nothing sent or queued, including for a clean
recompile on a schedule that sends clean reports by itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…sts don't do

Scheduled contact-form tests run in lab mode and suppress the outgoing email,
and a hand-run live test only records whether the site reported the email as
sent; nothing watches the destination inbox. Three places still said
otherwise:

- the Contact Form Testing module description and status note ("delivery
  verification", "email round-trip delivery verification");
- the setup page's Maintenance & QA description ("end-to-end SMTP
  deliverability verification");
- the client report preview's live-test tile, labelled "Form
  Deliverability", now "Form Emails Sent".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…way to refresh drafts

The feature page still described the rules from before the October audit
fixes. It now covers: the support URL fallback (already present), whole-day
periods, the From name, the single show/hide rule, partial-month "Monitoring
began" coverage, HTTP 429 blocked time, "Partial scan" (including how SSH
scans are read), the core-files row left out on hosts with no checksum path,
threats blocked only when measured, slug-joined vulnerable-plugin holds,
suspect and failing backups, traffic statuses, contact-form check holds, the
care-plan skip log entry and the recipient importer's care-plan gate, schema
version 5, and the full list of hold-for-review reasons.

A new "Refreshing held drafts (Recompile)" section explains the Recompile
action, what it never does, and the steps to refresh drafts after a deploy,
and warns against using Send now or --force for that. The tracker's runbook
step now points to Recompile instead of Send now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…Test

The redaction tests use a JWT-shaped constant that decodes to a fake
licence payload and signature. gitleaks' default jwt rule matches it on
shape alone, so add its exact fingerprint with an explanation, as the
test-fixture convention requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…vered

A live contact-form test only records whether the site's mailer reported
the notification email as sent; nothing checks the inbox. The preview,
HTML and text emails said "to confirm messages reach you" and "N%
delivered", and the review reason said the submissions "arrived". Word
all of them as what is measured and add a note that the inbox is not
checked. Colours and the pass-rate maths are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…nd split site coverage from uptime start

Outage times and the "monitoring began" date are shown in the report
timezone, but the uptime period and the coverage threshold were UTC days.
An outage at 01:00 UTC on the 1st read "Sep 30" under an "Oct 1" header,
a late start on the first local day held the report as partial, and one
on the last local day read as after the period.

- Build the uptime window and the coverage thresholds from the period's
  calendar days in the report timezone. Sections listing whole days keep
  their UTC days.
- monitoringBeganAfterPeriod and the coverage note compare against the
  local end of the period; a carried-in outage reads "since before" the
  local first day (older reports, clamped to midnight UTC, keep their date).
- meta.coverage_start is now when the site was added, if that was after
  the period's first day. The uptime seed only sets uptime.monitored_since,
  with its own review reason, so monitoring switched on mid-month no
  longer makes the whole report claim it covers part of the month.
- Bump the report schema to 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…ackfill included

Upgrader output is redacted when new update results are stored, but rows
written before that still hold licence keys and signed download URLs, and
the documented recovery after a reinstall (backfill-companion-action-log
--reset-since) re-pushed them to the client's wp-admin as stored.

Run the summary, details and error through UpgraderOutput on the way out,
in ActionLogger's push and in the backfill command. The stored rows are
unchanged; the details array keeps its shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…eal file-permission run

- A security-fix retry of an auto-paused plugin now follows the same
  one-attempt-per-version rule as a new release. The same fix version is
  retried at most once every 7 days, and never when it last failed for a
  licence or offer reason; a newer fix version is tried at once.
- Queueing a retry records the version (and when) on the failure streak,
  so a retry that times out, is reaped or hits a transport error still
  counts as that version's attempt instead of being retried every night.
- The streak keeps its own count of consecutive file-permission failures
  and the job that began that run. The site-wide pause needs that run to
  reach the threshold, checks for a successful update since its start,
  and states the run's length in its reason. One file-permission failure
  after failures for other reasons no longer pauses the whole site, and
  uncounted failures no longer shift the window.

Adds a migration for last_retry_queued_at, consecutive_fs_failures and
fs_streak_started_job_id on plugin_update_failure_streaks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…real outage open through one

A single HTTP 429 moved a site to probe_blocked at once, and from down it
closed the outage. A real outage behind an intermittent rate limiter then
alternated down and probe_blocked every few probes, re-sending the down
chat, SMS and client messages each time and never sending a recovery. One
429 on an up site wrote client-visible failed activity entries and could
hold a month's report.

- An up or unknown site enters probe_blocked only after as many 429s in a
  row as it takes failures to go down (new sites.uptime_consecutive_blocked;
  any other answer resets the run). The event and log are dated at the
  run's first 429.
- A down or maintenance site stays in that state through 429s. A failure
  after one is the same outage, and a success is its normal recovery with
  the usual alerts. Only after 429s for the probe-blocked notice hours does
  it move to probe_blocked, closing the outage at the first 429.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…a scan counts as complete

phpseclib returns the output so far when a command times out, and the
uploads check's xargs stopped silently at the first file name with a
quote, so an SSH check that never finished looked clean. Reports had
started treating a clean SSH scan with an empty truncated_checks as
complete, which turned that into a malware all-clear.

- SshClient::execChecked() throws SshCommandTimedOut on a timeout and
  fails when the command couldn't run.
- Each SSH check prints a completion sentinel after its pipeline; a check
  that timed out or lost it is listed in incomplete_checks and the scan is
  aborted with ssh_check_incomplete. ssh_checks_verified is stored only
  when every check finished.
- The uploads check passes paths to sh via find -exec … {} + instead of
  xargs, and a pre-check fails the scan when the docroot is missing or
  holds no wp-config.php.
- Client reports call a clean SSH scan complete only with
  ssh_checks_verified; the review reason names unfinished checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…iod never became bans

The blocked-visitors count used any review-queue row created or updated
in the period as proof that 0 bans was a measurement. Those rows are the
lockouts that weren't banned (waiting for review, or the ban failed), so
reports showed "Malicious visitors blocked: 0" for sites that had
lockouts on record.

- Review-queue rows no longer count as evidence. Lockouts created in the
  period that are still pending, queued or failed hide the count, even
  beside real bans, and hold the report with the number of unbanned
  lockouts (kept internally as unbanned_lockouts).
- A server's lockout pull vouches for a 0 only when it ran in the
  period's last day or later, not merely after the period started.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
claude added 29 commits November 5, 2026 15:27
…udit branch

Conflicts resolved:
- CHANGELOG.md: main released 1.12.0; this branch's entries move to a new
  Unreleased section on top of main's file.
- SuppressMalwarePath: keep the kind-aware suppression list and main's
  iterate-the-local-list fix for Larastan.
- Client reports list: main's single-column layout and no-wrap status
  column, plus this branch's flash messages and Outdated badge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
The greeting used the site's client name verbatim, which is often an
organisation, an initial, a name with an internal note in brackets, or a
contact who is not the person the report goes to. Greet by first name only
when the report goes to one address that is a known client contact whose
stored name is plainly a person's; otherwise say "Hi there,". Same in the
HTML and plain-text parts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
The wpvulnerability.net 'eq' operator was stored as an inclusive upper
bound with no lower bound, so an advisory for exactly one release matched
every older release and flagged unaffected sites as vulnerable.

The operator block is now resolved as a whole: 'eq' gives from = to (both
inclusive), the operator (not the min/max slot) decides which side a
version bounds, numeric versions are kept instead of opening the range,
and no patched_in is claimed for an exact advisory. A data migration
repairs existing rows from the advisory title, which carries the feed's
own '== X' expression.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
- Date updates by the report timezone's calendar day and measure the
  updates period over those days, as the uptime section already does, so
  an update run late in the evening is no longer listed a day late.
- Never claim "no updates were needed": with nothing listed, say only
  that everything was up to date at the end of the month, or that no
  updates were applied when something was waiting or can't be known.
- List version changes made outside Control (plugin_update_observed,
  theme_update_observed, core_update_observed rows), labelled like any
  other update, once each: a change Control itself made to the same
  version is not listed twice. One seen after the period counts as
  waiting at its end.
- Leave the agency's own connector plugins out of the list, the counts
  and the waiting updates.
- Leave out an update the site's later snapshot shows was undone (a
  lower version now, and a pause, auto-pause or backup restore since),
  keep it as updates.not_kept and hold the report to say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
… month

"Malicious visitors blocked" counted every ban in the period, including
bans reversed as false positives minutes later, and took any ban in the
period as proof the whole month was measured, so a site whose lockouts
stopped reaching Control part-way through still showed a monthly total.

Leave out bans decided as false positives or lifted within an hour, and
show the count only when the site reports its lockouts now and lockouts
from the fleet were still arriving within 72 hours of the period end
(the pull keeps stamping last_llar_pull_at even when nothing arrives).
Otherwise the count is hidden; security.blocks_unmeasured and
lockouts_last_ingested_at say why, worded for the operator by
ReportPhrases::blockedVisitorsNote().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…id-month

A site whose care plan was switched on part-way through the month was
reported as a whole month of care. Read the care-plan toggle rows (the
site page's toggle and the command-line form) and treat the last switch
from off to on inside the period, after its first day in the report
timezone, as the coverage start, like a site added mid-month: the later of
the two is kept in meta.coverage_start, with meta.coverage_source saying
which. The client reads "Your care plan began Oct 28, so this report
covers Oct 28 – Oct 31." and the report is held with "The care plan was
switched on Oct 28, so this report covers only part of the month."

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…theme

WordPress offers a custom theme the releases of any wordpress.org theme
that shares its folder name, and the nightly loop applied such an offer,
replacing a custom child theme with an unrelated directory theme.

Before queueing a theme, the nightly loop now checks that the installed
theme is the one on offer: when wordpress.org lists the folder, the names
must match; otherwise a child theme (a parent template in the snapshot, or
"child" in its name) is refused. A held-back theme gets a skipped job row
(reason_code identity_mismatch) for the summary mail, a private
identity_mismatch ignore so later nights skip it quietly, a local-only
theme_update_refused action log and one chat message. Nothing counts toward
failure streaks or auto-pauses, and the streak recorder no longer rewrites
such an ignore as an auto-failure.

Adds ActionLog::LOCAL_ONLY_TYPES, which ActionLogger and the Companion
action-log backfill never mirror to the client's wp-admin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
A long outage that ended before the period end went to the client as a
bare line with no context, and nothing held the report. Keep every
outage of 60 minutes or more (uptime.long_outages, uncapped, so one that
fell off the five listed still counts) and hold the report with its date
and length so a person adds context first. An outage still open at the
end keeps its own "still down" reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…o the HTML

- The HTML email showed every availability figure in the "good" green,
  97% included. Colour the tile and the row green from 99.9%, amber from
  99%, red below (ReportPhrases::uptimeTone).
- The plain-text part left out "Data transferred" and what each day's
  backups held. It now has the same bandwidth line and, per backup day,
  the kinds and size the HTML shows, from shared ReportPhrases helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
- Traffic: say "one IP address" when every listed request came from one
  (traffic.top_ip_count), not always "two IP addresses".
- Backups: count "N of M days" against the days backups were expected,
  from when the site's coverage began (backups.expected_days), not the
  whole month for a site that joined near its end.
- Vulnerabilities: an advisory with no patched version but a closed upper
  bound is fixed in the next version after that bound
  (vulnerable_plugin_items.fixed_after), not "no fixed version yet"; a
  waiting update past the bound counts as one that fixes it, and an
  exclusive bound is itself the fixed version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…ends

The module now opens on a Clients tab: every client with its sites'
care-plan status, report schedule, recipients and a six-month history
strip linking to each report, with search and care-plan / waiting /
missing-schedule filters. Sites without a client are grouped under
"Unassigned". Each client has a reports page with contacts, each site's
schedule and full report history (View, Email, Recompile, Send, Send
test to me), previous/next through the filtered list and a switcher.

"Send test to me" emails the exact client email to the signed-in
operator only, with [TEST] in the subject, and changes no report or
schedule state. The real Send now confirms who receives it and that a
schedule's first send switches it to sending clean reports itself.

Care-plan sites without a schedule get a one-click "Create schedule"
(default template, monthly, client contacts or drafts). The reports
list moves to /client-reports/reports (client-reports.reports).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
Control only logged the updates it ran, so anything WordPress auto-updates
or another updater applied first was invisible to the action log and to
everything built on it.

When a new Companion snapshot replaces the previous one (Site model
updated hook, so every snapshot writer is covered), each installed version
that went up and that no recent Control job made is written as one
plugin_update_observed / theme_update_observed / core_update_observed
action log with details {slug, name, from_version, to_version,
observed_at, previous_snapshot_at}. Downgrades, added/removed items and
repeats of the same change are skipped. The rows are local-only: never
mirrored to the client's wp-admin, and they touch no jobs, failure
streaks, auto-pauses or chat alerts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…hedule is

With no Control schedule, the recipients sync fell back to the site's
care-plan state and pushed "report active" to wp-admin, although nothing
sends without a schedule. The fallback payload is now always not active
(the client's addresses are still offered as recipients), and a
WordPress that an earlier push left showing reports as active while
Control has no schedule is told otherwise on its next snapshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…gs reviewed

A held report whose latest malware scan was graded "warning" (review-tier
findings only: PHP in uploads, a recently changed wp-config, the dead /e
modifier) had no way to clear the client's "Issues flagged" line after a
person confirmed the findings were benign.

Add "Mark findings reviewed" (POST client-reports.malware-reviewed, one
form in the preview's review box) for a held report that hasn't reached
anyone. It records who, when and a note in security.malware_reviewed, tied
to the scan id and finding kinds the report shows, removes that review
reason (the others stay), and both emails then read "Reviewed — no
threat found". Suspected malware (issues_found) can never be marked.
Recompile, and a scheduled run's fresh compile, carry the mark over only
while the scan and its findings are unchanged. Nothing is sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
The reports list shows one period at a time (the latest by default) and
hides replaced copies unless asked. It filters by status (needs review,
outdated, ready to send, partly sent, sent, or anything waiting), by
hold-reason category derived from the stored review reasons, and by a
search over site, client and recipient. A summary strip counts the
period's reports per status and doubles as filter links; each row shows
short reason chips and labelled View / Email / Recompile / Send / Test
actions. Filters live in the query string and survive paging.

"Recompile all outdated" for the period and "Recompile selected" run
the existing ScheduledReportRunner::recompile() per report, never send,
and report how many were recompiled, held, skipped or failed.

The Scheduling tab gains filters (no care plan, no recipients, disabled,
drafts) with counts, each schedule's latest report state, and a panel of
care-plan sites without a schedule with one-click and "create all".
Module tabs show how many reports wait and how many sites lack a
schedule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
… upstream

- Date observed version changes by ran_at (the snapshot that showed the
  new version) and decode HTML entities in their names; update labels
  from Control's own runs are decoded too, so "&amp;" never reaches the
  client.
- An advisory for exactly one version (stored from = to) is fixed in the
  next version after it, never "no fixed version yet".
- An update ignore with source identity_mismatch reads "update held back:
  the offered package appears to be a different theme", not "paused by
  hand".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
The operator preview gets a sticky toolbar (operator mode only, never on
the public report page or in print): back to the list it was opened from
with its filters and page, previous/next through that filtered list,
Email version, Recompile, Print/PDF, Send test to me, and Send, which is
disabled with the reason when the report is outdated, replaced or
already with everyone. Keys: left/right for previous/next, e for the
email version. Review reasons are grouped by category, each with a link
to the matching site dashboard tab (or the schedule for a first report).

The email version page now uses the app layout: a card with From, To,
Sent to and Subject, the grouped review reasons, HTML and plain-text
tabs, and the same previous/next and actions as the preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…nd toolbar

Rewrites the tabs section for the new layout: the Clients landing page
and client reports page, the filterable reports list and bulk recompile,
the operator preview toolbar and keyboard keys, the app-styled email
version, "Test to me", the send confirmation, the Scheduling filters and
one-click schedules for care-plan sites without one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
A small "Client reports" pill next to the care-plan badge in the site
header opens the site's client page (or the Unassigned group) at that
site's schedule and report history. Shown only when the Client Reports
module's routes are loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
Updates, threats blocked, coverage, long outages, traffic, backups and
vulnerability items are compiled under new rules, so unsent reports from
before must be recompiled before they can be sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…ites

With dozens of care-plan sites missing a schedule the panel pushed the
schedule table far down; the rest now open with "Show all N".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
Greeting, report-timezone update dates, updates applied outside Control,
agency plugins and undone updates left out, real-ban counting, mid-month
care-plan coverage, long-outage holds, uptime colour, text/HTML parity,
clearer review reasons, recipients sync, and an operator 'mark findings
reviewed' action. Report schema 7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
Keeps both sides: the "Mark findings reviewed" form now lives in the
grouped review panel (under the malware group) of the operator preview,
and its route sits next to the UI's routes with the numeric report
constraint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
The operator preview, the printout and the public report page now read
the same ReportPhrases as both email parts: the malware label (so a
reviewed report says "Reviewed — no threat found"), the "no updates"
sentence, the uptime figure and colour (green from 99.9%, amber from
99%, red below), backup kinds, dates and sizes, and data transferred,
left out like the email when the host reported no byte counts. The
operator-only note for a hidden blocked-visitors count gives the shared
reason. The care-plan coverage note already came from coverageNote().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
Covers updates dated by the report timezone, updates observed outside
Control, the connector plugins and undone updates left out, blocked
visitors counted only when measured all month (and the operator's
reason when hidden), the care-plan coverage start, long-outage holds,
uptime colours, "Mark findings reviewed", the greeting rule and the
phrases the report page now shares with both email parts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
The Clients tab now lists care-plan clients (any active site on a care
plan) under "Care plan clients — reported monthly" and folds the rest
into a collapsed "No care plan" section with a count. A Care plan / No
care plan / All switch (default Care plan) and an order toggle (Needs
attention, the default, or A–Z) live in the query string with the
search and filters, so paging and the client pages keep them; the tiles
count the current selection. Sites without a client are split the same
way. Previous/next on a client page follows the shown order.

The Scheduling tab lists care-plan schedules first and folds the "No
care plan — won't send" ones into a collapsed section, opened when
filtering for them. The blocked-visitors note on the preview is set in
small text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…d fleet accuracy fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
@aaronreimann
aaronreimann merged commit c6ff662 into main Nov 6, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants