Skip to content

feat(snapshots): post-meta snapshot + restore kind (P0.2 primitive)#47

Merged
BenKalsky merged 4 commits into
mainfrom
feat/snapshot-meta
Jul 7, 2026
Merged

feat(snapshots): post-meta snapshot + restore kind (P0.2 primitive)#47
BenKalsky merged 4 commits into
mainfrom
feat/snapshot-meta

Conversation

@BenKalsky

Copy link
Copy Markdown
Member

What

Adds post-meta capture/restore to Aura_Worker_Snapshots:

  • snapshot_meta( int $post_id, string|array $keys ) → new meta snapshot kind
  • restore() gains a meta case

Why

Enabling primitive for P0.2 — bringing Elementor page/kit writes under SiteAgent governance. Elementor stores everything as single-valued post-meta (_elementor_data, _elementor_page_settings, kit-scoped globals repositories). The existing snapshot_post captures post_content only, so it cannot roll back an Elementor edit. This closes that gap so the fork's mutating tools can snapshot-before-write.

Behavior

  • Each key captured with an existence flag — restore re-deletes a key that was absent at capture time rather than resurrecting an empty value (mirrors the option kind's existed handling).
  • Values re-slashed on restore (get_post_meta returns unslashed; meta writers expect slashed) so the value round-trips byte-exact.
  • Fails closed if payload missing/corrupt.
  • Targets single-valued meta keys — the shape every Elementor storage key uses.

Tests

6 new SnapshotsTest cases (roundtrip, absent→absent, multi-key, invalid post, empty keys). Bootstrap gains metadata_exists / delete_post_meta / wp_slash stubs. Full suite green: 491 tests.

🤖 Generated with Claude Code

Adds `Aura_Worker_Snapshots::snapshot_meta( $post_id, $keys )` and a `meta`
restore kind. This is the enabling primitive for bringing Elementor page/kit
writes under SiteAgent governance (P0.2): `_elementor_data`,
`_elementor_page_settings`, and the kit-scoped globals repositories all persist
as single-valued post-meta, which `snapshot_post` (post_content only) could not
capture.

Each requested key is captured with an existence flag so restore re-deletes a
key that was absent at capture time rather than resurrecting an empty value;
values are re-slashed on restore so they round-trip exactly (get_post_meta
returns unslashed, meta writers expect slashed input). Targets single-valued
meta keys — the shape every Elementor storage key uses.

Test bootstrap gains metadata_exists / delete_post_meta / wp_slash stubs.
Full suite green (491), 6 new SnapshotsTest cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@BenKalsky

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 567875142d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}
// Meta writers expect slashed input (WP unslashes before
// storing); get_post_meta returned unslashed, so re-slash.
update_post_meta( $post_id, $key, wp_slash( $info['value'] ) );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return an error when meta restoration fails

When restoring an existing meta key, this ignores update_post_meta()'s result and the surrounding restore() path still returns success: true; in the inspected REST restore flow that becomes a 200 response. If the metadata write fails (for example a database error or a pre_update_post_metadata filter veto) the Elementor value remains clobbered while governance records a successful rollback, so please verify the write or compare the stored value after a false return (since WordPress also returns false when unchanged) before reporting success.

Useful? React with 👍 / 👎.

…ound-1 P2)

The `meta` restore case ignored `update_post_meta()`'s result, so a failed
rollback write (DB error, filter veto) still returned success:true — governance
would record a restore that never happened while the value stays clobbered.

update_post_meta returns false both on real failure AND on unchanged-value
(no-op), so disambiguate by reading the value back: only a mismatch is a failed
restore. Two regression tests cover both branches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@BenKalsky

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@BenKalsky

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f3cb1ad5cd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if ( empty( $info['existed'] ) ) {
// The key was absent when captured — remove it, don't
// resurrect an empty value.
delete_post_meta( $post_id, $key );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail closed when absent meta deletion fails

When restoring a snapshot for a meta key that was absent at capture time but was added later, this path ignores delete_post_meta()'s result and immediately reports success. If the delete is vetoed by a metadata filter or fails at the database layer, the rollback leaves the newly-added Elementor meta in place while governance records a successful restore; the update branch below already does a read-back check, but this absent-key branch needs the same fail-closed treatment.

Useful? React with 👍 / 👎.

…round-2 P2)

The absent-at-capture restore branch deleted the meta key but ignored the
result, so a vetoed/failed delete left the newly-added Elementor meta in place
while governance recorded a successful rollback. Mirror the update branch's
read-back: delete_post_meta returns false on both failure and nothing-to-delete,
so verify via metadata_exists — a key still present means the delete failed.

Regression test forces a delete veto via a new delete_post_meta_return stub hook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@BenKalsky

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cba3521e5b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +218 to +220
if ( ! get_post( $post_id ) ) {
return array( 'success' => false, 'error' => 'Post not found: ' . $post_id );
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject revision IDs before meta snapshots

When callers pass a revision or autosave ID, this check succeeds because revisions are posts, but the snapshot is captured with metadata_exists()/get_post_meta() against the revision ID while restore later uses update_post_meta()/delete_post_meta(), which WordPress redirects to the parent post. In that scenario an absent revision key can delete the parent's Elementor meta, or revision meta can overwrite the parent, while restore reports success; normalize with wp_is_post_revision() or reject revisions before capturing.

Useful? React with 👍 / 👎.

if ( ! is_array( $captured ) ) {
return array( 'success' => false, 'error' => 'Snapshot payload corrupt.' );
}
$post_id = (int) $record['target'];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail restores when the target post is gone

If the page/kit is deleted after the snapshot is taken, restore still trusts the saved ID and proceeds to update_post_meta(), which can add orphaned wp_postmeta rows for a non-existent object and return success even though the Elementor page was not restored. Re-check get_post( $post_id ) before the meta restore loop and fail closed when the target no longer exists.

Useful? React with 👍 / 👎.

…(Codex round-3 P2 x2)

Two fail-closed hardenings for snapshot_meta / meta restore:

- Reject revision/autosave post ids at snapshot time (wp_is_post_revision).
  get_post_meta reads a revision's own meta, but update/delete_post_meta can
  affect the parent, so a snapshot taken against a revision could clobber or
  wipe the parent page's Elementor data. Elementor data lives on the parent.

- Re-check get_post() before the meta restore loop. If the page/kit was deleted
  after capture, writing meta would create orphaned wp_postmeta rows and falsely
  report success.

Adds wp_is_post_revision stub; two regression tests. Full suite green (496).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@BenKalsky

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: 1c51c32521

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@BenKalsky BenKalsky merged commit 3a6d0fe into main Jul 7, 2026
5 checks passed
@BenKalsky BenKalsky deleted the feat/snapshot-meta branch July 7, 2026 11:25
BenKalsky added a commit to Digitizers/elementor-mcp that referenced this pull request Jul 7, 2026
…ve page edits (v1.17.0) (#15)

* feat(governance): SiteAgent capture-before-write bridge for destructive page edits (v1.17.0)

Adds Elementor_MCP_Governance — when SiteAgent's snapshot engine
(\Aura_Worker_Snapshots) is installed alongside the plugin, every destructive
ability that targets a page (any tool carrying a post_id) is transparently
wrapped in elementor_mcp_register_ability(): the page's Elementor state
(_elementor_data + _elementor_page_settings) is snapshotted BEFORE the write and
rolled back if the write returns WP_Error or throws.

- Soft dependency: is_active() is class_exists(\Aura_Worker_Snapshots); with no
  SiteAgent present nothing is wrapped and behaviour is unchanged.
- Fail closed: if the pre-write snapshot can't be captured, the write is refused
  rather than made blind.
- Governance seams: elementor_mcp_governance_write / _rolled_back actions expose
  the rollback point to the gateway. No mutation of tool return payloads.
- Scope (this plank): page-targeting writes only. Kit/repository writes (no
  post_id) pass through; approval grants + post-write render checks are follow-ups.

Depends on SiteAgent snapshot_meta primitive (Digitizers/SiteAgent#47).
Test bootstrap gains an Aura_Worker_Snapshots stub; 8 GovernanceFunctionalTest
cases. Full suite green (641).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(governance): govern all write-capable page tools + normalize post_id (Codex round-1 P1+P2)

P1: the wrap gate keyed off destructive=true, but many page mutators
(update-element, batch-update, import-template, atomic add/update) save
_elementor_data while annotated destructive=false — they bypassed snapshot/
rollback entirely. Gate on readonly=false instead (write-capable superset); the
run-time post_id check still decides whether there's a page to snapshot.
Abilities with no annotations stay unwrapped (unclassifiable).

P2: run_governed used (int) on post_id, so a negative id failed the `<= 0` skip
yet the handlers absint() before saving — mutating abs(id) with no snapshot.
Use absint() to match the handlers and close the bypass.

Also make PAGE_META_KEYS explicitly public. +3 regression tests (non-destructive
page writer wrapped, readonly not wrapped, negative id normalized). Suite 644.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(governance): explicit page-writer allowlist + surface failed rollbacks (Codex round-2 P1+P2x2)

The readonly=false gate was too broad. Three fixes:

P1: govern only an explicit allowlist of page-structure writers (GOVERNED_TOOLS)
instead of any readonly=false + post_id tool. post_id + readonly=false also
matches tools that write OTHER state (set-template-conditions -> _elementor_conditions,
SEO meta) — a page-data snapshot would roll back the wrong keys. The allowlist is
exactly the tools that write _elementor_data / _elementor_page_settings.

P2 (preview): apply-flag a11y/SEO generators (fix-color-contrast, generate-meta-tags,
etc.) are excluded by the allowlist, so a preview call (no apply) no longer trips a
false snapshot/deny. Their governed-on-apply handling is a follow-up plank.

P2 (rollback): a failed restore() was ignored while returning only the original
write error, hiding a possible partial write. Now a failed rollback returns the
more-severe governance_rollback_failed (both the WP_Error and throw paths) and
fires elementor_mcp_governance_rollback_failed.

+5 governance tests (allowlist governs regardless of destructive flag, non-page-data
writer not governed, both rollback-failure paths). Suite 646.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(governance): snapshot at the page-write chokepoint, drop the allowlist (Codex round-3 P2)

The explicit allowlist was incomplete (missed add-widget/update-widget,
apply-template, add-stock-image, interactions, apply-global-class, add-custom-
css/js) and fragile — "writes page data" is a runtime property, not knowable
from a tool name.

New design, robust by construction: the ability wrapper arms a governed run for
the target post; the actual write site (Elementor_MCP_Data::save_page_data /
save_page_settings) calls Governance::before_page_write(), which captures the
snapshot lazily on the first real page-data write. Consequences:
- every save_page_data() caller is covered — no list to maintain;
- tools that write other state (template conditions, SEO meta) or preview-only
  calls (apply=false) never reach the write site, so are never snapshotted —
  this fixes the round-2 wrong-key and preview findings structurally;
- the tool boundary is preserved for rollback and the future grant plank.

Gate returns to readonly=false (write-capable); before_page_write fails closed if
the snapshot can't be captured. save_page_data/save_page_settings return the gate
WP_Error so the write is refused. Rollback-failure surfacing retained.

Suite 648 (15 governance tests, incl. "no page write → no snapshot" and
"snapshot captured once per run").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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.

1 participant