瀏覽代碼

C1: goal blocks ride the run task — one row, one answer path (F6)

goal_blocks is deleted (M0001 amended, nothing deployed): the goal
agent's questions, RFCs, plan submissions and completion reports are
rows of the run task's task_blocks, raised through
GoalService.raise_run_block (quiet task-side add + the goal-level
notification fired once) and answered through ONE service path,
GoalService.answer_block, that every surface funnels into: the goal
page's verdict forms, the task-page deep link (AnswerBlockAction now
delegates goal-run rows), and the completion accept. The verdict's
events record before the row settles, so an observer past the first
pulse can never read an unanswered straggler — the dual-row desync
(F6's duplicate review block and the permanently-open dashboard
card) is structurally impossible.

The dedup guards key on the single row: pending same-question
(question/RFC) or same-type (plan/completion) rows swallow
redelivered entry+state pairs, so the accept path and the goal-
failed path raise at most one review-kind block — and failure raises
none. TaskService.newest_block_answered and the allocation resume
drop the goal-row standin sweeps; the dashboard pile deletes the
goal-side loop and renders run-task blocks as goal items by kind
(goal badge, goal link), sourced from the same task loop that used
to hide them.
clanker 1 月之前
父節點
當前提交
d1c8bea7e2

+ 0 - 35
src/Data/Entities.vala

@@ -614,41 +614,6 @@ namespace ClankerSaddle.Data {
         }
     }
 
-    /**
-     * A goal-level blocking prompt (plan 12): the goal agent's
-     * question/RFC blocks, its plan submissions/revisions (type
-     * 'plan', the approved one mirrored into {@link Goal.plan}) and
-     * the completion report awaiting review (type 'completion').
-     * Answered by a human on the goal page.
-     */
-    public class GoalBlock : Object {
-
-        public int64 id { get; set; }
-        public int64 goal_id { get; set; }
-        public string question { get; set; default = ""; }
-        public string? answer { get; set; default = null; }
-        public string block_type { get; set; default = "question"; }
-        public string? options { get; set; default = null; }
-        public string? body { get; set; default = null; }
-        public DateTime? answered_at { get; set; default = null; }
-        public string? answered_by { get; set; default = null; }
-        public DateTime? created_at { get; set; default = null; }
-
-        public static void entity_mapping(EntityMapperBuilder<GoalBlock> cfg) {
-            cfg.table("goal_blocks")
-                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
-                .column<int64?>("goal_id", o => o.goal_id, (o, v) => o.goal_id = v)
-                .column<string>("question", o => o.question, (o, v) => o.question = v)
-                .column<string>("answer", o => o.answer, (o, v) => o.answer = v)
-                .column<string>("type", o => o.block_type, (o, v) => o.block_type = v)
-                .column<string>("options", o => o.options, (o, v) => o.options = v)
-                .column<string>("body", o => o.body, (o, v) => o.body = v)
-                .column<DateTime?>("answered_at", o => o.answered_at, (o, v) => o.answered_at = v)
-                .column<string>("answered_by", o => o.answered_by, (o, v) => o.answered_by = v)
-                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
-        }
-    }
-
     /**
      * One entry of a goal's supervision narrative (`goal_events`):
      * lifecycle moves, plan submissions/reviews, the goal agent's task

+ 13 - 32
src/Data/Migrations/M0001_Initial.vala

@@ -6,9 +6,10 @@ namespace ClankerSaddle.Data.Migrations {
      * The full ClankerSaddle schema in one migration (plan 11: every
      * earlier migration flattened — the database is not deployed, so
      * tear-down-and-recreate replaced incremental evolution). Covers:
-     * projects and their links; the plan-12 goals (with links,
-     * comments, typed blocks and the supervision-narrative event log)
-     * and the flattened task shape (`goal_id`, `kind`, `mode`,
+     * projects and their links; the plan-12 goals (with links and
+     * comments — plan 15 C1 deleted `goal_blocks`: the goal agent's
+     * blocks ride the run task's `task_blocks` rows) and the
+     * flattened task shape (`goal_id`, `kind`, `mode`,
      * `creator`, `enqueue_after_task_id` — the subtask machinery's
      * parent_task_id/plan/auto-* columns and the task_dependencies
      * table are gone); tasks with comments, typed blocks, progress
@@ -62,10 +63,10 @@ namespace ClankerSaddle.Data.Migrations {
             // user-named goal branch (uniqueness-validated against the
             // other live goals sharing involved repos); `plan` holds
             // the APPROVED plan markdown (rejected revisions live on
-            // as goal_blocks); `last_seen_event_id` is the goal
-            // agent's consumed-event watermark (resume coalescing);
-            // `branch_push_state` caches the daemon-reported push
-            // state of the goal branches.
+            // as the run task's answered plan blocks); `last_seen_event_id`
+            // is the goal agent's consumed-event watermark (resume
+            // coalescing); `branch_push_state` caches the
+            // daemon-reported push state of the goal branches.
             b.create_table("goals", (t) => {
                 t.column<int64?>("id")
                     .auto_increment()
@@ -209,10 +210,11 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<DateTime?>("created_at");
             });
 
-            // Plan 12: goal-level conversation and review machinery,
-            // mirroring the task-side shapes (author 'agent' or
-            // 'user:<name>'; blocks carry question/rfc bodies, plan
-            // submissions/revisions and the completion report).
+            // Plan 15 C1: the goal-side block table is GONE — the
+            // goal agent's questions, plans and completion reports
+            // ride the RUN TASK's task_blocks rows (one row per
+            // question, answered in place; no dual-row pair can fall
+            // out of sync).
             b.create_table("goal_comments", (t) => {
                 t.column<int64?>("id")
                     .auto_increment()
@@ -229,26 +231,6 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<DateTime?>("created_at");
             });
 
-            b.create_table("goal_blocks", (t) => {
-                t.column<int64?>("id")
-                    .auto_increment()
-                    .primary_key();
-                t.column<int64?>("goal_id")
-                    .not_null()
-                    .references("goals", "id")
-                    .indexed();
-                t.column<string>("question")
-                    .not_null();
-                t.column<string>("answer");
-                t.column<string>("type")
-                    .default_value<string>("question");
-                t.column<string>("options");
-                t.column<string>("body");
-                t.column<DateTime?>("answered_at");
-                t.column<string>("answered_by");
-                t.column<DateTime?>("created_at");
-            });
-
             // The supervision narrative's event log: one row per
             // notable goal occurrence (plan reviews, agent task
             // operations, task terminal events, lifecycle moves) —
@@ -530,7 +512,6 @@ namespace ClankerSaddle.Data.Migrations {
         public override void down(MigrationBuilder b) {
             b.drop_table("saddle_entry_dedup");
             b.drop_table("goal_events");
-            b.drop_table("goal_blocks");
             b.drop_table("goal_comments");
             b.drop_table("task_notify_prefs");
             b.drop_table("push_subscriptions");

+ 4 - 30
src/Services/AllocationService.vala

@@ -363,39 +363,13 @@ namespace ClankerSaddle.Services {
                 } else {
                     // Answering = allocating (plan 11): the verdict rides
                     // the resuming assignment's resume_notice, injected as
-                    // the restored conversation's opening turn. Plan 14
-                    // B3 sweep: a goal-run's MIRROR may lag its goal row
-                    // (the mid-answer window, historical one-sided pairs)
-                    // — the goal row's verdict stands in for it.
+                    // the restored conversation's opening turn. Plan 15
+                    // C1: the goal-run task's block IS the goal's block
+                    // — its own answer is the whole verdict, no mirror
+                    // sweep exists.
                     var newest = yield tasks.newest_block(task.id);
                     if (newest != null && ((!)newest).answer != null) {
                         resume_notice = TaskService.verdict_notice_for((!)newest);
-                    } else if (newest != null && task.kind == TASK_KIND_GOAL_RUN
-                            && task.goal_id > 0) {
-                        // Newest-of-question (plan revisions reuse one
-                        // question — an older verdict must not ride).
-                        GoalBlock? newest_goal = null;
-                        foreach (var goal_block in yield db.query<GoalBlock>()
-                                .where(expr("goal_id == $0",
-                                    elem<int64?>(task.goal_id)))
-                                .order_by(expr("id"))
-                                .materialise_async()) {
-                            if (goal_block.question == ((!)newest).question) {
-                                newest_goal = goal_block;
-                            }
-                        }
-                        if (newest_goal != null && ((!)newest_goal).answer != null) {
-                            // Shape the goal row's verdict like the
-                            // mirror so the stable wordings apply.
-                            var shaped = new TaskBlock() {
-                                task_id = ((!)newest).task_id,
-                                question = ((!)newest).question,
-                                answer = ((!)newest_goal).answer,
-                                block_type = ((!)newest).block_type,
-                                options = ((!)newest).options
-                            };
-                            resume_notice = TaskService.verdict_notice_for(shaped);
-                        }
                     }
                 }
                 // Plan 14 B4: persist what the resumed agent first

+ 152 - 168
src/Services/GoalService.vala

@@ -19,7 +19,11 @@ namespace ClankerSaddle.Services {
     public const string GOAL_FAILED = "failed";
     public const string GOAL_CANCELLED = "cancelled";
 
-    /** Valid {@link GoalBlock.block_type} values. */
+    /**
+     * Valid goal block types (plan 15 C1: the values of the goal-run
+     * task's `task_blocks.type` rows — question, rfc, plan and the
+     * completion review).
+     */
     public const string GOAL_BLOCK_QUESTION = "question";
     public const string GOAL_BLOCK_RFC = "rfc";
     public const string GOAL_BLOCK_PLAN = "plan";
@@ -46,14 +50,17 @@ namespace ClankerSaddle.Services {
     public const int64 GOAL_RUN_PRIORITY = 3;
 
     /**
-     * Goal storage and lifecycle (plan 12): creation with branch-name
-     * uniqueness validation, the draft → planning → plan review →
-     * active → completion review → complete/failed/cancelled flow
-     * (with paused freezing supervision in between), the goal agent's
-     * run task, plan/completion review flows with revisions, goal
-     * comments and blocks, the cancel cascade and the event-driven
-     * resume machinery (task-terminal events coalesce ~10 s into one
-     * goal-agent resume carrying the batched status report).
+     * Goal storage and lifecycle (plan 12; blocks consolidated plan
+     * 15 C1): creation with branch-name uniqueness validation, the
+     * draft → planning → plan review → active → completion review →
+     * complete/failed/cancelled flow (with paused freezing
+     * supervision in between), the goal agent's run task, plan/
+     * completion review flows with revisions, goal comments, the
+     * cancel cascade and the event-driven resume machinery (task-
+     * terminal events coalesce ~10 s into one goal-agent resume
+     * carrying the batched status report). The goal agent's BLOCKS
+     * ride the run task's own `task_blocks` rows — one row per
+     * question, answered in place through {@link answer_block}.
      */
     public class GoalService : Object {
 
@@ -349,95 +356,70 @@ namespace ClankerSaddle.Services {
         // ------------------------------------------------------------------
 
         /**
-         * The goal agent submitted (or revised) its plan: a new
-         * goal_blocks row of type 'plan' carrying the markdown, the
-         * goal moves to 'plan_review', and the goal-run task parks on
-         * a mirrored task block so the human verdict resumes it. The
-         * caller has already parked the task via the results state.
+         * The goal agent submitted (or revised) its plan: ONE
+         * review-kind block of type 'plan' on the goal-run task
+         * carries the markdown, the goal moves to 'plan_review' and
+         * the run parks on it (the verdict resume rides the answered
+         * block). The caller has already parked the task via the
+         * results state. Plan 15 C1: the block lives on the run task
+         * — there is no second row to keep in sync.
          */
-        public async GoalBlock submit_plan(int64 goal_id, string markdown) throws Error {
+        public async TaskBlock? submit_plan(int64 goal_id, string markdown) throws Error {
             var goal = yield require_goal(goal_id);
             // Idempotent on the pending submission: a redelivered push
             // (outbox retry, the entry + state pair of one `saddle
             // plan`) must not stack duplicate revisions — a NEW row
             // only appears once the previous one was answered
             // (rejected → the agent revises).
-            foreach (var existing in yield list_blocks(goal_id)) {
-                if (existing.block_type == GOAL_BLOCK_PLAN && existing.answer == null) {
-                    return existing;
-                }
+            TaskBlock? raised = yield raise_run_block(((!)goal), "Plan submitted for review",
+                GOAL_BLOCK_PLAN, null, markdown, true);
+            if (raised == null || ((!)raised).answer != null) {
+                // No pending submission: a fresh revision supersedes
+                // the answered history.
+                raised = yield raise_run_block(((!)goal), "Plan submitted for review",
+                    GOAL_BLOCK_PLAN, null, markdown, false);
+            }
+            if (raised == null) {
+                return null;
             }
-            var block = new GoalBlock() {
-                goal_id = goal_id,
-                question = "Plan submitted for review",
-                block_type = GOAL_BLOCK_PLAN,
-                body = markdown,
-                created_at = new DateTime.now_local()
-            };
-            yield db.insert_async<GoalBlock>(block);
             ((!)goal).status = GOAL_PLAN_REVIEW;
-            yield db.update_async<Goal>((!)goal);
+            yield db.update_async<Goal>(((!)goal));
             yield record_event(goal_id, "plan-submitted",
-                @"Plan revision #$(block.id) submitted for review");
-            yield mirror_block_on_run_task(((!)goal), block);
-            yield pulse.bump(0, "goal plan submitted");
-            push.notify_global(PushService.goal_block_notification(
-                ((!)goal).title, goal_id, GOAL_BLOCK_PLAN, block.question));
-            return block;
+                @"Plan revision #$(((!)raised).id) submitted for review");
+            return raised;
         }
 
         /**
-         * Records a goal-agent question/RFC block and parks the
-         * goal-run task on its mirrored task block. Idempotent on the
-         * pending question: a redelivered blocked push (outbox retry,
-         * the park-unwinding round-end push) must not stack duplicates.
+         * Records a goal-agent question/RFC as ONE block on the
+         * goal-run task and parks it there. Idempotent on the pending
+         * question: a redelivered blocked push (outbox retry, the
+         * park-unwinding round-end push) must not stack duplicates.
          */
-        public async GoalBlock raise_agent_block(int64 goal_id, string question,
+        public async TaskBlock? raise_agent_block(int64 goal_id, string question,
                 string block_type, string[]? options = null, string? body = null) throws Error {
             var goal = yield require_goal(goal_id);
-            foreach (var existing in yield list_blocks(goal_id)) {
-                if (existing.question == question && existing.answer == null) {
-                    return existing;
-                }
+            TaskBlock? raised = yield raise_run_block(((!)goal), question,
+                block_type.length > 0 ? block_type : GOAL_BLOCK_QUESTION,
+                options, body, false);
+            if (raised == null) {
+                return null;
             }
-            string options_json = null;
-            if (options != null && options.length > 0) {
-                var array = new Json.Array();
-                foreach (var option in options) {
-                    array.add_string_element(option);
-                }
-                options_json = Json.to_string(
-                    new Json.Node(Json.NodeType.ARRAY).init_array(array), false);
-            }
-            var block = new GoalBlock() {
-                goal_id = goal_id,
-                question = question,
-                block_type = block_type.length > 0 ? block_type : GOAL_BLOCK_QUESTION,
-                options = options_json,
-                body = body != null && body.length > 0 ? body : null,
-                created_at = new DateTime.now_local()
-            };
-            yield db.insert_async<GoalBlock>(block);
             yield record_event(goal_id, "block-raised",
-                @"$(block.block_type) raised: $(question)");
-            yield mirror_block_on_run_task(((!)goal), block);
-            yield pulse.bump(0, "goal block raised");
-            push.notify_global(PushService.goal_block_notification(
-                ((!)goal).title, goal_id, block.block_type, question));
-            return block;
+                @"$(((!)raised).block_type) raised: $(question)");
+            return raised;
         }
 
         /**
          * The goal agent submitted its completion report (`saddle
          * done` in goal context): the goal moves to
-         * 'completion_review', the report is stored (goal row +
-         * goal_blocks type 'completion') and the goal-run task parks
-         * on the mirrored block. The daemon enforces the
-         * branch-push rule before letting `done` through. Idempotent
-         * on terminal goals (plan 13 B2): a redelivered done push
-         * must not resurrect a finished goal.
+         * 'completion_review', the report is stored (goal row + ONE
+         * completion-kind block on the goal-run task) and the run
+         * parks on it. The daemon enforces the branch-push rule
+         * before letting `done` through. Idempotent on terminal goals
+         * (plan 13 B2): a redelivered done push must not resurrect a
+         * finished goal.
          */
-        public async GoalBlock submit_completion(int64 goal_id, string report) throws Error {
+        public async TaskBlock? submit_completion(int64 goal_id, string report) throws Error {
             var goal = yield require_goal(goal_id);
             if (((!)goal).status == GOAL_COMPLETE || ((!)goal).status == GOAL_FAILED
                     || ((!)goal).status == GOAL_CANCELLED) {
@@ -447,44 +429,31 @@ namespace ClankerSaddle.Services {
                     @"The goal is already $(((!)goal).status) — its completion review is closed");
             }
             // Idempotent on the pending report (see submit_plan).
-            foreach (var existing in yield list_blocks(goal_id)) {
-                if (existing.block_type == GOAL_BLOCK_COMPLETION
-                        && existing.answer == null) {
-                    return existing;
-                }
+            TaskBlock? raised = yield raise_run_block(((!)goal), "Goal completion report",
+                GOAL_BLOCK_COMPLETION, null, report, true);
+            if (raised == null) {
+                return null;
             }
             ((!)goal).status = GOAL_COMPLETION_REVIEW;
             ((!)goal).completion_report = report;
-            yield db.update_async<Goal>((!)goal);
-            var block = new GoalBlock() {
-                goal_id = goal_id,
-                question = "Goal completion report",
-                block_type = GOAL_BLOCK_COMPLETION,
-                body = report,
-                created_at = new DateTime.now_local()
-            };
-            yield db.insert_async<GoalBlock>(block);
+            yield db.update_async<Goal>(((!)goal));
             yield record_event(goal_id, "completion-submitted",
                 "Completion report submitted for review");
-            yield mirror_block_on_run_task(((!)goal), block);
-            yield pulse.bump(0, "goal completion submitted");
-            push.notify_global(PushService.goal_block_notification(
-                ((!)goal).title, goal_id, GOAL_BLOCK_COMPLETION, block.question));
-            return block;
+            return raised;
         }
 
         /**
-         * The goal agent failed the goal (`saddle failure` in goal
-         * context): status 'failed', the report stored, the goal-run
-         * task failed (its session closes and saddle frees), every
-         * non-terminal task cascades exactly like a cancellation
-         * (plan 13 B3: drafts are deleted — no orphan stays
-         * dispatchable — queued/working/blocked tasks are cancelled)
-         * and the narrative records the cascade. Every user is
-         * notified. Idempotent on terminal goals (plan 13 B2): a
-         * replayed failure push — redelivered OR a fresh entry id
-         * from the honest burst — re-runs no bookkeeping, so exactly
-         * one goal-failed event, one cascade, one notification, ever.
+         * Fails the goal (`saddle failure` in goal context): status
+         * 'failed', the report stored, the goal-run task failed (its
+         * session closes and saddle frees), every non-terminal task
+         * cascades exactly like a cancellation (plan 13 B3: drafts
+         * are deleted — no orphan stays dispatchable — queued/working/
+         * blocked tasks are cancelled) and the narrative records the
+         * cascade. Every user is notified. Idempotent on terminal
+         * goals (plan 13 B2): a replayed failure push — redelivered
+         * OR a fresh entry id from the honest burst — re-runs no
+         * bookkeeping, so exactly one goal-failed event, one cascade,
+         * one notification, ever.
          */
         public async void fail_goal(int64 goal_id, string? report) throws Error {
             var goal = yield require_goal(goal_id);
@@ -495,7 +464,7 @@ namespace ClankerSaddle.Services {
             ((!)goal).status = GOAL_FAILED;
             ((!)goal).completed_at = new DateTime.now_local();
             ((!)goal).completion_report = report ?? ((!)goal).completion_report;
-            yield db.update_async<Goal>((!)goal);
+            yield db.update_async<Goal>(((!)goal));
 
             var run = yield goal_run_task(goal_id);
             if (run != null && ((!)run).status != STATUS_DONE
@@ -533,15 +502,17 @@ namespace ClankerSaddle.Services {
         }
 
         /**
-         * Answers a goal block (the goal page's single verdict path):
-         * the answer lands on the goal row AND on EVERY unanswered
-         * mirror of it on the goal-run task — both halves of the
-         * dual-rowed pair settle before any observer pulse fires
-         * (plan 14 B3's mirror-sync: a one-sided pair is exactly the
-         * staleness the dashboard pile suffered); the parked-resume
-         * machinery then wakes the agent with the verdict as its
-         * resume_notice. Plan and completion blocks apply their
-         * goal-state transitions:
+         * Answers a goal's block (plan 15 C1: THE single answer path
+         * every surface calls — the goal page's verdict forms, the
+         * task-page deep link on a goal-run block, the dashboard's
+         * link-through destination). The block is the goal-run task's
+         * OWN row: answering it in place settles everything an
+         * observer can read before the first pulse fires, so the
+         * completion-review-never-leaves-the-dashboard bug class is
+         * structurally impossible — no dual rows exist to fall out of
+         * sync. The parked-resume machinery then wakes the agent with
+         * the verdict as its resume_notice. Plan and completion
+         * blocks apply their goal-state transitions:
          *
          * - plan + {@link NOTICE_PLAN_APPROVED} → the submission
          *   becomes goal.plan, status 'active';
@@ -551,9 +522,9 @@ namespace ClankerSaddle.Services {
          * - completion + feedback → status 'active' (the agent keeps
          *   working).
          */
-        public async void answer_goal_block(int64 block_id, string answer,
+        public async void answer_block(int64 block_id, string answer,
                 string? answered_by = null) throws Error {
-            var block = yield db.query<GoalBlock>()
+            var block = yield db.query<TaskBlock>()
                 .where(expr("id == $0", elem<int64?>(block_id)))
                 .first_async();
             if (block == null) {
@@ -563,25 +534,26 @@ namespace ClankerSaddle.Services {
             if (((!)block).answer != null) {
                 return;
             }
-            var goal = yield get_goal(((!)block).goal_id);
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(((!)block).task_id)))
+                .first_async();
+            if (task == null || ((!)task).kind != TASK_KIND_GOAL_RUN
+                    || ((!)task).goal_id <= 0) {
+                // Not a goal-run block: the plain task answer path.
+                yield tasks.answer_block(block_id, answer, answered_by);
+                return;
+            }
+            var goal = yield get_goal(((!)task).goal_id);
             if (goal == null) {
                 return;
             }
 
-            ((!)block).answer = answer;
-            ((!)block).answered_at = new DateTime.now_local();
-            ((!)block).answered_by = answered_by;
-            yield db.update_async<GoalBlock>((!)block);
-
-            // Plan 14 B3 mirror-sync ordering: the verdict's events
-            // record FIRST, then both halves of the dual-rowed pair
-            // settle (goal row above, every unanswered mirror below)
-            // before the first observer pulse fires — a one-sided
-            // pair is exactly the staleness the dashboard pile
-            // suffered, and an event-less resume is a lost
-            // context_update. The dispatcher's periodic tick can
-            // strike anywhere in between; by the time either row
-            // reads answered, the events are already committed.
+            // The verdict's events record FIRST, then the row settles
+            // (below) before the first observer pulse fires — an
+            // event-less resume is a lost context_update, and an
+            // unanswered row past the pulse is exactly the stale-pile
+            // shape plan 14 B3 defended against with mirror sweeps.
+            // With one row there is nothing left to sweep.
             if (((!)block).block_type == GOAL_BLOCK_PLAN) {
                 if (answer == NOTICE_PLAN_APPROVED) {
                     if (((!)block).body != null && ((!)block).body.length > 0) {
@@ -607,26 +579,16 @@ namespace ClankerSaddle.Services {
                         "Completion report rejected with feedback — the agent continues");
                 }
             }
-            yield db.update_async<Goal>((!)goal);
+            yield db.update_async<Goal>(((!)goal));
+
+            yield tasks.answer_block(block_id, answer, answered_by);
 
-            var run = yield goal_run_task(((!)goal).id);
-            if (run != null) {
-                foreach (var task_block in yield tasks.list_blocks(((!)run).id)) {
-                    if (task_block.question == ((!)block).question
-                            && task_block.answer == null) {
-                        yield tasks.answer_block(task_block.id, answer, answered_by);
-                    }
-                }
-                if (((!)block).block_type == GOAL_BLOCK_COMPLETION
-                        && answer == NOTICE_REVIEW_APPROVED) {
-                    // Approving the completion closes the goal agent's
-                    // own task: the run is finished, its saddle frees.
-                    yield tasks.complete_task(((!)run).id,
-                        ((!)goal).completion_report, null, "done");
-                }
-            }
             if (((!)block).block_type == GOAL_BLOCK_COMPLETION
                     && answer == NOTICE_REVIEW_APPROVED) {
+                // Approving the completion closes the goal agent's
+                // own task: the run is finished, its saddle frees.
+                yield tasks.complete_task(((!)task).id,
+                    ((!)goal).completion_report, null, "done");
                 push.notify_global(PushService.goal_terminal_notification(
                     ((!)goal).title, ((!)goal).id, true));
             }
@@ -634,28 +596,44 @@ namespace ClankerSaddle.Services {
         }
 
         /**
-         * Raises the mirror of a goal block on the goal-run task: the
-         * parked-resume machinery (answered blocks ride the resuming
-         * allocation) works untouched, and the goal-run task parks as
-         * 'blocked' waiting for the human verdict. A no-op when the
-         * run task does not exist or already carries the question.
-         * The mirror is QUIET — the goal-level block already pushed
-         * its notification (goal title, /goals/<id> URL); the mirror
-         * must not double-notify with the run task's title.
+         * Raises the goal's ONE block on the goal-run task (plan 15
+         * C1): the parked-resume machinery (answered blocks ride the
+         * resuming allocation) works untouched, and the goal-run task
+         * parks as 'blocked' waiting for the human verdict. The raise
+         * is QUIET on the task side — the goal-level notification
+         * (goal title, /goals/<id> URL) fires here, once, and must
+         * not double-notify with the run task's title.
+         *
+         * Deduplication (`dedupe_on_type`): a pending row of the same
+         * question — or, for the review kinds whose question text is
+         * fixed ("Plan submitted for review", "Goal completion
+         * report"), of the same TYPE — means the push is a
+         * redelivery and the existing row is returned unwritten. A
+         * missing or terminal run task raises nothing (null).
          */
-        private async void mirror_block_on_run_task(Goal goal, GoalBlock block) throws Error {
+        private async TaskBlock? raise_run_block(Goal goal, string question,
+                string block_type, string[]? options, string? body,
+                bool dedupe_on_type) throws Error {
             var run = yield goal_run_task(goal.id);
             if (run == null || ((!)run).status == STATUS_DONE
                     || ((!)run).status == STATUS_FAILED) {
-                return;
+                warning("[ClankerSaddle] Goal %s block '%s' raised without a live run task",
+                    goal.id.to_string(), question);
+                return null;
             }
             foreach (var existing in yield tasks.list_blocks(((!)run).id)) {
-                if (existing.question == block.question && existing.answer == null) {
-                    return;
+                bool same = dedupe_on_type
+                    ? existing.block_type == block_type : existing.question == question;
+                if (same && existing.answer == null) {
+                    return existing;
                 }
             }
-            yield tasks.add_block(((!)run).id, block.question, block.block_type,
-                TaskService.parse_block_options(block.options), block.body, true);
+            var raised = yield tasks.add_block(((!)run).id, question, block_type,
+                options, body, true);
+            yield pulse.bump(0, "goal block raised");
+            push.notify_global(PushService.goal_block_notification(
+                goal.title, goal.id, block_type, question));
+            return raised;
         }
 
         // ------------------------------------------------------------------
@@ -685,12 +663,18 @@ namespace ClankerSaddle.Services {
                 .materialise_async();
         }
 
-        /** A goal's blocks, oldest first. */
-        public async ImmutableLot<GoalBlock> list_blocks(int64 goal_id) throws Error {
-            return yield db.query<GoalBlock>()
-                .where(expr("goal_id == $0", elem<int64?>(goal_id)))
-                .order_by(expr("id"))
-                .materialise_async();
+        /**
+         * A goal's blocks (plan 15 C1): the goal-run task's
+         * `task_blocks` rows, oldest first — the run task is the
+         * single home of the goal agent's questions, plans and
+         * completion reports. Empty while the goal has no run task.
+         */
+        public async ImmutableLot<TaskBlock> list_blocks(int64 goal_id) throws Error {
+            var run = yield goal_run_task(goal_id);
+            if (run == null) {
+                return new Vector<TaskBlock>().to_immutable_buffer();
+            }
+            return yield tasks.list_blocks(((!)run).id);
         }
 
         /** A goal's narrative events, oldest first. */

+ 10 - 8
src/Services/SaddleService.vala

@@ -1584,9 +1584,10 @@ namespace ClankerSaddle.Services {
          * summary (freeing the saddle and closing the session) —
          * except on a goal-run session, where they submit the
          * completion report or fail the goal (plan 12); `blocked`
-         * raises the block payload as an answerable block (a goal
-         * run's blocks attach to the goal, mirrored onto the run task
-         * for the verdict resume) and parks the saddle; `suspended`
+         * raises the block payload as an answerable block on the
+         * goal run's OWN task row (plan 15 C1: the goal agent's
+         * blocks ride the run task — no mirrored second row exists)
+         * and parks the saddle; `suspended`
          * parks the same way — for a goal agent the resume is driven
          * by the goal's task-terminal events; and `working` flips the
          * task to working (never downgrading a pending review).
@@ -1635,11 +1636,12 @@ namespace ClankerSaddle.Services {
                     break;
                 case "blocked":
                     if (goal_id > 0) {
-                        // Goal context: blocks attach to the goal — a
-                        // plan payload enters plan review, anything
-                        // else raises a goal question/RFC. The mirror
-                        // block on the run task parks it for the
-                        // verdict resume. A BLOCKED push WITHOUT a
+                        // Goal context: the block lands on the run
+                        // task itself (plan 15 C1) — a plan payload
+                        // enters plan review, anything else raises a
+                        // goal question/RFC; the run parks on that
+                        // very row for the verdict resume. A BLOCKED
+                        // push WITHOUT a
                         // block payload (the park-unwinding round-end
                         // push, its trailing entries) raises nothing —
                         // the payload-carrying transition already did,

+ 10 - 29
src/Services/TaskService.vala

@@ -24,6 +24,12 @@ namespace ClankerSaddle.Services {
     public const string BLOCK_RFC = "rfc";
     public const string BLOCK_REVIEW = "review";
     public const string BLOCK_PLAN = "plan";
+    /**
+     * The goal agent's completion report (plan 15 C1): raised on the
+     * goal-run task by {@link GoalService.submit_completion} — the
+     * goal's review kinds are plan and completion.
+     */
+    public const string BLOCK_COMPLETION = "completion";
 
     /**
      * The agent-celebration entry type (`saddle yeehaw <message>`): the
@@ -1467,38 +1473,13 @@ namespace ClankerSaddle.Services {
          * (the answer-delivery pass, the affinity resume, or a later
          * assignment payload). Such a task must never be requeued: the
          * requeue closes the session the resume machinery needs.
-         * Plan 14 B3 defensive: a goal-run's MIRROR may lag its goal
-         * row (historical one-sided pairs, the mid-answer window) —
-         * the pair counts as answered when the NEWEST goal block of
-         * the same question carries an answer (newest-of-question:
-         * plan revisions reuse one question, an older revision's
-         * verdict must not close the current review).
+         * Plan 15 C1: goal blocks live on the run task as single rows
+         * — no mirror pair exists to fall out of sync, so the newest
+         * row's own answer is the whole truth.
          */
         public async bool newest_block_answered(int64 task_id) throws Error {
             var newest = yield newest_block(task_id);
-            if (newest == null) {
-                return false;
-            }
-            if (((!)newest).answer != null) {
-                return true;
-            }
-            var task = yield db.query<TaskEntity>()
-                .where(expr("id == $0", elem<int64?>(task_id)))
-                .first_async();
-            if (task == null || ((!)task).kind != TASK_KIND_GOAL_RUN
-                    || ((!)task).goal_id <= 0) {
-                return false;
-            }
-            GoalBlock? newest_goal = null;
-            foreach (var goal_block in yield db.query<GoalBlock>()
-                    .where(expr("goal_id == $0", elem<int64?>(((!)task).goal_id)))
-                    .order_by(expr("id"))
-                    .materialise_async()) {
-                if (goal_block.question == ((!)newest).question) {
-                    newest_goal = goal_block;
-                }
-            }
-            return newest_goal != null && ((!)newest_goal).answer != null;
+            return newest != null && ((!)newest).answer != null;
         }
 
         /** All commands recorded against a session, oldest first. */

+ 1 - 2
src/actions/DashboardActions.vala

@@ -16,7 +16,6 @@ namespace ClankerSaddle {
         private TaskService task_service = inject<TaskService>();
         private SaddleService saddle_service = inject<SaddleService>();
         private TimelineService timeline_service = inject<TimelineService>();
-        private GoalService goal_service = inject<GoalService>();
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private PushService push_service = inject<PushService>();
         private UserService users = inject<UserService>();
@@ -29,7 +28,7 @@ namespace ClankerSaddle {
 
             var me = SpryAuth.current(request);
             var state = yield DashboardState.build(state_service, task_service,
-                saddle_service, timeline_service, goal_service, db, action_registry,
+                saddle_service, timeline_service, db, action_registry,
                 push_service, me != null ? ((!)me).user_id : 0);
             return directives().set(state);
         }

+ 10 - 6
src/actions/GoalActions.vala

@@ -327,11 +327,15 @@ namespace ClankerSaddle {
     }
 
     /**
-     * Answers a goal block — the goal page's single verdict path for
-     * plan submissions, completion reports and the agent's
-     * questions/RFCs (see {@link GoalService.answer_goal_block}). An
-     * empty reply with a pending option list selects the first option
-     * (the plain reply form's affordance).
+     * Answers a goal block — the goal page's verdict path for plan
+     * submissions, completion reports and the agent's questions/RFCs.
+     * Plan 15 C1: the block is the goal-run task's own row and
+     * {@link GoalService.answer_block} is the ONE service path every
+     * surface (goal page, task-page deep link, completion accept)
+     * funnels into — a single row settles in one write, so the pile
+     * card cannot outlive its answer. An empty reply with a pending
+     * option list selects the first option (the plain reply form's
+     * affordance).
      */
     public class AnswerGoalBlockAction : TypedStatumAction<GoalBlockAnswerPrivate> {
 
@@ -370,7 +374,7 @@ namespace ClankerSaddle {
             var me = SpryAuth.current(request);
             string answered_by = me != null ? @"user:$(me.username)" : "user:anonymous";
             try {
-                yield goals.answer_goal_block(block_id, answer, answered_by);
+                yield goals.answer_block(block_id, answer, answered_by);
                 return directives().notify("success", "Answer recorded")
                     .navigate(@"/goals/$(goal_id)");
             } catch (Error e) {

+ 11 - 29
src/actions/TaskActions.vala

@@ -282,38 +282,20 @@ namespace ClankerSaddle {
             }
 
             try {
-                // Plan 12: the answer rides the parked workspace's
-                // resume verbatim (the stable verdicts); goal-plan
-                // approval happens on the goal page, not here.
-                yield tasks.answer_block(((!)ctx).block_id, answer);
-                // Plan 14 B3 mirror-sync: an answer landing on a
-                // goal-run task's MIRROR block (a stale deep link, a
-                // historical bookmark — the observed one-sided-row
-                // shape) must close the goal's own block too, or the
-                // goal never applies its verdict transition and the
-                // dashboard pile keeps showing the question forever.
+                // Plan 15 C1: a goal-run task's blocks ARE the goal's
+                // blocks — the deep-link answer funnels into the ONE
+                // goal answer path (verdict transitions + row settle +
+                // resume machinery); every other task answers plainly.
+                // The answer rides the parked workspace's resume
+                // verbatim (the stable verdicts).
                 var detail = yield tasks.get_task(task_id);
                 if (detail != null && ((!)detail).task.kind == TASK_KIND_GOAL_RUN
                         && ((!)detail).task.goal_id > 0) {
-                    string question = "";
-                    foreach (var candidate in ((!)detail).blocks) {
-                        if (candidate.id == ((!)ctx).block_id) {
-                            question = candidate.question;
-                            break;
-                        }
-                    }
-                    if (question.length > 0) {
-                        foreach (var goal_block in yield goals.list_blocks(
-                                ((!)detail).task.goal_id)) {
-                            if (goal_block.question == question
-                                    && goal_block.answer == null) {
-                                var me = SpryAuth.current(request);
-                                yield goals.answer_goal_block(goal_block.id, answer,
-                                    me != null ? @"user:$(me.username)" : null);
-                                break;
-                            }
-                        }
-                    }
+                    var me = SpryAuth.current(request);
+                    yield goals.answer_block(((!)ctx).block_id, answer,
+                        me != null ? @"user:$(me.username)" : null);
+                } else {
+                    yield tasks.answer_block(((!)ctx).block_id, answer);
                 }
                 return directives().notify("success", "Answer recorded; the saddle will collect it")
                     .navigate(@"/tasks/$(task_id)");

+ 8 - 1
src/entrypoints/GoalDetailEntrypoint.vala

@@ -19,7 +19,12 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? remove { get; set; }
     }
 
-    /** One goal block (question/RFC/plan revision/completion) as the page renders it. */
+    /**
+     * One goal block (question/RFC/plan revision/completion) as the
+     * page renders it — plan 15 C1: the view over the goal-run
+     * task's own `task_blocks` row (there is no separate goal-side
+     * row).
+     */
     public class GoalBlockView : Object {
         public int64 id { get; set; }
         public string question { get; set; default = ""; }
@@ -380,6 +385,8 @@ namespace ClankerSaddle {
             }
 
             bool completion_pending = false;
+            // Plan 15 C1: the goal's blocks ARE the goal-run task's
+            // blocks — one row per question, answered in place.
             foreach (var block in yield goals.list_blocks(goal.id)) {
                 bool pending_plan = block.block_type == GOAL_BLOCK_PLAN
                     && block.answer == null;

+ 37 - 86
src/entrypoints/HomeEntrypoint.vala

@@ -55,13 +55,14 @@ namespace ClankerSaddle {
     }
 
     /**
-     * A pinned OPEN blocking item (plan 10 phase 3; plan 12 unified):
-     * an unanswered question, RFC or review across any task, or a goal
-     * block (the goal agent's question/RFC, a plan submission or the
-     * completion report) — rendered as a link-through card. Goal items
-     * carry `item_type` "goal" and link the goal page; task items
-     * carry "task" and link the task page where the answer form lives.
-     * No inline answer forms on the dashboard (settled).
+     * A pinned OPEN blocking item (plan 10 phase 3; plan 12 unified;
+     * plan 15 C1 single-row): an unanswered question, RFC, review or
+     * the goal agent's block (question/RFC, plan submission,
+     * completion report — all rows of the goal-run task) rendered as
+     * a link-through card. Goal items carry `item_type` "goal" and
+     * link the goal page; task items carry "task" and link the task
+     * page where the answer form lives. No inline answer forms on the
+     * dashboard (settled).
      */
     public class DashboardBlockView : Object {
         /** "task" or "goal" — the pile interleaves both. */
@@ -103,7 +104,8 @@ namespace ClankerSaddle {
      * the {@link RefreshDashboardAction} pulse refresh, so both hydrate
      * identical state: queue counters, saddle cards, the pinned OPEN
      * blocking items as link-through cards (newest first — plan 10
-     * phase 3), active tasks with their latest progress, and the
+     * phase 3; goal items arrive with the run task's own blocks,
+     * plan 15 C1), active tasks with their latest progress, and the
      * cross-task activity timeline ({@link TimelineService}: last ~50
      * events, newest first).
      */
@@ -115,7 +117,7 @@ namespace ClankerSaddle {
          */
         public static async Statum.Slot build(StateService state_service, TaskService tasks,
                 SaddleService saddles, TimelineService timeline,
-                GoalService goal_service, InvercargillSql.Orm.OrmSession db,
+                InvercargillSql.Orm.OrmSession db,
                 ActionRegistry? actions = null, PushService? push = null,
                 int64 viewer_id = 0) throws GLib.Error {
             var pub = new DashboardPublic();
@@ -198,13 +200,14 @@ namespace ClankerSaddle {
             var active = new Vector<DashboardActiveView>();
             var pinned = new Vector<DashboardBlockView>();
             foreach (var task in yield tasks.list_tasks(null, null)) {
-                // Goal-agent runs are internal — never an "active task"
-                // row (the goal page shows the agent's activity).
-                if (task.kind == TASK_KIND_GOAL_RUN) {
-                    continue;
-                }
-                if (task.status == STATUS_ASSIGNED || task.status == STATUS_WORKING
-                        || task.status == STATUS_BLOCKED || task.status == STATUS_REVIEW_REQUESTED) {
+                // Goal-agent runs are internal — never an "active
+                // task" row (the goal page shows the agent's
+                // activity). Their BLOCKS still pin below (plan 15
+                // C1): the run task's rows are the goal's pile cards.
+                bool internal_run = task.kind == TASK_KIND_GOAL_RUN;
+                if (!internal_run
+                        && (task.status == STATUS_ASSIGNED || task.status == STATUS_WORKING
+                        || task.status == STATUS_BLOCKED || task.status == STATUS_REVIEW_REQUESTED)) {
                     var row = new DashboardActiveView() {
                         id = task.id,
                         title = task.title,
@@ -220,82 +223,31 @@ namespace ClankerSaddle {
                 }
 
                 // Every unanswered block pins as a link-through card —
-                // questions, RFCs and reviews alike.
+                // questions, RFCs and reviews alike. Plan 15 C1: the
+                // goal agent's blocks ARE the run task's blocks (one
+                // row each) — they render as goal items (goal badge,
+                // goal link) straight from this same loop; there is no
+                // goal-side pass and no mirror to consult.
+                int64 block_goal_id = 0;
+                string block_goal_title = "";
+                if (internal_run && task.goal_id > 0) {
+                    block_goal_id = task.goal_id;
+                    var goal_row = yield db.query<Goal>()
+                        .where(expr("id == $0", elem<int64?>(task.goal_id)))
+                        .first_async();
+                    block_goal_title = goal_row != null ? ((!)goal_row).title : "";
+                }
                 foreach (var block in yield tasks.list_blocks(task.id)) {
                     if (block.answer != null) {
                         continue;
                     }
                     var view = new DashboardBlockView() {
-                        item_type = "task",
+                        item_type = block_goal_id > 0 ? "goal" : "task",
                         id = block.id,
                         task_id = task.id,
                         task_title = task.title,
-                        block_type = block.block_type,
-                        question = block.question,
-                        created_display = format_time(block.created_at)
-                    };
-                    if (block.created_at != null) {
-                        view.at_epoch = ((!)block.created_at).to_unix();
-                    }
-                    // The goal agent's own parked run mirrors its
-                    // blocks onto the goal — the goal entry owns the
-                    // card (no duplicate).
-                    if (task.kind == TASK_KIND_GOAL_RUN) {
-                        continue;
-                    }
-                    pinned.add(view);
-                }
-            }
-
-            // Plan 12: the goal blocks join the pile — the goal agent's
-            // questions/RFCs, plan submissions and completion reports,
-            // interleaved with the task blocks, newest first. The
-            // GoalService arrives as a parameter (an inject past an
-            // await resumes with an empty injection context stack).
-            // Plan 14 B3 defensive: the dual-rowed pair is CLOSED when
-            // EITHER side carries an answer — a historical one-sided
-            // row (the goal row null, its run-task mirror answered via
-            // a stale deep link) must not pin the question forever.
-            foreach (var goal in yield goal_service.list_goals()) {
-                int64 run_task_id = 0;
-                foreach (var block in yield goal_service.list_blocks(goal.id)) {
-                    if (block.answer != null) {
-                        continue;
-                    }
-                    if (run_task_id == 0) {
-                        var run = yield db.query<TaskEntity>()
-                            .where(expr("goal_id == $0 && kind == $1",
-                                elem<int64?>(goal.id),
-                                elem<string>(TASK_KIND_GOAL_RUN)))
-                            .first_async();
-                        if (run != null) {
-                            run_task_id = ((!)run).id;
-                        }
-                    }
-                    if (run_task_id > 0) {
-                        // Newest-of-question: plan revisions reuse one
-                        // question — only the NEWEST mirror's verdict
-                        // closes the current review.
-                        bool mirror_answered = false;
-                        TaskBlock? newest_mirror = null;
-                        foreach (var mirror in yield tasks.list_blocks(run_task_id)) {
-                            if (mirror.question == block.question) {
-                                newest_mirror = mirror;
-                            }
-                        }
-                        if (newest_mirror != null
-                                && ((!)newest_mirror).answer != null) {
-                            mirror_answered = true;
-                        }
-                        if (mirror_answered) {
-                            continue;
-                        }
-                    }
-                    var view = new DashboardBlockView() {
-                        item_type = "goal",
-                        id = block.id,
-                        goal_id = goal.id,
-                        goal_title = goal.title,
+                        goal_id = block_goal_id,
+                        goal_title = block_goal_title,
                         block_type = block.block_type,
                         question = block.question,
                         created_display = format_time(block.created_at)
@@ -377,7 +329,6 @@ namespace ClankerSaddle {
         private TaskService task_service = Inversion.inject<TaskService>();
         private SaddleService saddle_service = Inversion.inject<SaddleService>();
         private TimelineService timeline_service = Inversion.inject<TimelineService>();
-        private GoalService goal_service = Inversion.inject<GoalService>();
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private PushService push_service = Inversion.inject<PushService>();
         private UserService users = Inversion.inject<UserService>();
@@ -390,7 +341,7 @@ namespace ClankerSaddle {
 
             var me = SpryAuth.current(request);
             var state = yield DashboardState.build(state_service, task_service,
-                saddle_service, timeline_service, goal_service, db, action_registry,
+                saddle_service, timeline_service, db, action_registry,
                 push_service, me != null ? ((!)me).user_id : 0);
             return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
         }

+ 0 - 1
src/main.vala

@@ -51,7 +51,6 @@ int main(string[] args) {
         db.add_entity<Goal>(Goal.entity_mapping);
         db.add_entity<GoalLink>(GoalLink.entity_mapping);
         db.add_entity<GoalComment>(GoalComment.entity_mapping);
-        db.add_entity<GoalBlock>(GoalBlock.entity_mapping);
         db.add_entity<GoalEvent>(GoalEvent.entity_mapping);
         db.migrate_on_startup();
 

+ 9 - 12
test/integration_test.py

@@ -13,7 +13,7 @@ The goal-era arc (plan 12):
    over the ask/allocate channel with the full plan-12 payload (kind/
    mode/creator/goal context/ephemeral branches for workers)
 2. pre-plan question block -> answered on the goal page -> the ONE
-   answer resolves the goal_blocks row AND the mirrored task_blocks
+   answer resolves the goal-run task's single task_blocks row
    row, and the parked goal agent resumes with the verdict wording
 3. plan submit -> reject-with-feedback -> revision -> approval (the
    agreed verdict words ride the resume)
@@ -430,13 +430,13 @@ def main():
                                 capture_output=True, text=True).stdout.strip()
         check(branch == "goal/demo-run", "the goal clone sits on the user-named goal branch")
 
-        print("== pre-plan question -> one answer, dual rows ==")
+        print("== pre-plan question -> one answer, one row ==")
         check(wait_for(lambda: any(b["block_type"] == "question" and not b.get("answered")
                                    for b in goal_state(auth, goal1).get("blocks", [])) or False,
                        timeout=90, what="the agent's question block") is not None,
               "the pre-plan question surfaces as a goal block")
         check(wait_task_status(auth, run_task, "blocked", timeout=30) is not None,
-              "the goal-run task parks blocked on the mirrored block")
+              "the goal-run task parks blocked on its own block row")
         question = [b for b in goal_state(auth, goal1)["blocks"]
                     if b["block_type"] == "question" and not b.get("answered")][0]
         check(question["options"] == ["Demo only", "Whole workspace"],
@@ -445,23 +445,20 @@ def main():
               "the question title derives from the body's first line")
         check("confirm the blast radius" in question["body"], "the question carries the markdown body")
         dupes = db_query(
-            "select count(*) from goal_blocks where goal_id = ? and type = 'question'", (goal1,))[0][0]
+            "select count(*) from task_blocks tb join tasks t on t.id = tb.task_id"
+            " where t.goal_id = ? and t.kind = 'goal_run' and tb.type = 'question'",
+            (goal1,))[0][0]
         check(dupes == 1, "one question row despite the park-unwinding round-end push")
         dash = slot_public(auth, "/", "dashboard")
         check(any(int(r.get("goal_id") or 0) == goal1 for r in dash["pinned"]),
               "the dashboard pile pins the goal block")
         act(auth, question["answer_action"], {"option": "Demo only"})
-        answered_goal_rows = db_query(
-            "select answer from goal_blocks where goal_id = ? and type = 'question'",
-            (goal1,))
-        mirrored_rows = db_query(
+        answered_rows = db_query(
             "select answer from task_blocks tb join tasks t on t.id = tb.task_id"
             " where t.goal_id = ? and t.kind = 'goal_run' and tb.type = 'question'",
             (goal1,))
-        check(answered_goal_rows and answered_goal_rows[0][0] == "Demo only",
-              "the ONE answer lands on the goal_blocks row")
-        check(mirrored_rows and mirrored_rows[0][0] == "Demo only",
-              "the same answer resolves the mirrored task_blocks row")
+        check(answered_rows and answered_rows[0][0] == "Demo only",
+              "the ONE answer lands on the goal-run task's single row")
 
         print("== plan review: reject -> revise -> approve ==")
         check(wait_goal_status(auth, goal1, "plan_review", timeout=90) is not None,

+ 138 - 72
test/web_stack_test.py

@@ -2719,7 +2719,7 @@ def bundle_dispatch(ctx):
 # subtask events exist to resume it), the DELETED subtask machinery's
 # verbatim refusals, and the goal agent's task CLI in depth
 # (capabilities, ownership, the daemon's task-reading API, the
-# goal-run mirror block answered from the task page).
+# goal-run block answered from the task page).
 # =============================================================================
 
 def bundle_subtasks(ctx):
@@ -2904,10 +2904,11 @@ def bundle_subtasks(ctx):
           and body["task"].get("goal_title") == "Sequencing goal",
           "the task CLI detail names the owning goal")
 
-    print("== the goal-run mirror block answers from the task page ==")
-    # A question block raised in goal context mirrors onto the run
-    # task; the TASK page's answer action resumes the agent with the
-    # verdict riding the allocation (the goal page shares the path).
+    print("== the goal-run block answers from the task page (C1) ==")
+    # A question block raised in goal context lands on the run task's
+    # own row; the TASK page's answer action funnels into the one
+    # goal answer path and resumes the agent with the verdict riding
+    # the allocation (the goal page shares the path).
     fake.goal_script = [("question", "Which harness: pytest or vitest?",
                          ["pytest", "vitest"])]
     conn = db()
@@ -2917,26 +2918,27 @@ def bundle_subtasks(ctx):
     conn.commit()
     conn.close()
     check(wait_for(lambda: task_state(auth, run_task)["status"] == "blocked"
-                   or False, timeout=60, what="mirror block parks the run") is not None,
-          "a goal question parks the run task via its mirror block")
+                   or False, timeout=60, what="goal block parks the run") is not None,
+          "a goal question parks the run task on its own block row")
     run_detail = task_state(auth, run_task)
-    mirror = [b for b in run_detail["blocks"]
-              if b["block_type"] == "question"][0]
-    check(mirror.get("question") == "Which harness: pytest or vitest?",
-          "the mirror block carries the goal question verbatim")
-    check(any(b.get("block_type") == "question" and not b.get("answered")
-              for b in goal_state(goal)["blocks"]),
-          "the goal block exists alongside (the goal page's card)")
-    act(auth, mirror["answer_action"], {"answer": "pytest"})
+    question_block = [b for b in run_detail["blocks"]
+                      if b["block_type"] == "question"][0]
+    check(question_block.get("question") == "Which harness: pytest or vitest?",
+          "the run task's block carries the goal question verbatim")
+    goal_block_ids = [int(b["id"]) for b in goal_state(goal)["blocks"]
+                      if b.get("block_type") == "question"]
+    check(goal_block_ids == [int(question_block["id"])],
+          "the goal page's card is the SAME row (no dual-row pair)")
+    act(auth, question_block["answer_action"], {"answer": "pytest"})
     fake.goal_script = [("suspend",)]
     check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
-                   or False, timeout=60, what="answered mirror resumes the agent")
+                   or False, timeout=60, what="answered block resumes the agent")
           is not None,
-          "the answered mirror block resumes the goal agent")
+          "the answered block resumes the goal agent")
     answered = [b for b in task_state(auth, run_task)["blocks"]
                 if b["block_type"] == "question"][0]
     check(answered.get("answer") == "pytest",
-          "the verdict is stored on the mirror block")
+          "the verdict is stored on the single block row")
 
     print("== settle: release the hold and let the shard drain ==")
     # Drop the hold tag so the held worker can dispatch and complete;
@@ -6190,6 +6192,13 @@ def bundle_replay(ctx):
           "redelivered failures never re-run the goal failure (one event, ever)")
     check(task_state(auth, run_task)["status"] == "failed",
           "the goal-run task stays failed after the replays")
+    conn = db()
+    burst_blocks = conn.execute(
+        "select count(*) from task_blocks where task_id = ?", (run_task,)
+    ).fetchone()[0]
+    conn.close()
+    check(burst_blocks == 0,
+          "the goal-failed path raises ZERO blocks (nothing pins the pile)")
 
     print("== plan 13 B4: the comment projection stores the body ==")
     # The heredoc shape: the command is the pipe invocation, the OUTPUT
@@ -6265,15 +6274,17 @@ def bundle_replay(ctx):
     check(queued_count() == 0, "the replay shard drains its queue")
 
 def bundle_goalui(ctx):
-    """Plan 14 workstream B: the goal supervision surface.
+    """Plan 14 workstream B + plan 15 C1: the goal supervision surface.
 
     B1 — goal-run control parity (Retry supervision / Requeue-to-resume
     on the status head, the /tasks/<goal-run-id> redirect, no
-    notify-me toggle), B2 — the plan-review remarks queue, B3 — the
-    dual-row mirror-sync (either side closes the pair; historical
-    one-sided rows don't pin), B4 — sessions.resume_notice + the
-    terminal dropdown/resume panel, B5 — page order, effective rules
-    and per-task capability badges, B6 — narrative footnote weights.
+    notify-me toggle), B2 — the plan-review remarks queue, B4 —
+    sessions.resume_notice + the terminal dropdown/resume panel, B5 —
+    page order, effective rules and per-task capability badges, B6 —
+    narrative footnote weights. C1 — the single-row block world: the
+    goal's blocks ARE the run task's rows (answered from every
+    surface through one path; no dual rows, no stragglers, at most
+    one review-kind block per verdict path).
     """
     auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
 
@@ -6427,65 +6438,62 @@ def bundle_goalui(ctx):
     check(min(notices) in fresh_ids,
           "session 1 (the fresh opening round) carries no resume notice")
 
-    print("== B3: either side of the dual row closes the pair ==")
+    print("== C1: the single run-task row IS the goal's block ==")
     check("Which datastore?" in pile_questions(),
           "the open goal question pins onto the dashboard pile")
-    mirror_rows = [b for b in task_state(auth, run_task)["blocks"]
-                   if b["question"] == "Which datastore?"]
-    check(len(mirror_rows) == 1, "the question mirrors once onto the run task")
+    goal_rows = [b for b in goal_state(goal)["blocks"]
+                 if b["question"] == "Which datastore?"]
+    run_rows = [b for b in task_state(auth, run_task)["blocks"]
+                if b["question"] == "Which datastore?"]
+    check(len(run_rows) == 1 and len(goal_rows) == 1
+          and int(run_rows[0]["id"]) == int(goal_rows[0]["id"]),
+          "the goal page and the run task read the SAME single row")
+    pile_card = [b for b in slot_public(auth, "/", "dashboard")["pinned"]
+                 if b.get("question") == "Which datastore?"][0]
+    check(pile_card.get("item_type") == "goal"
+          and int(pile_card.get("goal_id") or 0) == goal
+          and int(pile_card.get("id") or 0) == int(run_rows[0]["id"])
+          and pile_card.get("goal_title") == "Goalui goal",
+          "the pile card is the run-task row styled as a goal item")
     act(auth, [b for b in goal_state(goal)["blocks"]
                if b["question"] == "Which datastore?" and not b.get("answered")]
         [0]["answer_action"], {"option": "SQLite"})
     conn = db()
-    rows = conn.execute(
-        "select (select count(*) from goal_blocks where question = 'Which datastore?'"
-        "        and answer is null),"
-        "       (select count(*) from task_blocks where question = 'Which datastore?'"
-        "        and answer is null)").fetchone()
+    unanswered = conn.execute(
+        "select count(*) from task_blocks where question = 'Which datastore?'"
+        " and answer is null").fetchone()[0]
     conn.close()
-    check(rows[0] == 0 and rows[1] == 0,
-          "answering from the goal page closes BOTH rows (goal + mirror)")
+    check(unanswered == 0, "answering from the goal page closes the single row")
     check("Which datastore?" not in pile_questions(),
-          "the answered pair no longer pins the dashboard pile")
+          "the answered row no longer pins the dashboard pile")
 
-    # The mirror-side answer path (a stale deep link firing the task
-    # action API): the goal row must close with it.
+    # The task-page deep link (the AnswerBlockAction API surface)
+    # answers the SAME row through the one goal path — there is no
+    # second row to sweep and no stale copy can survive.
     conn = db()
-    conn.execute("insert into goal_blocks (goal_id, type, question, created_at)"
-                 " values (?, 'question', 'Mirror probe question?',"
-                 " strftime('%s','now'))", (goal,))
     conn.execute("insert into task_blocks (task_id, type, question, created_at)"
-                 " values (?, 'question', 'Mirror probe question?',"
+                 " values (?, 'question', 'Deep link probe?',"
                  " strftime('%s','now'))", (run_task,))
     conn.commit()
     conn.close()
-    mirror_block = [b for b in task_state(auth, run_task)["blocks"]
-                    if b["question"] == "Mirror probe question?" and not b.get("answered")][0]
-    act(auth, mirror_block["answer_action"], {"answer": "Answered via the mirror"})
+    check("Deep link probe?" in pile_questions(),
+          "the fixture row pins like any goal question")
+    deep_block = [b for b in task_state(auth, run_task)["blocks"]
+                  if b["question"] == "Deep link probe?" and not b.get("answered")][0]
+    act(auth, deep_block["answer_action"], {"answer": "Answered via the deep link"})
     conn = db()
-    answered = conn.execute(
-        "select count(*) from goal_blocks"
-        " where question = 'Mirror probe question?' and answer is not null"
+    answered_row = conn.execute(
+        "select answer from task_blocks where question = 'Deep link probe?'"
     ).fetchone()[0]
+    tables = {r[0] for r in conn.execute(
+        "select name from sqlite_master where type = 'table'").fetchall()}
     conn.close()
-    check(answered == 1,
-          "an answer on the mirror closes the goal row too (the API path)")
-
-    # Historical one-sided rows (the observed reference-DB shape):
-    # goal row open, its mirror already answered — the pile must not
-    # show it forever.
-    conn = db()
-    conn.execute("insert into goal_blocks (goal_id, type, question, created_at)"
-                 " values (?, 'question', 'Legacy one-sided?',"
-                 " strftime('%s','now'))", (goal,))
-    conn.execute("insert into task_blocks (task_id, type, question, answer,"
-                 " answered_at, created_at)"
-                 " values (?, 'question', 'Legacy one-sided?', 'Resolved long ago',"
-                 " strftime('%s','now'), strftime('%s','now'))", (run_task,))
-    conn.commit()
-    conn.close()
-    check("Legacy one-sided?" not in pile_questions(),
-          "a historical one-sided row does not render in the pile")
+    check(answered_row == "Answered via the deep link",
+          "the task-page deep link answers the same single row (the API path)")
+    check("Deep link probe?" not in pile_questions(),
+          "the deep-link answer closes the pile card too")
+    check("goal_blocks" not in tables,
+          "the goal_blocks table is gone (run-task blocks are canonical)")
 
     print("== B4: the terminal session dropdown + resume-info panel ==")
     fake.goal_script = [("suspend",)]
@@ -6501,7 +6509,7 @@ def bundle_goalui(ctx):
     labels = {int(s["id"]): s["label"] for s in terminal["sessions"]}
     check(all(label.startswith("Session #") for label in labels.values()),
           "dropdown labels read 'Session #N — started <time>'")
-    # Recompute at the check point: the B3 mirror answers above each
+    # Recompute at the check point: the C1 answers above each
     # resumed the agent too, so the earlier snapshot would go stale.
     conn = db()
     resumed_ids = [int(i) for (i, text) in conn.execute(
@@ -6534,9 +6542,10 @@ def bundle_goalui(ctx):
     conn.execute("update saddles set status = 'offline' where id = ?", (fake_id,))
     conn.commit()
     conn.close()
-    state = goal_state(goal)
-    check(state.get("requeue_run") is not None,
+    check(wait_for(lambda: goal_state(goal).get("requeue_run") is not None
+                   or False, timeout=30, what="requeue offered") is not None,
           "a parked run on a dead saddle offers Requeue-to-resume")
+    state = goal_state(goal)
     check(state.get("retry_run") is None,
           "an active goal offers no Retry supervision")
     open_before = open_session(run_task)
@@ -6567,11 +6576,11 @@ def bundle_goalui(ctx):
     check(wait_for(lambda: goal_state(goal)["status"] == "failed" or False,
                    timeout=30, what="goal fails") is not None,
           "the run's failure fails the goal")
-    state = goal_state(goal)
-    check(state.get("retry_run") is not None,
+    check(wait_for(lambda: goal_state(goal).get("retry_run") is not None
+                   or False, timeout=30, what="retry offered") is not None,
           "a failed goal offers Retry supervision")
     fake.goal_script = [("suspend",)]
-    act(auth, state["retry_run"], {})
+    act(auth, goal_state(goal)["retry_run"], {})
     state = goal_state(goal)
     check(state["status"] == "active",
           "the retry revives supervision (goal back to active)")
@@ -6702,6 +6711,63 @@ def bundle_goalui(ctx):
     check(".goal-event.footnote" in css and ".footnote {" in css,
           "the stylesheet ships the footnote row weight beside the utility")
 
+    print("== C1: one review block per verdict path (F6) ==")
+    # The daemon's honest round pushes the ENTRY and the STATE
+    # together — the done pair must raise exactly ONE completion
+    # block, and a redelivered pair nothing more.
+    fake.goal_script = [("done", "# Completion\n\nOne block only.")]
+    conn = db()
+    conn.execute("insert into goal_events (goal_id, kind, text, created_at)"
+                 " values (?, 'task-done', 'Task #997 Warmup — done',"
+                 " strftime('%s','now','-5 seconds'))", (goal,))
+    conn.commit()
+    conn.close()
+    check(wait_for(lambda: goal_state(goal)["status"] == "completion_review" or False,
+                   timeout=60, what="goalui completion review opens") is not None,
+          "the scripted done round enters completion review")
+    def completion_block_count():
+        conn = db()
+        try:
+            return conn.execute(
+                "select count(*) from task_blocks where task_id = ?"
+                " and type = 'completion'", (run_task,)).fetchone()[0]
+        finally:
+            conn.close()
+    check(completion_block_count() == 1,
+          "the done state raises exactly ONE completion block")
+    # The burst shape: the done ENTRY and the done STATE in one push
+    # (fresh entry ids), then the whole pair redelivered — the
+    # pending-report idempotency leaves the one row alone.
+    conn = db()
+    burst_session = conn.execute(
+        "select id from sessions where task_id = ? and ended_at is null",
+        (run_task,)).fetchone()[0]
+    conn.close()
+    done_pair = [{"id": 9401, "command": "saddle done",
+                  "output": "# Completion\n\nOne block only.", "exit_code": 0,
+                  "type": "done", "timestamp": "2026-09-12T00:00:00Z"}]
+    status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
+                           {"session_id": burst_session, "state": "done",
+                            "entries": done_pair,
+                            "summary": "# Completion\n\nOne block only."})
+    check(status == 200 and completion_block_count() == 1,
+          "the done entry + state pair raises nothing more (one block, ever)")
+    state = goal_state(goal)
+    act(auth, state["completion_pending_block"]["answer_action"],
+        {"answer": "Review approved. Continue with the task."})
+    check(wait_for(lambda: goal_state(goal)["status"] == "complete" or False,
+                   timeout=30, what="goalui goal completes") is not None,
+          "accepting the completion completes the goal")
+    check(completion_block_count() == 1,
+          "the accept path leaves exactly one review-kind block behind")
+    conn = db()
+    stragglers = conn.execute(
+        "select count(*) from task_blocks where task_id = ? and answer is null",
+        (run_task,)).fetchone()[0]
+    conn.close()
+    check(stragglers == 0,
+          "no unanswered straggler rows survive the completion")
+
 
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
                  "allocation", "lifecycle", "answers", "push", "goals",