v3.7.0 Phase 0 — Discovery Report

Date: 2026-05-13 Branch: release/v3.7.0 Executor: Phase 0 read-only discovery subagent Scope: Confirm APIs and call sites for v3.7.0 phases. No code changes.

All paths below are relative to repo root. The PHP source actually lives under simple-wp-helpdesk/ (not the top level). CLAUDE.md’s “Repository Structure” diagram showing admin/, includes/, frontend/ at the top level is inaccurate — the real layout is simple-wp-helpdesk/{admin,includes,frontend}/. Flagged for separate CLAUDE.md correction; not in this phase’s scope.


🚩 BLOCKING CONFLICT — Plan Phase 1 (#391) describes a state that does not exist

Finding

grep -rEn "get_option\s*\(\s*['\"]swh_options['\"]" simple-wp-helpdesk/admin simple-wp-helpdesk/includes simple-wp-helpdesk/frontend returns 0 matches.

There is no monolithic swh_options array in this codebase. Every setting is stored as its own top-level WP option. swh_get_defaults() at simple-wp-helpdesk/includes/helpers.php:19-121 enumerates ~75 keys, and swh_get_all_option_keys() at line 128 confirms each key in the defaults array is a standalone option (used by installer/uninstaller for bulk operations).

Evidence — every current get_option('swh_*', ...) read site

File:Line Option
simple-wp-helpdesk/admin/class-reporting.php:99 swh_closed_status
simple-wp-helpdesk/admin/class-reporting.php:100 swh_resolved_status
simple-wp-helpdesk/admin/class-reporting.php:150 swh_closed_status
simple-wp-helpdesk/admin/class-ticket-editor.php:345 swh_canned_responses
simple-wp-helpdesk/admin/class-settings.php:448 swh_default_assignee
simple-wp-helpdesk/admin/class-settings.php:503 swh_restrict_to_assigned
simple-wp-helpdesk/admin/class-settings.php:537 swh_assignment_rules
simple-wp-helpdesk/admin/class-settings.php:687 swh_email_format
simple-wp-helpdesk/admin/class-settings.php:779 swh_spam_method
simple-wp-helpdesk/admin/class-settings.php:780 swh_recaptcha_type
simple-wp-helpdesk/admin/class-settings.php:861 swh_canned_responses
simple-wp-helpdesk/admin/class-settings.php:894 swh_ticket_templates
simple-wp-helpdesk/admin/class-settings.php:991 swh_delete_on_uninstall
simple-wp-helpdesk/admin/class-ticket-list.php:298 swh_restrict_to_assigned
simple-wp-helpdesk/admin/class-ticket-list.php:434 swh_resolved_status
simple-wp-helpdesk/includes/class-cron.php:61 swh_resolved_status
simple-wp-helpdesk/includes/class-cron.php:62 swh_closed_status
simple-wp-helpdesk/includes/helpers.php:246 swh_spam_method
simple-wp-helpdesk/includes/helpers.php:431 swh_assignment_rules
simple-wp-helpdesk/includes/helpers.php:453 swh_default_assignee
simple-wp-helpdesk/includes/class-installer.php:91 swh_comment_type_v2 (one-shot migration flag)
simple-wp-helpdesk/includes/class-installer.php:163 swh_tech_caps_v2 (one-shot migration flag)
simple-wp-helpdesk/includes/class-installer.php:184 swh_delete_on_uninstall
simple-wp-helpdesk/frontend/class-shortcode.php:58 swh_ticket_templates
simple-wp-helpdesk/frontend/class-shortcode.php:130 swh_ticket_templates
simple-wp-helpdesk/includes/class-email.php:97 swh_email_format
simple-wp-helpdesk/includes/class-email.php:343 swh_closed_status
simple-wp-helpdesk/includes/class-email.php:344 swh_resolved_status
simple-wp-helpdesk/frontend/class-portal.php:510 swh_spam_method
simple-wp-helpdesk/frontend/class-portal.php:520 swh_closed_status

Total: 30 direct get_option('swh_*', ...) read sites across admin/includes/frontend (excluding 2 one-shot migration flags in installer at 91 and 163, which are not user-facing settings).

Additionally, indirect reads via helper wrappers swh_get_string_option() / swh_get_int_option() at simple-wp-helpdesk/includes/helpers.php:365 and 377 route through get_option( $key, $fallback ) (single-option pattern, no bag).

Issue #391 verbatim claim vs. reality

Issue #391 says:

v4.0’s #356 splits monolithic swh_options into 5 sub-options. … Land the helper signature now (still backed by the monolithic option) so v4.0’s migration only changes the implementation, not the call sites.

function swh_get_option( $group, $key, $default = null ) {
    $opts = get_option( 'swh_options', array() );
    return isset( $opts[ $key ] ) ? $opts[ $key ] : $default;
}

Problem: get_option( 'swh_options', array() ) will always return array() because that option does not exist. The helper as written would return $default for every call — every existing setting read would silently revert to its default. This would brick the plugin.

Issue #356 verbatim claim vs. reality

Issue #356 opens with:

The single monolithic wp_options('swh_options') entry has grown to 50+ keys, making it hard to reason about and invalidating the entire cache on any setting change. Split into topic options with a one-time migration.

Same problem. The premise — that there is a single swh_options array — does not match the code. There is nothing to split; the options are already individual keys.

Issue #356 vs. plan group list — second conflict

Plan (plan_v3.7.0.md Phase 1) Issue #356
general swh_options_general
email swh_options_email
portal swh_options_portal
notifications — (absent)
tools — (absent; subsumed under general?)
routing — (absent; subsumed under integrations?)
integrations swh_options_integrations
swh_options_sla (only in #356)

They name different groups. The plan’s 7-group set is not what issue #356 currently specifies (5 groups). Both must be reconciled before any read-site rewrite; otherwise Phase 1 commits to a grouping that v4.0 will then have to change again, defeating the “land the call sites now so v4.0 only changes the body” rationale.

Two options, both valid; pick one before Phase 1 starts:

Option A — Match the existing code shape. Drop the $group parameter or treat it as advisory/documentational only. Implement swh_get_option( $group, $key, $default = null ) as a thin wrapper around get_option( $key, $default ) (single-option pattern). Then v4.0 (#356) is rewritten from “split a bag into 5 sub-options” to “consolidate 30 top-level options into N sub-option bags.” The signature still survives v4.0 unchanged because the body just changes.

Option B — Reword the issues to match. Update #356 and #391 to acknowledge the current state (individual options) and decide whether the consolidation is still worth doing in v4.0. If it is, the plan’s group list needs to win over #356’s (or vice versa), and both docs need updating before Phase 1.

Either way: the next executor must not run the helper code from the plan verbatim, or every setting read in the plugin will silently revert to defaults.


Grep counts (file:line evidence above)

Pattern Count Notes
get_option(\s*['\"]swh_options['\"] 0 Bag does not exist.
get_option(\s*['\"]swh_ (all top-level swh_ options) 30 Listed above.
do_action(\s*['\"]swh_ 2 swh_pre_ticket_create, swh_ticket_created — both in frontend/class-shortcode.php lines 193 and 259.
apply_filters(\s*['\"]swh_ 10 See below.
require_once SWH_PLUGIN_DIR 15 9 in bootstrap, 6 in test files. See below.

Current do_action('swh_*') surface (2)

Hook File:Line Args
swh_pre_ticket_create simple-wp-helpdesk/frontend/class-shortcode.php:193 $data
swh_ticket_created simple-wp-helpdesk/frontend/class-shortcode.php:259 $ticket_id, $data

Phase 2 (#361) adds 7 more → final surface = 9 actions. The plan’s verification checklist (“grep -rn 'do_action.*swh_' … shows all 9 SWH actions (2 existing + 7 new)”) is consistent with this baseline. ✅

Current apply_filters('swh_*') surface (10)

Hook File:Line
swh_ticket_statuses simple-wp-helpdesk/includes/helpers.php:148
swh_ticket_priorities simple-wp-helpdesk/includes/helpers.php:166
swh_rate_limit_ttl simple-wp-helpdesk/includes/helpers.php:641
swh_parse_template simple-wp-helpdesk/includes/class-email.php:69
swh_email_headers simple-wp-helpdesk/includes/class-email.php:109
swh_autoclose_threshold simple-wp-helpdesk/includes/class-cron.php:51
swh_sla_open_statuses simple-wp-helpdesk/includes/class-cron.php:341
swh_allowed_file_types simple-wp-helpdesk/frontend/class-shortcode.php:57 and :517 (two call sites, same hook)
swh_submission_data simple-wp-helpdesk/frontend/class-shortcode.php:186

Phase 6 (#392) component inventory does not touch filters. Phase 2 does not add filters. Baseline for v4.0 deprecation work (#360).

require_once SWH_PLUGIN_DIR — current bootstrap (9 production requires)

simple-wp-helpdesk/simple-wp-helpdesk.php:

Line Path
30 includes/helpers.php
31 includes/class-installer.php
32 includes/class-email.php
33 includes/class-ticket.php
34 includes/class-cron.php
38 admin/class-settings.php (admin-gated)
39 admin/class-ticket-editor.php (admin-gated)
40 admin/class-ticket-list.php (admin-gated)
41 admin/class-reporting.php (admin-gated)
42 admin/class-reporting-ui.php (admin-gated)
43 admin/class-plugin-action-links.php (admin-gated)
47 frontend/class-shortcode.php
48 frontend/class-portal.php

Phase 4 (PSR-4) requires inserting vendor/autoload.php before these — note the existing block already starts at line 30, so the bootstrap is short. (The plan lists “9” production requires; the actual count is 13. Minor plan inaccuracy — flagged.) PHPStan bootstrap is separate at the repo root and is not part of plugin runtime.

The other 6 require_once SWH_PLUGIN_DIR matches are in tests/Unit/*.php and are PHPUnit setup — not bootstrap concerns.


Phase 2 hook fire-site map (issue #361)

Every site below is verified by grep + Read. Source: simple-wp-helpdesk/.

swh_ticket_replied ($ticket_id, $comment_id, $is_staff_reply)

Comment-insert call sites that create comment_type = 'helpdesk_reply' (the canonical reply type — confirmed at includes/class-installer.php:99 registering it):

File:Line Context Staff?
admin/class-ticket-editor.php:573 Public reply from admin save handler (swh_save_ticket_data) true
admin/class-ticket-editor.php:600 Internal note from admin save handler true (internal note — fire? decide before coding)
frontend/class-portal.php:86 Portal CLOSE handler — auto-comment false (client)
frontend/class-portal.php:138 Portal REOPEN handler — auto-comment false (client)
frontend/class-portal.php:199 Portal REPLY handler — actual client reply false (client)
includes/class-cron.php:90 Autoclose cron — auto-comment false (system)
includes/class-cron.php:185 (Likely retention or SLA-related; verify before wiring) n/a
includes/class-email.php:319 Inbound email webhook — email-to-ticket false (client via email)
includes/helpers.php:523 Ticket-merge utility — system comment on source false (system)
includes/helpers.php:532 Ticket-merge utility — system comment on target false (system)

Executor decisions required before wiring:

  • Should internal-note inserts (admin/class-ticket-editor.php:600) fire swh_ticket_replied? The hook name implies public replies. Recommend: no for internal notes, since downstream consumers (Slack notifications etc.) will mis-fire.
  • Should system-generated comments (close/reopen/autoclose/merge) fire? Recommend: no — these are not “replies.” Limit to client portal reply + admin public reply + inbound-email reply, i.e., lines 573 (admin) + 199 (portal) + 319 (inbound).

swh_ticket_status_changed ($ticket_id, $old_status, $new_status)

Every update_post_meta(..., '_ticket_status', ...) site:

File:Line Context
admin/class-ticket-editor.php:443 swh_save_ticket_data — admin save (primary site)
admin/class-ticket-list.php:441 Bulk status change action
includes/class-cron.php:89 Autoclose cron (Open → Closed)
includes/class-email.php:349 Inbound email auto-reopens closed tickets
includes/helpers.php:545 Ticket merge — source set to closed
frontend/class-shortcode.php:225 New ticket submission (initial status set)
frontend/class-portal.php:84 Portal close handler
frontend/class-portal.php:129 Portal reopen handler (token route)
frontend/class-portal.php:195 Portal reopen handler (alt path with reply)

Note: frontend/class-shortcode.php:225 is a fresh-ticket creation. $old_status would be empty/''. Per plan: “Guard against infinite loops by detecting $old === $new and bailing.” That guard also handles the new-ticket case (no transition).

swh_save_ticket_data captures $old_status at line 421:

$old_status = swh_get_string_meta( $post_id, '_ticket_status' );

…then writes new at line 443. Hook fires after the write.

swh_ticket_assigned ($ticket_id, $old_user_id, $new_user_id)

File:Line Context
admin/class-ticket-editor.php:445 swh_save_ticket_data writes _ticket_assigned_to; $old_assigned_to captured at line 421, comparison already exists at line 450
includes/helpers.php:458 swh_apply_assignment_rules() writes assignee on submission

Plan’s “do not fire when going from 0 → 0” rule applies cleanly to both sites.

swh_ticket_closed ($ticket_id, $previous_status) / swh_ticket_reopened ($ticket_id, $previous_status)

Fire after swh_ticket_status_changed at each of the 9 status-change sites listed above, conditional on the transition direction. The “closed” status to compare against is get_option('swh_closed_status', $defs['swh_closed_status']). The “open”/reopened status is get_option('swh_reopened_status', ...).

Recommend: implement a small helper inside Phase 2 (swh_fire_status_change_actions($ticket_id, $old, $new)) that all 9 sites call, instead of duplicating the closed/reopened detection logic. Keep this helper private to Phase 2 (not part of the public hook API).

swh_sla_breached ($ticket_id, $minutes_over)

Single site — simple-wp-helpdesk/includes/class-cron.php:390:

$current_level = swh_get_string_meta( $ticket->ID, '_ticket_sla_status' );  // line 387
if ( /* breach condition true */ ) {
    if ( 'breach' !== $current_level ) {                                     // line 389
        update_post_meta( $ticket->ID, '_ticket_sla_status', 'breach' );     // line 390 ← fire here, after the update
    }
} else {
    update_post_meta( $ticket->ID, '_ticket_sla_status', 'warn' );           // line 395
}

The if ( 'breach' !== $current_level ) guard at line 389 is exactly the “first transition to breach” gate the plan requires. Fire the hook immediately after the update_post_meta call inside that if. $minutes_over must be computed from the SLA threshold + the ticket’s age — variables for both already exist in the surrounding scope (verify in class-cron.php lines 311–395 before wiring; full function not re-quoted here to keep this doc short).

swh_csat_submitted ($ticket_id, $rating)

Exact function: swh_submit_csat_ajax() in simple-wp-helpdesk/includes/class-ticket.php lines 391–~425.

Registered at:

  • simple-wp-helpdesk/includes/class-ticket.php:391add_action( 'wp_ajax_swh_submit_csat', 'swh_submit_csat_ajax' );
  • simple-wp-helpdesk/includes/class-ticket.php:392add_action( 'wp_ajax_nopriv_swh_submit_csat', 'swh_submit_csat_ajax' );

Rating saved at simple-wp-helpdesk/includes/class-ticket.php:421:

update_post_meta( $ticket_id, '_ticket_csat', $rating );

Fire do_action( 'swh_csat_submitted', $ticket_id, $rating ) immediately after line 421.


WP core API confirmation

Both helpers exist in the WP stubs bundled in this repo (vendor/php-stubs/wordpress-stubs/wordpress-stubs.php):

  • apply_filters_deprecated( $hook_name, $args, $version, $replacement = '', $message = '' ) — line 134876.
  • do_action_deprecated( $hook_name, $args, $version, $replacement = '', $message = '' ) — line 134898.

WP version added: both were introduced in WP 4.6 (per WP core history — confirmed by the inline example in stubs at line 134862 referencing '4.9.0' as an example use). Plugin minimum is WP 5.3, so they are safe to call unconditionally. Plan claim (“available since WP 4.6”) is correct. ✅

Other plan-asserted APIs (wp_set_post_terms(), get_post_meta(), update_post_meta()) are already in use throughout the codebase — no verification needed.


Confidence notes

High confidence (grep-verified, file:line cited)

  • 0 get_option('swh_options') reads.
  • 30 get_option('swh_*') reads.
  • 2 existing do_action('swh_*'); 10 existing apply_filters('swh_*').
  • All 11 comment-insert sites for comment_type = 'helpdesk_reply'.
  • All 9 _ticket_status write sites.
  • All 2 _ticket_assigned_to write sites (for the assignee hook).
  • SLA breach transition site (single, exact line).
  • CSAT save site (single, exact line).
  • apply_filters_deprecated / do_action_deprecated exist in WP 5.3+ stubs.

Medium confidence — re-verify in Phase 2

  • simple-wp-helpdesk/includes/class-cron.php:185 comment-insert site — context not fully inspected; could be retention-related or autoclose-related. The next-phase executor should Read lines 170–210 of class-cron.php before deciding whether it fires swh_ticket_replied.
  • The $minutes_over calculation for swh_sla_breached — variables exist in scope but I did not enumerate them. Executor must Read full swh_process_sla_check() function (around class-cron.php:307–400) before wiring.

Gaps to re-verify before each phase

  • Phase 1 (#391): Must resolve the monolithic-bag conflict (see top of doc) before writing any code. If the helper ships as the plan describes verbatim, every setting in the plugin reverts to default.
  • Phase 2 (#361): Decide which of the 11 comment-insert sites fire swh_ticket_replied. Recommend the 3 truly-user-facing sites (admin reply 573, portal reply 199, inbound email 319) and document the exclusion of system-generated comments.
  • Phase 4 (#394): Bootstrap has 13 require_once lines, not “9” as the plan implies. Adjust mental model.
  • CLAUDE.md “Repository Structure” diagram is outdated — shows admin/ etc. at repo root, but they are actually under simple-wp-helpdesk/. Out of scope for v3.7.0 but flag to user.

Issue snapshots saved

docs/internal/v3.7.0-issue-snapshots/issue-{361,390,391,392,393,394,395}.md — title, labels, milestone, full body each. Source-of-truth for acceptance criteria; phases should quote these, not paraphrase.