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 showingadmin/,includes/,frontend/at the top level is inaccurate — the real layout issimple-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_optionsinto 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.
Recommended Phase 1 path (for the human to choose, not for the next executor to assume)
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) fireswh_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:391—add_action( 'wp_ajax_swh_submit_csat', 'swh_submit_csat_ajax' );simple-wp-helpdesk/includes/class-ticket.php:392—add_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 existingapply_filters('swh_*'). - All 11 comment-insert sites for
comment_type = 'helpdesk_reply'. - All 9
_ticket_statuswrite sites. - All 2
_ticket_assigned_towrite sites (for the assignee hook). - SLA breach transition site (single, exact line).
- CSAT save site (single, exact line).
apply_filters_deprecated/do_action_deprecatedexist in WP 5.3+ stubs.
Medium confidence — re-verify in Phase 2
simple-wp-helpdesk/includes/class-cron.php:185comment-insert site — context not fully inspected; could be retention-related or autoclose-related. The next-phase executor shouldReadlines 170–210 ofclass-cron.phpbefore deciding whether it firesswh_ticket_replied.- The
$minutes_overcalculation forswh_sla_breached— variables exist in scope but I did not enumerate them. Executor must Read fullswh_process_sla_check()function (aroundclass-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_oncelines, not “9” as the plan implies. Adjust mental model. CLAUDE.md“Repository Structure” diagram is outdated — showsadmin/etc. at repo root, but they are actually undersimple-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.