Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions STATUS.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
## Last session
2026-09-08 (**week 2 starts: the drafts-only write policy**) — Issue [#168](https://github.com/plugpressco/saddle/issues/168); PR [#186](https://github.com/plugpressco/saddle/pull/186) — **open, ready for review, awaiting Fahim's merge** (this session's permission classifier still blocks `gh pr merge`). **672 tests** (was 658), 0 lint errors, CI green on all seven jobs.

**The issue's own checklist named a trap that wasn't real.** It listed `set-blocks` alongside create/update-post/page as a place a `status=publish` request should land as draft. Read `blocks.php` before writing anything: set-blocks has no `status`/`post_status` field at all — it only ever touches `post_content`. Said so plainly in the issue-closing comment rather than quietly reinterpreting the ticket or bolting on a new `status` param nobody asked for.

**Create and update needed different treatment, not the same downgrade.** A new post/page has nothing public to protect, so `status=publish` is rewritten to `draft` before insert and the response says so (`drafts_only_override`). An *existing* post/page being flipped to publish is the actually risky transition, so instead of a silent downgrade it goes through the same preview → `confirm_token` gate `delete-post` already uses — `Saddle_Approval::gate()`, not a new mechanism. The token's `bind` folds in a hash of the rest of the payload (title, content, whatever else is changing at the same time), so a preview shown for one edit can't be replayed to publish a different edit that happened to reuse the id. Not triggered when the item is already published — resaving `status=publish` on something already live isn't "flipping" anything. `update_of_type()`'s mutation logic moved into a shared `execute_update()` so the gate's `execute` closure and the direct path use the same code, with a `$log` flag so a confirmed publish isn't logged twice (the gate logs the confirmed action itself).

**Caught myself mid-session running `git checkout main -- .` on the feature branch** while trying to diff a test-count baseline — it overwrote every tracked file's working-tree content with `main`'s version (commits on the branch were untouched, only the working tree). Noticed immediately from the changed-file list, fixed with `git checkout HEAD -- .`, then reran the full suite (672/672 green) before touching anything else, to actually verify nothing was lost rather than assume the fix worked. Worth naming here because it's exactly the kind of thing that looks fine at a glance and isn't: check `git status` after any `git checkout <ref> -- <path>` on a branch you're actively working on.

**Verified live on localhost:8882**, not only in the suite: as admin, create-post/page with `status=publish` landed as draft with the override note both times; an update-post preview left the DB untouched and a confirmed one actually published; a stale confirm_token against a changed payload was correctly rejected; the system-context bullet appeared only with the setting on (a first pass falsely "failed" this from a leftover `Saddle_Log` row whose own summary text happened to contain the words "drafts-only" — not the feature, cleaned up and reran to confirm); a throwaway subscriber was still denied outright, since the tier gate runs before drafts-only ever does. The Permissions → Drafts-only Switch was clicked in the real dashboard, saved instantly, and confirmed via `wp option get` both ways. All fixtures, log rows, and the throwaway subscriber were deleted afterward.

**Next up:** Fahim merges #186 (and the still-open #164/#165/#167 from last session, if not already done). Week 2 continues with the next pillar-2 item on the board — bulk-update-posts (#169) is next in line by issue number, followed by upload-media inline content (#170) and verify-page's logged-out fetch (#171).

## Previous session
2026-09-07 (**roadmap reset, the CI red fixed at its root, `offline_access` advertised, backlog triaged**) — Issues [#145](https://github.com/plugpressco/saddle/issues/145), [#159](https://github.com/plugpressco/saddle/issues/159), [#166](https://github.com/plugpressco/saddle/issues/166); PRs [#164](https://github.com/plugpressco/saddle/pull/164), [#165](https://github.com/plugpressco/saddle/pull/165), [#167](https://github.com/plugpressco/saddle/pull/167) — **all three open, awaiting Fahim's merge** (the session's permission classifier blocks `gh pr merge`). **657 tests** (was 653), 0 lint errors.

**Why a roadmap reset, in one line each.** Elegant Themes shipped Divi AI Agents on 2026-09-04, built into Divi 5, official MCP "on the way" — so "AI builds Divi pages" no longer sells Pro by itself. Two Reddit threads and 13 competitor pages said the community wants (1) not to fear write access on production, (2) bulk content/meta ops, (3) verification from outside the tool that wrote; nobody sells (1) as the headline, and Respira sells the safety story for €9/mo through their own server. The new `ROADMAP.md` is the answer: one positioning sentence, four pillars in order, an explicit NO list, and constraints that do not move. The 4-week plan behind it lives in Fahim's plan file, measured in Claude Code sessions (one session = one merged PR with tests), six a week.
Expand Down
2 changes: 1 addition & 1 deletion admin/build/index.asset.php
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<?php return array('dependencies' => array('react', 'react-dom', 'wp-api-fetch', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => '768b5d174835e661c422');
<?php return array('dependencies' => array('react', 'react-dom', 'wp-api-fetch', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => '508f29ed2068eb96517a');
12 changes: 6 additions & 6 deletions admin/build/index.js

Large diffs are not rendered by default.

64 changes: 64 additions & 0 deletions admin/src/components/Permissions.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@
*/
import { useState, useMemo, useEffect } from '@wordpress/element';
import {
Card,
CardHeader,
CardContent,
CardRadioGroup,
Collapsible,
ApplyBar,
Notice,
Switch,
toast,
PageHeader,
Tooltip,
Expand Down Expand Up @@ -80,6 +84,28 @@ export default function Permissions( {
.catch( () => setUnderLevelled( [] ) );
}, [ savedTier ] );

// Drafts-only: saves immediately on toggle, like Settings' pause/OAuth
// switches — it isn't part of the level/tools ApplyBar below.
const [ draftsOnly, setDraftsOnly ] = useState( false );
const [ savingDraftsOnly, setSavingDraftsOnly ] = useState( false );

useEffect( () => {
api( 'preferences' )
.then( ( res ) => setDraftsOnly( !! res.drafts_only ) )
.catch( () => setDraftsOnly( false ) );
}, [] );

const toggleDraftsOnly = () => {
const next = ! draftsOnly;
setSavingDraftsOnly( true );
api( 'preferences', { method: 'POST', data: { drafts_only: next } } )
.then( ( res ) => setDraftsOnly( !! res.drafts_only ) )
.catch( () =>
toast.error( __( 'Could not save that setting.', 'saddle' ) )
)
.finally( () => setSavingDraftsOnly( false ) );
};

// Free text filter across a tool's name/id/description. Empty matches all.
const q = query.trim().toLowerCase();
const matchesQuery = ( c ) =>
Expand Down Expand Up @@ -231,6 +257,44 @@ export default function Permissions( {
} ) ) }
/>

<Card>
<CardHeader
title={ __( 'Drafts-only', 'saddle' ) }
description={ __(
'Agent writes always land as a draft. Publishing something brand new lands as a draft too, and publishing an existing post or page asks you to confirm first — the same way a deletion does.',
'saddle'
) }
/>
<CardContent>
<label
className="saddle-toggle-row"
htmlFor="saddle-drafts-only-switch"
>
<Switch
id="saddle-drafts-only-switch"
checked={ draftsOnly }
disabled={ savingDraftsOnly }
onChange={ toggleDraftsOnly }
aria-label={ __(
'Agent writes always land as draft; publishing asks',
'saddle'
) }
/>
<span>
{ draftsOnly
? __(
'On — every write lands as a draft; publishing an existing item asks first.',
'saddle'
)
: __(
'Off — the level above already decides who can publish.',
'saddle'
) }
</span>
</label>
</CardContent>
</Card>

{ underLevelled.length > 0 && (
<Notice tone="warning">
{ sprintf(
Expand Down
133 changes: 123 additions & 10 deletions includes/abilities/core-content.php
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,84 @@ private static function authorize_write( $type, array $input, $current_owner, $i
return true;
}

/**
* Drafts-only policy for a brand-new post/page (see
* Saddle_Capabilities::DRAFTS_ONLY_OPTION). There is no existing object to
* protect on create, so an explicit status=publish is rewritten to draft
* before insert rather than gated — nothing public exists yet either way.
*
* @param array $input Create input, mutated in place.
* @return bool Whether the requested status was overridden.
*/
private static function apply_drafts_only_to_create( array &$input ) {
if ( ! Saddle_Capabilities::is_drafts_only() ) {
return false;
}
if ( ! isset( $input['status'] ) || 'publish' !== sanitize_key( (string) $input['status'] ) ) {
return false;
}
$input['status'] = 'draft';
return true;
}

/**
* Drafts-only policy for an update that would flip an EXISTING post/page
* to publish: unlike create, something real is already sitting at its
* current status, so this is gated the same way delete_of_type() gates a
* deletion — a preview + confirm_token round trip — rather than silently
* downgraded. Not triggered when the item is already published, since
* nothing is being "flipped".
*
* @param string $type 'post'|'page'.
* @param int $id Post id being updated.
* @param WP_Post $existing The post before this update.
* @param array $input Update input (read for status + confirm_token).
* @return array|WP_Error|null Gate result to return immediately, or null
* when the update should proceed unguarded.
*/
private static function guard_publish_transition( $type, $id, $existing, array $input ) {
if ( ! Saddle_Capabilities::is_drafts_only() ) {
return null;
}
if ( ! isset( $input['status'] ) || 'publish' !== sanitize_key( (string) $input['status'] ) ) {
return null;
}
if ( 'publish' === $existing->post_status ) {
return null;
}

// Fold the rest of the payload into the token identity: a preview
// shown for one edit must not be confirmable into publishing a
// different one (same principle as delete's trash-vs-permanent bind).
$bind = md5( (string) wp_json_encode( array_diff_key( $input, array( 'confirm_token' => true ) ) ) );

return Saddle_Approval::gate(
array(
'action' => 'publish_' . $type,
'target' => (string) $id,
'bind' => $bind,
'summary' => sprintf(
/* translators: 1: type, 2: id, 3: title. */
__( 'Publish %1$s #%2$d "%3$s". This site is set to drafts-only, so publishing an existing item asks for confirmation first.', 'saddle' ),
$type,
$id,
$existing->post_title
),
'preview' => array(
'id' => $id,
'type' => $type,
'title' => $existing->post_title,
'current_status' => $existing->post_status,
'new_status' => 'publish',
),
'input' => $input,
'execute' => function () use ( $type, $id, $input ) {
return self::execute_update( $type, $id, $input, false );
},
)
);
}

/**
* SSRF guard for upload_media: reject a source URL that resolves to a
* private or reserved IP range.
Expand Down Expand Up @@ -1788,6 +1866,8 @@ private static function create_of_type( $type, array $input ) {
return $denied;
}

$drafts_only_override = self::apply_drafts_only_to_create( $input );

$postarr = self::build_postarr( $type, $input );
$postarr['post_type'] = $type;
if ( empty( $postarr['post_status'] ) ) {
Expand Down Expand Up @@ -1818,6 +1898,9 @@ private static function create_of_type( $type, array $input ) {
if ( ! empty( $meta_denied ) ) {
$detail['meta_denied'] = $meta_denied;
}
if ( $drafts_only_override ) {
$detail['drafts_only_override'] = __( 'This site is set to drafts-only: the requested "publish" status was not applied, and the item was saved as a draft instead.', 'saddle' );
}
return $detail;
}

Expand Down Expand Up @@ -2003,6 +2086,34 @@ private static function update_of_type( $type, array $input ) {
}
}

// Drafts-only policy: flipping an EXISTING item to publish is gated
// (preview + confirm_token) rather than executed immediately. Returns
// null when the policy is off, the status isn't changing to publish,
// or the item is already published.
$gated = self::guard_publish_transition( $type, $id, $existing, $input );
if ( null !== $gated ) {
return $gated;
}

return self::execute_update( $type, $id, $input );
}

/**
* Perform the actual post/page mutation: build_postarr(), wp_update_post(),
* terms/meta, log, and the response detail. Split out of update_of_type()
* so the drafts-only approval gate's `execute` closure can call the same
* path a direct (ungated) update uses.
*
* @param string $type 'post'|'page'.
* @param int $id Post id being updated.
* @param array $input Writable fields.
* @param bool $log Whether to record this action in Saddle_Log. False
* when called from inside Saddle_Approval::gate(),
* which already logs the confirmed action itself —
* logging here too would double the entry.
* @return array|WP_Error
*/
private static function execute_update( $type, $id, array $input, $log = true ) {
$postarr = self::build_postarr( $type, $input );
$postarr['ID'] = $id;

Expand All @@ -2014,17 +2125,19 @@ private static function update_of_type( $type, array $input ) {
$meta_denied = self::apply_terms_and_meta( $type, $id, $input );

$post = get_post( $id );
Saddle_Log::record_action(
'update-' . $type,
$id,
sprintf(
/* translators: 1: post type, 2: id, 3: title. */
__( 'Updated %1$s #%2$d "%3$s"', 'saddle' ),
$type,
if ( $log ) {
Saddle_Log::record_action(
'update-' . $type,
$id,
$post->post_title
)
);
sprintf(
/* translators: 1: post type, 2: id, 3: title. */
__( 'Updated %1$s #%2$d "%3$s"', 'saddle' ),
$type,
$id,
$post->post_title
)
);
}

$detail = self::post_detail( $post );
if ( ! empty( $meta_denied ) ) {
Expand Down
17 changes: 13 additions & 4 deletions includes/admin/class-saddle-rest.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,24 +49,28 @@ public static function register_routes() {
'callback' => array( __CLASS__, 'update_settings' ),
'permission_callback' => array( __CLASS__, 'can_manage' ),
'args' => array(
'tier' => array(
'tier' => array(
'type' => 'string',
'required' => false,
'enum' => Saddle_Capabilities::tiers(),
),
'onboarded' => array(
'onboarded' => array(
'type' => 'boolean',
'required' => false,
),
'paused' => array(
'paused' => array(
'type' => 'boolean',
'required' => false,
),
'theme' => array(
'theme' => array(
'type' => 'string',
'required' => false,
'enum' => array( 'system', 'light', 'dark' ),
),
'drafts_only' => array(
'type' => 'boolean',
'required' => false,
),
),
),
);
Expand Down Expand Up @@ -602,6 +606,7 @@ public static function get_settings() {
'recorded' => Saddle_Capabilities::recorded_tier_domain(),
'enforced' => Saddle_Capabilities::is_domain_enforced(),
),
'drafts_only' => Saddle_Capabilities::is_drafts_only(),
// The key itself never leaves the server — only whether one is
// set, plus a last-4 hint so the owner can recognize it.
'unsplash' => array(
Expand Down Expand Up @@ -664,6 +669,10 @@ public static function update_settings( WP_REST_Request $request ) {
Saddle_Capabilities::set_domain_enforcement( (bool) $request->get_param( 'domain_enforced' ) );
}

if ( array_key_exists( 'drafts_only', $params ) ) {
Saddle_Capabilities::set_drafts_only( (bool) $request->get_param( 'drafts_only' ) );
}

// Key absent from the body ⇒ untouched; '' or null ⇒ cleared;
// non-empty ⇒ validated and saved.
if ( array_key_exists( 'unsplash_access_key', $params ) ) {
Expand Down
29 changes: 29 additions & 0 deletions includes/class-saddle-capabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ class Saddle_Capabilities {
*/
const ENFORCE_DOMAIN_OPTION = 'saddle_enforce_tier_domain';

/**
* Option key for the drafts-only write policy. Off by default. When on,
* a create request asking for status=publish lands as draft instead, and
* an update that would flip an existing post/page to publish goes
* through the approval gate rather than executing immediately. See
* Saddle_Abilities::authorize_write().
*/
const DRAFTS_ONLY_OPTION = 'saddle_drafts_only';

/**
* Tier name => numeric rank. Higher rank = more power.
*
Expand Down Expand Up @@ -610,6 +619,26 @@ public static function set_domain_enforcement( $enforce ) {
return update_option( self::ENFORCE_DOMAIN_OPTION, (bool) $enforce );
}

/**
* Whether the drafts-only write policy is on (see DRAFTS_ONLY_OPTION).
* Read fresh every call — never cached across requests.
*
* @return bool
*/
public static function is_drafts_only() {
return (bool) get_option( self::DRAFTS_ONLY_OPTION, false );
}

/**
* Turn the drafts-only write policy on or off.
*
* @param bool $on Whether publish requests should be downgraded/gated.
* @return bool
*/
public static function set_drafts_only( $on ) {
return update_option( self::DRAFTS_ONLY_OPTION, (bool) $on );
}

/**
* The site's current hostname, for comparison against the recorded one.
*
Expand Down
4 changes: 4 additions & 0 deletions includes/class-saddle-context.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ public static function system_context() {
$lines[] = '- ' . __( 'Saddle exposes core content only: posts, pages, media, and their block structure.', 'saddle' );
$lines[] = '- ' . __( 'Stay within the tools Saddle provides. Do not attempt actions outside this scope.', 'saddle' );

if ( 'read' !== $tier && Saddle_Capabilities::is_drafts_only() ) {
$lines[] = '- ' . __( 'This site is set to drafts-only: publishing a new post or page lands as a draft instead, and publishing an existing one goes through the same preview-and-confirm step as a deletion.', 'saddle' );
}

foreach ( self::withheld_tools_lines() as $line ) {
$lines[] = $line;
}
Expand Down
Loading
Loading