stage_id = X; $task->save();) bypass * Fluent's lifecycle behavior. Specifically, they do NOT fire: * - do_action('fluent_boards/task_stage_updated', $task, $oldStageId) * which feeds: * - ActivityHandler@logTaskStageUpdatedActivity (the audit log) * - NotificationHandler@changeStageNotification (emails) * - StageChangedTrigger (FluentCRM automations) * They also leave 'status' and 'last_completed_at' stale relative to the * stage's default_task_status setting. * * The default path (move_task) keeps the system in sync. The gated path * (move_task_silent) preserves the same state correctness but suppresses the * action — for context repair / migration / latent-checks that should not * generate notifications. Both paths use Fluent's own Task::close() and * Task::reopen() primitives so the canonical timestamp behavior is honored. * * Direct ORM ($task->save() with no helpers) remains available as an * implicit escape hatch for ad-hoc surgery; nothing in this class blocks it. * The choice is at the call site. * * Architectural rule recorded: * - Protocol comment is canonical for lifecycle (Proposed/Approved/ * In Progress/Blocked/Complete/Accepted). * - Fluent stage_id + status are projections of that lifecycle. * - This wrapper keeps the projection true. */ class SA_Orch_Fluent_State { /** * Move a task to a new stage and fire Fluent's lifecycle behavior. * * Behavior: * - reads old stage_id * - sets new stage_id, saves * - aligns status + last_completed_at via Task::close() / Task::reopen() * based on the new stage's default_task_status setting * - fires do_action('fluent_boards/task_stage_updated', $task, $oldStageId) * * Idempotent: if old == new stage_id, no-op returns true. * Returns false if task or stage not found. */ public static function move_task( int $task_id, int $new_stage_id, array $opts = [] ): bool { return self::do_move( $task_id, $new_stage_id, true /* fire events */ ); } /** * Same as move_task() but does NOT fire 'fluent_boards/task_stage_updated'. * * Use cases (developer choice — explicitly opt-in): * - Backfilling drifted state without spamming notifications * - Repair operations during migration * - Latent-check passes outside the project board concept * * State correctness (status, last_completed_at) is still maintained. * Only the side-channel notifications/automations are suppressed. */ public static function move_task_silent( int $task_id, int $new_stage_id, array $opts = [] ): bool { return self::do_move( $task_id, $new_stage_id, false /* suppress events */ ); } /** * Internal mover. Single code path; the only difference between move_task * and move_task_silent is whether the action fires at the end. * * When $fire_events = true (default move_task path), the Description * Contract gates from Board #27 also apply: * - Pre-Work gate: blocks Open → In Progress when description is empty * - Completion gate: blocks In Progress → Complete when description * lacks acceptance language ('accept', 'criteria', 'done when', * 'pass when' — case-insensitive substring; deterministic, no NLP). * The silent variant explicitly bypasses both gates — it's the gated * developer-choice path for context repair / migration. */ private static function do_move( int $task_id, int $new_stage_id, bool $fire_events ): bool { $task = \FluentBoards\App\Models\Task::find( $task_id ); if ( ! $task ) { return false; } $old_stage_id = (int) $task->stage_id; if ( $old_stage_id === $new_stage_id ) { // Idempotent no-op. Stage is already where requested. return true; } $stage = \FluentBoards\App\Models\Stage::find( $new_stage_id ); if ( ! $stage ) { return false; } // Board #27 — Description Contract gates. Apply only on default path. if ( $fire_events && ! self::description_contract_passes( $task, $new_stage_id ) ) { return false; } // Move the stage first so subsequent close()/reopen() observers see // the new stage as the source of truth. $task->stage_id = $new_stage_id; $task->save(); // Align status + last_completed_at to the new stage's contract. // Task::close() and Task::reopen() are Fluent's own primitives and // handle the timestamp invariants correctly. $default_status = $stage->defaultTaskStatus(); if ( $default_status === 'closed' && $task->status !== 'closed' ) { $task->close(); } elseif ( $default_status === 'open' && $task->status === 'closed' ) { $task->reopen(); } if ( $fire_events ) { do_action( 'fluent_boards/task_stage_updated', $task, $old_stage_id ); } return true; } /** * Board #27 — Description Contract gates. * * Returns true if the transition is allowed. Returns false (with * error_log entry explaining why) if the gate blocks. * * Stage IDs (board 16): * 131 = Open / Approved / [QUEUE] * 132 = In Progress * 133 = Completed * * Gate logic: * - Moving INTO 132 (In Progress) → Pre-Work gate: description must be non-empty * - Moving INTO 133 (Completed) → Completion gate: description must contain * one of 'accept', 'criteria', 'done when', * 'pass when' (case-insensitive substring) * - Other stage moves → no gate * * Both gates are bypassed by move_task_silent() (the gated escape hatch from #19). */ private static function description_contract_passes( $task, int $new_stage_id ): bool { $description = isset( $task->description ) ? trim( (string) $task->description ) : ''; if ( $new_stage_id === 132 ) { if ( $description === '' ) { error_log( "[SA-Orch #27] move_task blocked: task #{$task->id} has empty description; cannot move to In Progress. Use move_task_silent() to bypass for repair/migration." ); return false; } return true; } if ( $new_stage_id === 133 ) { if ( $description === '' ) { error_log( "[SA-Orch #27] move_task blocked: task #{$task->id} has empty description; cannot move to Complete. Use move_task_silent() to bypass." ); return false; } $lower = strtolower( $description ); $patterns = [ 'accept', 'criteria', 'done when', 'pass when' ]; $hit = false; foreach ( $patterns as $p ) { if ( strpos( $lower, $p ) !== false ) { $hit = true; break; } } if ( ! $hit ) { error_log( "[SA-Orch #27] move_task blocked: task #{$task->id} description lacks acceptance language (one of: 'accept', 'criteria', 'done when', 'pass when'); cannot move to Complete. Use move_task_silent() to bypass." ); return false; } return true; } // Other stage moves: no gate. return true; } }