Skip to content

feat: drafts-only write policy (#168) - #186

Open
ifahimreza wants to merge 7 commits into
mainfrom
feat/168-drafts-only-policy
Open

feat: drafts-only write policy (#168)#186
ifahimreza wants to merge 7 commits into
mainfrom
feat/168-drafts-only-policy

Conversation

@ifahimreza

Copy link
Copy Markdown
Contributor

Closes #168

What

A Saddle → Permissions setting, off by default: agent writes always land as a draft; publishing asks. Creating a new post/page with status=publish lands as a draft instead, and the response says so. Flipping an existing post/page to publish goes through the same preview → confirm-token approval gate delete-post already uses. Enforced once, in the authorize_write() path — not duplicated per ability. The system context names the policy when it's on.

Why

The single most repeated piece of community advice for agent write access is "have it write drafts only and publish yourself" — everyone does it by hand with a dedicated Editor account, nobody ships it as a checkbox. This answers the #1 fear directly: write access on a live site. Week 2, pillar 2 of the post-reset ROADMAP.md (#166).

How

  • Create (create-post/create-page): no pre-existing object to protect, so status=publish is silently rewritten to draft before insert. The response gains a drafts_only_override note (mirrors the existing meta_denied flag's shape).
  • Update (update-post/update-page): flipping an existing item to publish is the actually risky transition, so it's gated instead of downgraded — first call returns a preview + confirm_token (no mutation), the same call with the token performs the update, including the publish. The token's bind folds in a hash of the rest of the payload, so a preview shown for one edit can't be replayed to publish a different one. Not triggered when the item is already published (nothing to "flip"). update_of_type()'s mutation logic moved into execute_update() so the gate's execute closure and the direct path share it, with a $log flag so a confirmed publish isn't logged twice (the gate already logs it).
  • set-blocks is genuinely out of scope, not an oversight — it has no status/post_status field at all; only post_content. Adding one would be new scope beyond this ticket.
  • New Saddle_Capabilities::DRAFTS_ONLY_OPTION (is_drafts_only()/set_drafts_only()), read fresh every call — no caching. Round-tripped through POST/GET saddle/v1/preferences (drafts_only) the same way domain_enforced already is. A Switch on the Permissions screen (mirrors Settings.jsx's pause/OAuth toggles — saves on toggle, not part of the level/tools Apply bar).

Testing

  • composer test — 672/672 green. composer lint — 0 errors (2 pre-existing warnings, unrelated files).
  • Verified in a real install (localhost:8882): create-post/page downgrade to draft with the override note; update-post preview → confirm → actual publish; already-published resave not gated; a stale token rejected against a changed payload; system context bullet present only when on; subscriber still denied outright (tier gate runs first); Permissions toggle saves and persists through the real REST route, confirmed via wp option get/eval. Fixtures and log rows cleaned up afterward.
  • No notices/warnings with WP_DEBUGcomposer lint/test runs were clean.

🤖 Generated with Claude Code

DRAFTS_ONLY_OPTION plus is_drafts_only()/set_drafts_only(), matching the
existing ENFORCE_DOMAIN_OPTION pattern. Off by default, read fresh via
get_option() with no caching.

Refs #168
Create: an explicit status=publish on a new post/page is rewritten to
draft before insert when the policy is on (apply_drafts_only_to_create).
Nothing public exists yet, so this is a silent downgrade — the response
gains a drafts_only_override note.

Update: flipping an EXISTING post/page to publish instead goes through
Saddle_Approval::gate(), the same preview -> confirm_token handshake
delete_of_type() already uses (guard_publish_transition). The token is
bound to a hash of the rest of the payload so a preview for one edit
can't be replayed to publish a different one. update_of_type()'s actual
mutation is split into execute_update() so both the direct path and the
gate's execute closure share it, with a $log flag to avoid double-logging
a confirmed publish (the gate already logs it).

Not triggered when the item is already published (no transition), or
set-blocks (it never sets post_status — nothing to intercept there).

Refs #168
Same on/off pattern as domain_enforced: declared as a boolean route arg,
read via array_key_exists (presence, not truthiness) in update_settings(),
and reported back in get_settings().

Refs #168
Mirrors Settings.jsx's pause/OAuth Switch pattern (save-on-toggle, not
part of the level/tools ApplyBar): a Card between the access-level radio
and the under-levelled-apps notice, backed by GET/POST /preferences'
new drafts_only field.

Refs #168
New tests/drafts-only-test.php drives the real wp_get_ability()->execute()
path (same style as abilities-test.php): create-post/page with
status=publish lands as draft when the policy is on and is unaffected
when off or when status is omitted; update-post/page to publish previews
without mutating anything, a confirmed token actually publishes, an
already-published post isn't gated (nothing to flip), and a token can't
be replayed against a different payload.

Plus the on/off context-bullet pair in context-test.php and two direct
getter/setter tests plus tear_down cleanup in capabilities-test.php.

composer test: 672/672 green. composer lint: 0 errors (pre-existing
warnings only, unrelated files).

Refs #168
@ifahimreza ifahimreza linked an issue Sep 7, 2026 that may be closed by this pull request
5 tasks
@ifahimreza
ifahimreza marked this pull request as ready for review September 7, 2026 20:57
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.

Drafts-only policy: agent writes always land as draft, publishing asks

1 participant