MammothWP Acceptance Logs: use cases

What MammothWP Acceptance Logs does and how people use it, from the product's readme and the docs in its repository.

Usage

Users in the selected roles are prompted the next time they load the site or the dashboard while logged in. Whoever activated the plugin becomes its first manager; managers are the only people who can read the acceptance log, under Users > Terms acceptance log.

Download and install

The wordpress.org listing is pending review. Until it is live, install from this repository, as described on

  1. Download the ZIP of main from GitHub (Code → Download ZIP).
  2. Unzip it, rename the folder admin-terms-acceptance-main to mammoth-acceptance-logs, and zip that folder again.
  3. In WordPress go to Plugins → Add New → Upload Plugin, choose the zip and install it.
  4. Activate the plugin, then open Settings → Terms & Conditions.

The GitHub tree has no vendor/ directory. The core acceptance flow works without it, but the REST API, WP-CLI, the Abilities API, the Support screen and PDF receipts need the dependencies. Run this inside the plugin folder before zipping (or on the server):

composer install --no-dev --optimize-autoloader

Requires WordPress 6.2+ and PHP 8.1+. To build a distributable zip from a checkout, use composer build (see Contributing).

Source: README.md(opens in a new tab)

Usage (README.md)

Full details in 0 (opens in a new tab).

SurfaceExample
AdminSettings → Terms & Conditions; Users → Terms acceptance log
RESTGET /wp-json/mmwp/v1/terms/acceptances?version_id=3 (plugin managers)
WP-CLIwp mmwp terms publish --content="$(cat terms.html)" --only-if-changed
Abilitiesmmwp-acceptance-logs/list-acceptances (read-only)

Source: README.md(opens in a new tab)

Support

Use the support forum on wordpress.org, or e-mail [email protected]. The plugin never sends anything on its own; see 0 (opens in a new tab).

Source: README.md(opens in a new tab)

Questions answered in the plugin readme

No, and the distinction drives the whole design. Accepting Terms & Conditions is an access-control and contractual-compliance mechanism resting on Art. 6(1)(b) and 6(1)(f) GDPR, not consent under Art. 6(1)(a). Practically: acceptance is not withdrawable in the Art. 7(3) sense, and records are not deleted on request. If you need cookie consent, you need a different plugin — run both.

Which roles are prompted?

Until you choose, every role. The plugin does not guess a safe subset, and picking the roles is step 3 of the setup for that reason. Settings → Terms & Conditions lists every role on the site.

Does it lock users out until they accept?

No. The popup has a "skip for now" button, and a user who clicks it keeps working. Skipping is not recorded as an acceptance or a decline: it only hides the popup for that user for 12 hours (filterable with mmwp_terms_skip_duration) and fires the mmwp_terms_skipped action; nothing is written to the acceptance log or audit events. What persists is a non-dismissable notice on every wp-admin screen, shown only to logged-in users in scope who have not accepted the current version, until they accept. It is a prompt with no exit, not a wall — a hard lockout risks stranding an administrator who needs the backend to fix the terms.

What happens when I edit the terms?

Editing and publishing creates a new version with the next sequential number, archives the full text permanently, and re-prompts everyone in scope. Previous versions are never deleted, and republishing earlier text is a new publication with a new number rather than a silent return to an old one — so "revert the text and everyone counts as accepted again" cannot happen. Changing the text without publishing changes nothing. The daily cron only picks up changes made through a content source that bypasses the settings screen.

Can a user withdraw their acceptance?

No. The record is evidence that a particular version was accepted at a particular moment; that fact does not stop being true. A user who does not want to accept can only skip the popup for now; that is not recorded (see above), and the acceptance prompt returns until they accept.

How do I handle an erasure request?

Through WordPress's own Tools → Erase Personal Data — the plugin registers an eraser. It returns the acceptance records as retained, with the reasoning shown to the requester rather than applied silently, and erases the context (IP, User-Agent) of any record already past its retention period. Art. 17(3)(b) and (e) disapply the right to erasure where processing is necessary for legal claims, which is what proof of acceptance is. See the retention policy document in the plugin repository.

How long is data kept?

Records live while the account exists, plus a dispute period defaulting to five years, after which the IP address, IP source and User-Agent are erased and the record is retained in anonymised form. Records are anonymised, never deleted. Published versions of the terms are never deleted at all — an acceptance whose text cannot be reproduced proves almost nothing.

I'm an administrator. Why can't I see the acceptance log?

Access is an explicit list of user accounts, not a capability. That is deliberate: manage_options gets handed out liberally by role plugins and during onboarding, and the log is evidence about other people. Whoever activated the plugin is the first manager and can appoint others; the last manager cannot be removed.

Can I delete a record, or fix a wrong one?

No, by design. There is no update or delete path in the code and no edit or delete affordance in the admin. If a record is wrong, the answer is a new record plus an audit event. Their absence is the control, not an oversight.

Does it work behind Cloudflare or a load balancer?

Yes, but configure the trusted proxies first or the audit log records the proxy's address instead of the visitor's. Return your proxy's address or CIDR range from the mmwp_terms_trusted_proxies filter. Forwarded IP headers are ignored by default because trusting them unconditionally would let any client write its own address into the log.

Does it support multisite?

Each site gets its own tables and settings, so activate it per site. Network activation is not supported: the tables are created for the site the activation runs on, not for every site in the network.

Where do I get evidence for a dispute?

Users → Terms acceptance log, which shows every record with its integrity and delivery status, filterable, with CSV export and an on-demand integrity check. the evidence-handling document in the plugin repository covers the procedure, what the evidence actually shows, and the limitations you should disclose alongside it.

What personal data is stored?

Per acceptance: user ID, login and e-mail as they were at the time, the version accepted, UTC and local timestamps with timezone, the site identifier, IP address (and how it was determined), User-Agent and cryptographic signatures. Three user-meta markers record the version each user accepted. No fingerprinting, MAC address or geolocation is collected.

Does it support the WordPress personal data tools?

Yes. It registers a personal-data exporter and eraser (wp_privacy_personal_data_exporters, wp_privacy_personal_data_erasers) and adds suggested text to the Privacy Policy guide with wp_add_privacy_policy_content().

Does it contact any external service?

No. There are no remote calls, no content delivery network assets, no update checks and no telemetry. The bundled Support screen (Mammoth WP > Support) sends one e-mail through wp_mail() to [email protected], only when an administrator presses Send. A site report is attached only if they tick "Include site report" after previewing exactly what it contains.

What happens when I delete the plugin?

By default the acceptance evidence is kept. When you click Delete on the Plugins screen, a dialog asks whether to keep the acceptance evidence (recommended) or delete it permanently; deleting requires typing the plugin name. Either way the plugin's settings, scheduled tasks and options are removed. If you keep the evidence, the log, the version archive, the audit events, the per-user acceptance markers and the signing key stay in your database, so they can still be verified later. You can change the choice at any time under Settings > Terms & Conditions, or purge the evidence immediately with wp mmwp terms purge (it only reports what exists; add --force to delete, --dry-run to never delete). Developers can override the choice with the mmwp_terms_drop_data_on_uninstall filter.

Can I manage it without the admin screens?

Yes: REST (mmwp/v1/terms/...), WP-CLI (wp mmwp terms ...) and the Abilities API, all declared once. Reading the acceptance log over any of them requires being a plugin manager and returns a redacted shape (no IP address, User-Agent or e-mail).

Producing acceptance evidence for a dispute

What to do when someone has to demonstrate that a named person accepted a named version of the terms — and what to be careful about while doing it.

The procedure

  1. Verify the log before you export it. Users → Terms acceptance log → Verify integrity. Do this first: an export taken from a log you have not checked tells you nothing about whether the log is intact. Record the result, including a clean one.
  2. Filter to the person. Filter by user ID, or open their profile and follow "View this user's acceptance records".
  3. Export the CSV. It carries every field, both signatures, the chain link, and a per-row verification verdict.
  4. Attach the version. From Settings → Terms & Conditions → Version history, download the PDF of the version they accepted. The record names a version; the version supplies the text. One without the other proves half the point.
  5. State the integrity position honestly — see below.

What the evidence actually shows

ElementWhat it establishes
User ID + username/email snapshotWhich account acted, readable even after the account is deleted
UTC timestamp + local time and offsetWhen, unambiguously, regardless of later timezone changes
Version ID, number, effective date, hashWhich document
Archived version textWhat that document said — the part a hash alone cannot supply
ActionThat the act was an acceptance
Site identifierWhich installation it happened on
IP address + sourceWhere the request came from, and whether that came from the connection or a trusted proxy header
User-AgentCorroborating circumstances only — trivially spoofable, never primary proof
record_hash / prev_record_hashThat the record has not been altered or unlinked since it was written

Integrity: state it accurately

The signatures are tamper-evidence, not tamper-proofing. Overstating this is worse than claiming nothing, because the overstatement is what gets attacked.

  • Key in wp-config.php (MMWP_TERMS_INTEGRITY_KEY): the key sits outside the database. Someone with SQL access alone can alter a record but cannot produce a matching signature, so the alteration is detectable.
  • Key in the database (the default when the constant is absent): detects accidental corruption and careless direct edits. Someone who holds the database also holds the key and could re-sign an altered record. Say so.
  • Neither protects against an attacker with full filesystem access, who could alter the plugin itself.

Set the constant before you need to rely on the log. Records signed under the old key stop verifying when the key changes.

Known limitations to disclose

Disclose these rather than let them be discovered. A limitation you volunteered costs you far less than one found in cross-examination.

  • Context signature on anonymised records. Once a record passes its retention period, the IP address and User-Agent are erased and the context signature is cleared with them. This is the retention policy working as documented, not tampering; the record's own signature still verifies. The anonymised_at_utc field carries the date, and an anonymised audit event carries the reason.
  • Chain gaps. If two acceptances are recorded at the same instant, the advisory lock that orders them can time out. The plugin records the gap as a chain_gap audit event and links nothing, rather than writing a link that looks valid and is not. The per-record signature is unaffected. A gap is a known, logged state — not evidence of interference.
  • Proxy addresses. If the site sits behind a content delivery network or load balancer and mmwp_terms_trusted_proxies was never configured, ip_address is the proxy's address, and ip_source says remote_addr. Check that field before asserting anything about where a request came from.
  • User-Agent weight. Corroborating only.
  • Receipt email. The emailed PDF is supplementary. Delivery is logged (email_sent / email_failed), but delivery to a mail server is not proof the person read it. Where an ESP webhook is wired to mmwp_terms_delivery_status_recorded, provider-side status is available too.

If verification fails

Do not "fix" anything. There is no repair function, deliberately: re-signing a record that failed verification would destroy the only evidence that something happened to it.

  1. Note which record IDs failed and how (record signature, context signature, or chain link).
  2. Preserve a database backup taken before any further change.
  3. Check whether the integrity key changed — the most common innocent explanation, and one that invalidates every signature at once rather than a few.
  4. Check the audit events around the affected IDs.
  5. Treat a small number of failures against an otherwise valid chain as the more serious signal: wholesale failure usually means a key change, while isolated failure suggests a specific record was touched.

Versions published programmatically

The source column on each version says how it was published: settings (an admin typed it), filter (picked up from an external content source), rest, cli, api or code. Quote it — "the version was published by the legal team's CMS sync at 03:14 UTC" is a more complete account than "the version existed".

Two things follow for programmatic publications:

  • Where no version number was supplied, one was derived (1.0 → 1.1, or a dated identifier). A derived number is still unique and still names one specific document, but it was assigned by the system rather than chosen by a person — say so rather than implying a human numbered it.
  • Where no effective date was supplied, none is recorded. The field is empty rather than inherited from earlier text, which is deliberate: an inherited date would be a false claim. An empty effective date is not a gap in the evidence — the publication time in created_at_utc is authoritative for when the version became active.

Without the admin UI

On installs where the management UI is switched off (MMWP_TERMS_DISABLE_ADMIN_UI), there is no acceptance log screen and no CSV button. Either re-enable it temporarily on the install, or read the tables directly:

wp db query "SELECT a.*, v.version_number, v.effective_date \
  FROM wp_mmwp_terms_acceptances a \
  LEFT JOIN wp_mmwp_terms_versions v ON v.id = a.version_id \
  WHERE a.user_id = 123 ORDER BY a.id DESC" --skip-column-names

Reading the table directly gives you no integrity verification. Re-enable the UI and run the check, or verify the signatures separately, before relying on what you pull out this way.

The version archive itself is reachable without the UI:

wp mmwp terms list
wp mmwp terms get --format=json

Source: evidence-handling.md(opens in a new tab)

Privacy

What is stored

Per acceptance (table {prefix}mmwp_terms_acceptances): user ID, login and e-mail snapshot, the version accepted and its hash, UTC and local time with timezone, the site identifier, IP address and its source, User-Agent, and two signatures plus a chain link. Events about a record (receipt delivery, anonymisation) go to {prefix}mmwp_terms_audit_events. Each user also gets three user-meta markers recording the version they accepted.

No device fingerprinting, MAC address or geolocation is collected.

GDPR Art. 6(1)(b) and 6(1)(f). Not consent (Art. 6(1)(a)); acceptance is not withdrawable and records are not deleted on request. See dpia-screening.md, ropa.md, retention-policy.md.

Retention

IP address, its source and the User-Agent (the erasable context) are cleared after the configurable dispute period (default five years) by a daily sweep; the core record keeps verifying. The anonymisation is audited.

Personal-data tools

  • Exporter (Tools → Export Personal Data) lists a user's acceptances.
  • Eraser anonymises the erasable context where retention allows and reports what it retained and why; the core evidence stays.
  • Privacy policy text is suggested via wp_add_privacy_policy_content().

Network

The plugin makes no outbound requests, loads no remote assets, and sends no telemetry. Two kinds of e-mail leave through wp_mail(): the receipt to the accepting user (and an optional address you configure), and a support request to [email protected] that an administrator sends from Mammoth WP → Support. The support request carries a site report only if the administrator ticks "Include site report" after previewing exactly what it contains.

Uninstall: what is kept and what is deleted

Always removed when the plugin is deleted: settings, options, scheduled tasks and transients.

Kept by default (the acceptance evidence): the three tables ({prefix}mmwp_terms_acceptances, {prefix}mmwp_terms_versions, {prefix}mmwp_terms_audit_events), the per-user acceptance markers and the signing key, so kept records can still be verified.

The choice is asked when you click Delete on the Plugins screen ("Keep acceptance evidence (recommended)" or "Delete all acceptance evidence permanently", the latter needing the plugin name typed back). It is stored in the mmwp_terms_uninstall_data option and can be changed under Settings → Terms & Conditions. The filter mmwp_terms_drop_data_on_uninstall overrides it.

Purging evidence now

wp mmwp terms purge              # report what exists, delete nothing
wp mmwp terms purge --dry-run    # same, explicitly
wp mmwp terms purge --force      # permanently delete the evidence

Irreversible: after a purge no acceptance can be reproduced or proved.

Source: privacy.md(opens in a new tab)

Programmatic API reference

The supported programmatic surface of mammoth-acceptance-logs: Api\TermsApi and its REST, WP-CLI and helper-function wrappers, plus every action and filter the plugin fires.

Programmatic management

Every route below goes through the same code path, so a version published by any of them is archived, numbered and audited identically. All of them keep working with the admin UI switched off — a locked-down install is precisely the one that has to be driven by code.

Publishing re-prompts everyone in scope. That is the point, but it means publishing on every deploy when nothing changed would nag your users into ignoring the dialog. Use the "only if changed" variants in automation.

Version numbers are derived, never inherited

If you publish without a version_number, one is derived — 1.0 becomes 1.1, 3 becomes 4, and a non-numeric or absent number falls back to a dated identifier, stepping until it finds an unused one. It is never copied from the version being replaced: two different documents sharing a number is the exact ambiguity the archive exists to remove, and the settings screen already refuses to allow it.

The effective date is never inherited either. It is a statement about a specific document, so a version published without one simply has none recorded, rather than borrowing a date that belonged to different text.

PHP

// Publish, asking everyone in scope to accept.
$result = mmwp_terms_publish_version( $html, array(
    'version_number' => '2.1',
    'effective_date' => '2026-10-01',
    'change_summary' => 'Clarified section 4.',
) );

if ( is_wp_error( $result ) ) {
    // Duplicate version number, empty content, bad date, or a failed write.
}

// For sync jobs: no-op when the text already matches the active version.
mmwp_terms_publish_version_if_changed( $html, array( 'version_number' => '2.1' ) );

// Re-read the content source now instead of waiting for the daily cron.
$new_version_id = mmwp_terms_refresh();     // null if nothing changed

mmwp_terms_active_version();                // array|null
mmwp_terms_user_status( $user_id );         // accepted|pending|not_required

Call these on or after plugins_loaded.

WP-CLI, REST and Abilities

These three are declared once with wp-kit (Api\TermsResource) and share one validation and authorisation path. Commands, routes and abilities are listed in usage.md (opens in a new tab). Permissions are decided by wp-kit's capability resolver (edit_posts for reads, manage_options for publishing, plugin managers for the acceptance listing) and can be adjusted with its mmwp_kit_operation_capability filter.

Public read routes

Publishing your terms on a public page needs the text readable without a login, which the routes above deliberately are not. A second, narrower set covers that — read-only, versions only, and redacted:

RouteMethodPurpose
/public/versionGETThe active version, with its full text
/public/versionsGETThe archive, newest first, without the text (per_page, offset)
/public/versions/<id>GETOne version, with its full text
curl https://example.com/wp-json/mmwp/v1/public/version

Each response carries id, version_number, label, effective_date, change_summary and is_active, plus content on the single-version routes. version_hash, source, created_at_utc and the activation window are not exposed: they are internal provenance for whoever audits the archive, not information a visitor reading the terms needs. Responses are anonymous and sent with Cache-Control: public, max-age=300.

Set the mmwp_terms_public_api_enabled filter to false to remove these routes entirely — they then 404 rather than 401, so an install whose terms are internal does not advertise that the endpoint exists. Doing so also leaves the Terms & Conditions block with nothing to fetch.

Actions

HookFires whenArgs
mmwp_terms_accepteda user accepts$user_id, $version_hash, $acceptance_id, $version_id
mmwp_terms_record_failedan acceptance could not be persisted$user_id, $version_id, $db_error
mmwp_terms_skippeda user clicks "skip for now"$user_id
mmwp_terms_version_publisheda new version is archived and activated$version_id, $version_hash
mmwp_terms_version_changedthe content hash changes (save or cron)$old_hash, $new_hash
mmwp_terms_content_savedthe settings page is saved$version_hash, $meta
mmwp_terms_delivery_status_recordedintegration point for ESP delivery webhooks$acceptance_id, $provider_message_id, $status

Hook mmwp_terms_record_failed and alert someone. Silently losing acceptance evidence is the worst failure this plugin can have.

Filters

FilterPurposeDefault
mmwp_terms_enable_admin_uiRemove the whole management UI. See "Headless deployment".true
mmwp_terms_can_manageOverride who may manage the plugin. Cannot re-enable anything while the kill-switch is on.in the manager list
mmwp_terms_eligible_managersThe users offered when appointing a manager.administrators
mmwp_terms_trusted_proxiesProxies whose forwarded-IP header may be trusted (IPs or CIDR ranges).[]
mmwp_terms_ip_headerWhich $_SERVER key carries the forwarded client IP.HTTP_X_FORWARDED_FOR
mmwp_terms_site_identifierThe source application identifier stored on each record.host + blog ID
mmwp_terms_retention_settingsDispute period and whether anonymisation runs.5 years, enabled
mmwp_terms_content_sourceSwap the content source entirely — a JSON file, a CPT, an external legal CMS.the mmwp_terms_content option
mmwp_terms_version_metaSupply the version number, effective date and change summary for a publication. Runs last and wins — see "Pulling from an external system".[]
mmwp_terms_public_api_enabledWhether the unauthenticated /public/* read routes are registered at all. The Terms & Conditions block depends on them.true
mmwp_terms_excluded_rolesRole slugs exempt from the gate.[]
mmwp_terms_excluded_usersIndividual user IDs exempt from the gate.[]
mmwp_terms_send_pdf_emailDisable the emailed PDF receipt. ($enabled, $user_id).true
mmwp_terms_notify_admin_emailCopy every acceptance notification to this address.''
mmwp_terms_skip_durationHow long "skip for now" suppresses the overlay. The admin notice is unaffected.12 * HOUR_IN_SECONDS
mmwp_terms_drop_data_on_uninstallWhether deleting the plugin also drops the three tables and the acceptance markers. Return false to keep the evidence.true

Example: source the terms from a JSON file instead of the settings page

add_filter( 'mmwp_terms_content_source', function ( string $content ) {
    $path = WP_CONTENT_DIR . '/mmwp-terms/terms.json';
    if ( ! is_readable( $path ) ) {
        return $content;
    }
    $data = json_decode( (string) file_get_contents( $path ), true );
    return (string) ( $data['content'] ?? $content );
} );

The daily cron notices the change and archives the new version automatically, so acceptances against it still point at preserved text.

Example: exclude WooCommerce customers, keep staff roles gated

add_filter( 'mmwp_terms_excluded_roles', fn ( array $roles ) => array_merge( $roles, [ 'customer' ] ) );

Example: record provider-side delivery status from an ESP webhook

// The plugin cannot poll your provider; wire its webhook to this action.
do_action( 'mmwp_terms_delivery_status_recorded', $acceptance_id, $message_id, 'delivered' );

Source: programmatic-api.md(opens in a new tab)

Usage (usage.md)

Admin

  1. Settings → Terms & Conditions: write the terms, choose the roles that must accept, publish the version.
  2. Users → Terms acceptance log: the evidence, with signature verification. Only plugin managers can open it — being an administrator is not enough.

Users in scope see the popup on their next page load while logged in.

REST (mmwp/v1)

Authenticate like any WordPress request (cookie + nonce, or an application password). Routes are declared once through wp-kit.

Method and routeWho
GET /mmwp/v1/terms/versionedit_posts
GET /mmwp/v1/terms/versions?limit=&offset=edit_posts
GET /mmwp/v1/terms/versions/<id>edit_posts
POST /mmwp/v1/terms/version (content, version_number, effective_date, change_summary, only_if_changed)manage_options
POST /mmwp/v1/terms/refreshmanage_options
GET /mmwp/v1/terms/acceptances?user_id=&version_id=&date_from=&date_to=&limit=&offset=plugin managers

The acceptance listing is read-only and redacted: no IP address, User-Agent or e-mail. The only unauthenticated routes are read-only version routes under /mmwp/v1/public/ (redacted, no acceptance records); disable them with mmwp_terms_public_api_enabled.

WP-CLI

wp mmwp terms get
wp mmwp terms list --limit=10 --format=json
wp mmwp terms show 3
wp mmwp terms publish --content="$(cat terms.html)" --version-number=2.1 --effective-date=2026-10-01 --change-summary="Clarified section 4." --only-if-changed
wp mmwp terms refresh
wp mmwp terms acceptances --version-id=3 --format=csv
wp mmwp terms purge --dry-run        # report evidence; add --force to delete it permanently

Purge is CLI-only: deleting evidence is deliberately not offered over REST or abilities.

Abilities API (WordPress 6.9+)

Category mmwp-acceptance-logs: get-active-version, list-versions, get-version, publish-version, refresh-content and the read-only list-acceptances. Each carries its field schema and read/write annotations and enforces the same capability as REST.

PHP

mmwp_terms_*() helpers in src/api-functions.php and Api\TermsApi are the supported programmatic entry points. See programmatic-api.md.

Block

Insert Terms & Conditions (mmwp/terms-display) on a public page to show the current version, its effective date and history.

Source: usage.md(opens in a new tab)

Templates

These documents in the repository are templates with fields to fill in, so they are linked rather than copied here.