# Business rules — tom

Evidence date: 2026-10-01. Source: `master` at `0ceed11`.

Rules are taken from code and from text in the theme. They are not a separate business-rules document. Where the code implies a rule that nobody wrote down, the rule is marked **Inferred**.

Secret values are not recorded here.

## Identity

| Rule | Statement | Provenance |
|---|---|---|
| BR-001 | The theme is for the Old Mutual Two Oceans Marathon | **Documented** (`style.css`, `README.txt`) |
| BR-002 | Public copy and the contact-email logo alt text use the name "Two Oceans Marathon" | **Implemented** (`classes/forms/forms.php`) |
| BR-003 | The Twitter card site handle is `@2OceansMarathon` | **Implemented** (`header.php`) |
| BR-004 | API admin help names the same event for Facebook posts, Twitter `@2OceansMarathon`, and Instagram | **Documented** (`options.api.php`) |

What "TOM" expands to beyond Two Oceans Marathon / OMTOM is **Unknown / Requires Confirmation**. The theme description is the only expansion found.

## Events and entries

| Rule | Statement | Provenance |
|---|---|---|
| BR-010 | An event with meta `tickets-available` set shows the status "Entries Open" on the home events grid | **Implemented** (`front-page.php`) |
| BR-011 | An event without that flag shows meta `no-tickets` as the status when that text is non-empty. If both are empty, no status line is printed | **Implemented** |
| BR-012 | Ticket availability is a checkbox on the event edit screen ("Ticket Settings") | **Implemented** (`tickets-available.meta.php`) |
| BR-013 | "ENTER NOW / via" controls on the event single template use an empty `href` | **Implemented** (`single-events.php`). **Inferred:** the template does not encode a Webtickets (or other) entry URL. Icons and the CSS class `webtickets` are the only entry-vendor evidence |

Route files encode these distance names: ultra marathon, half marathon, trail 12 km, trail 24 km, fun run 2.1 km, fun run 5.6 km, friendship run, and a shuttle route. Provenance: **Implemented** as filenames. Whether those are the current official race programme is **Unknown / Requires Confirmation**.

Map script page ids 1–6 select ultra, half, trail, fun run, friendship, and shuttle (`assets/js/maps.js`). Provenance: **Implemented**.

## Newsletter

| Rule | Statement | Provenance |
|---|---|---|
| BR-020 | A subscription requires a non-empty name | **Implemented** (`forms.php` `save_subscription`) |
| BR-021 | A subscription requires an email that passes PHP `FILTER_VALIDATE_EMAIL` | **Implemented** |
| BR-022 | A valid subscription is inserted into `wp_omtom_subscriptions` (`name`, `email`) | **Implemented**. Table creation is **Unknown** |
| BR-023 | The same request is posted to Touchbase Pro with resubscribe true, consent to track "Yes", and restart subscription-based autoresponders true | **Implemented** |
| BR-024 | The code does not check for an existing email before insert | **Implemented** (absence of a duplicate query). **Inferred:** the same address can be stored more than once locally |
| BR-025 | Failed validation returns `success` false and the field key `subscription-name` and/or `subscription-email`. It does not return a human-readable sentence | **Implemented** |
| BR-026 | There is no WordPress nonce check in `save_subscription` | **Implemented** |
| BR-027 | Logged-out visitors may call the action (`wp_ajax_nopriv_handle_subscriptions`) | **Implemented** |

The local insert runs before the Touchbase call. If Touchbase fails, the local row can already exist. Provenance: **Inferred** from statement order.

## Contact

| Rule | Statement | Provenance |
|---|---|---|
| BR-030 | Contact name, email, and message are required | **Implemented** |
| BR-031 | Contact email must pass `FILTER_VALIDATE_EMAIL`. Invalid email error text is "Invalid Email". Empty required fields use "Field Required" | **Implemented** |
| BR-032 | Subject is optional. Default subject is "Two Oceans Marathon Contact Form Message" | **Implemented** |
| BR-033 | Mail is sent to option `contact-details-settings['form-email']` using PHP `mail()` | **Implemented** |
| BR-034 | Name, subject, and email header values are passed through `strip_tags`. The message body is passed through `the_content` | **Implemented** |
| BR-035 | If `mail()` fails, the JSON response is the string "Failed to send email" | **Implemented** |
| BR-036 | No nonce check is present in `send_contact_message`. Logged-out visitors may call it | **Implemented** |

Contact directory sections an editor can fill (not hardcoded addresses): race/entries/seeding/results; non-race reception; media; exhibition and sales (person, website, phone, email); postal address; physical address; office hours; VAT number; form recipient. Provenance: **Implemented** as fields. The values are **Unknown** (database).

## Cookie notice

| Rule | Statement | Provenance |
|---|---|---|
| BR-040 | If cookie `OMTOMDisclaimer` is missing, the notice banner is shown | **Implemented** (`assets/js/functions.js` `check_cookie_state`) |
| BR-041 | CLOSE hides the banner and does not write the cookie | **Implemented** (`hide_cookie_disclaimer`) |
| BR-042 | On `window.onload`, the site sets `OMTOMDisclaimer=1` for 10 years, path `/`, whether or not CLOSE was used | **Implemented** (`functions.php` `cookie_notice`, commit `0ceed11`) |
| BR-043 | Banner copy says that closing the banner, clicking a link, or continuing to browse agrees to cookies, and links the cookie policy to post ID 3 | **Implemented** (`footer.php`) |
| BR-044 | The earlier server-side rule (set `OMTOMInitialLoad`, then `OMTOMDisclaimer`, each for two years) is commented out and does not run | **Implemented** as disabled code |

**Inferred:** after the first full page load, later views do not show the banner, because onload has already stored the cookie. That matches commit `2f1a52b` ("thereafter it won't show the popup again"). It does not match a rule that consent is stored only when CLOSE is pressed.

Whether this matches the event's legal cookie policy is **Unknown / Requires Confirmation**.

## Charity applications

| Rule | Statement | Provenance |
|---|---|---|
| BR-050 | On "Charity Applications", if the page has button meta, the Google Form iframe is always embedded with the content | **Implemented** (`template.charity-applications.php`) |
| BR-051 | If the page has no button meta, the iframe is embedded only when meta `enable_application_form` is set | **Implemented** |
| BR-052 | Sidebar buttons render only when both URL and label are non-empty | **Implemented** |
| BR-053 | `?panel` suppresses the theme header and footer on this template | **Implemented** |

The Google Form's own required fields are **Unknown**. They are not defined in this repository.

## Content and display

| Rule | Statement | Provenance |
|---|---|---|
| BR-060 | Sponsor categories have an order number. Help text says terms pull through in ascending order. Minimum on the input is 0 | **Implemented** (`functions.php` term meta). Whether every sponsor query honours that meta was not verified in every template |
| BR-061 | Social icons: admin description says clearing the URL hides that icon | **Documented** (`options.social.php`) |
| BR-062 | A "Keeping you posted" slot is rendered when background image, heading, and button URL are all non-empty | **Implemented** (`front-page.php`) |
| BR-063 | The home slider loads at most 20 published `homepage_slider` posts, ordered by `menu_order` ascending. A slide is output only when it has a featured image | **Implemented** (`front-page.php`) |
| BR-064 | The home events list loads at most 100 published `events` posts with `post_parent` 0, ordered by `menu_order` ascending | **Implemented** (`front-page.php`) |
| BR-065 | Instagram feed items are included only when `type` is `image` | **Implemented** (`pull_instagram_feed`) |
| BR-066 | Comments are removed from the admin menu | **Implemented**. Front-end comment posting via `wp-comments-post.php` is still part of WordPress core. Whether comments are closed in the database is **Unknown** |
| BR-067 | Custom post types that set `exclude_from_search` are omitted from search: homepage slider, race-day tabs, route-info tabs, prizes tabs, compulsory equipment, T-shirts, combined results, charities, sponsors, FAQs | **Implemented** (registration args) |
| BR-068 | Theme Settings and its submenus (Keeping You Posted, Homepage Links, Advertising Space, Vision/Mission/Values, Social Media, Contact Us, API Keys, Newsletter Export) require `manage_options` | **Implemented** (`classes/theme-options/menus.php`) |
| BR-069 | The Newsletter Export screen is an admin menu for `manage_options`. The AJAX action that builds the CSV is still registered for logged-out users (BR-073) | **Implemented**. The menu rule and the AJAX rule disagree. **Requires Confirmation** if the export was meant to be administrators only |

## Reporting

| Rule | Statement | Provenance |
|---|---|---|
| BR-070 | Newsletter CSV includes columns Name, Email, Date | **Implemented** |
| BR-071 | Rows are subscriptions whose `created` value is between the requested from-date and to-date, ordered by `created` descending | **Implemented** |
| BR-072 | The date strings from the request are concatenated into SQL | **Implemented** |
| BR-073 | The action is registered for logged-out users | **Implemented** |
| BR-074 | CSV cell values are wrapped in quotes after replacing `;` with `,` | **Implemented** (`wrap_value`) |
| BR-075 | The file name pattern is `newsletter-report-` plus `YmdHi`, written under the uploads path for the current month | **Implemented** |
| BR-076 | The JSON response is the filesystem path, not a public URL | **Implemented** |

## Permissions

No custom capability or role is defined. Post types use capability type `post` unless a call overrides it. The calls in `functions.php` do not pass `capability_type`, so they use the class default `post`. Provenance: **Implemented**.

Theme Settings menus require `manage_options` (BR-068). Other WordPress admin screens (users, plugins, posts) follow WordPress core. A full role matrix beyond that is **Unknown**.

## Configuration rules that affect behaviour

| Rule | Statement | Provenance |
|---|---|---|
| BR-080 | `$_SERVER['HTTPS']` is set to `on` for every request that loads `wp-config.php` | **Implemented** |
| BR-081 | `WP_CACHE` is false, so the W3 Total Cache constant does not enable the advanced cache drop-in | **Implemented** |
| BR-082 | `FS_METHOD` is `direct` | **Implemented** |
| BR-083 | Table prefix is `wp_` | **Implemented** |
| BR-084 | Active database name is `twooceansmarathon`, user `root`, host `localhost` | **Implemented** in the uncommented defines. A commented block names user `admin` and an RDS host. Which block a server actually runs is the file as committed, so the uncommented block is the one PHP executes |

## Not business rules

- Plugin feature sets (Yoast, SEOPress, TablePress, Wordfence, Crazy Egg) are vendor products. Their internal rules are not re-documented here. Whether they are active is **Unknown**.
- WordPress core login, password reset, and cron behaviour are core, not OMTOM rules.
- Distances in filenames are evidence of content that was designed into the theme. They are not, by themselves, a statement of the current official race rules.
