浏览代码

Merge branch 'goal/selfhosted-fixes' of clanker/clanker-saddle into web-stack

Billy Barrow 1 月之前
父节点
当前提交
8c92bb6720
共有 41 个文件被更改,包括 2867 次插入 和 218 次删除
  1. 85 11
      README.md
  2. 19 0
      meson.build
  3. 2 2
      src/Api/SaddleApi.vala
  4. 17 1
      src/Data/Entities.vala
  5. 40 0
      src/Data/Migrations/M0002_ForceWake.vala
  6. 25 1
      src/Services/AllocationService.vala
  7. 92 4
      src/Services/GoalService.vala
  8. 177 9
      src/Services/PushService.vala
  9. 29 22
      src/Services/TaskService.vala
  10. 7 2
      src/actions/CapabilityActions.vala
  11. 114 29
      src/actions/GoalActions.vala
  12. 14 0
      src/actions/ProfileActions.vala
  13. 65 0
      src/actions/PushActions.vala
  14. 96 23
      src/actions/TaskActions.vala
  15. 352 0
      src/actions/UserActions.vala
  16. 31 3
      src/entrypoints/GoalDetailEntrypoint.vala
  17. 85 13
      src/entrypoints/HomeEntrypoint.vala
  18. 64 0
      src/entrypoints/ProfileEntrypoint.vala
  19. 11 2
      src/entrypoints/SaddlesEntrypoint.vala
  20. 22 8
      src/entrypoints/TaskDetailEntrypoint.vala
  21. 11 7
      src/entrypoints/TaskTerminalEntrypoint.vala
  22. 123 0
      src/entrypoints/UsersEntrypoint.vala
  23. 14 0
      src/main.vala
  24. 12 0
      src/pages/app.css
  25. 39 3
      src/pages/goal_detail.html
  26. 127 12
      src/pages/home.html
  27. 113 0
      src/pages/main.html
  28. 38 0
      src/pages/profile.html
  29. 17 11
      src/pages/saddles.html
  30. 44 9
      src/pages/task_detail.html
  31. 31 30
      src/pages/tasks.html
  32. 93 0
      src/pages/users.html
  33. 2 1
      test/bundles/__init__.py
  34. 58 0
      test/bundles/allocation.py
  35. 195 0
      test/bundles/forcewake.py
  36. 98 8
      test/bundles/goals.py
  37. 82 0
      test/bundles/goalui.py
  38. 126 6
      test/bundles/pages.py
  39. 144 0
      test/bundles/push.py
  40. 151 0
      test/bundles/users.py
  41. 2 1
      test/harness.py

+ 85 - 11
README.md

@@ -175,6 +175,10 @@ share the `/workspace` volume.
    the highest). The task is created in **draft**: edit every field,
    attach rules and capabilities, then press **Enqueue** (only the
    creator enqueues — humans their own, the goal agent its own).
+   Long-form fields edit in place: an **Edit** button beside the task
+   page's Mandate card (drafts only) swaps the rendered markdown for a
+   prefilled editor; the same pattern on a draft goal's Initial Brief
+   card edits title, brief and priority before you start it.
    Chain human work with **auto-enqueue after**: pick a predecessor
    from the global task list; the dependent enqueues itself when the
    predecessor completes (a failed or cancelled predecessor leaves it
@@ -183,7 +187,9 @@ share the `/workspace` volume.
    Registration mints a per-saddle token and delivers it to the daemon's
    `POST /register` (with this app's base URL); the daemon then asks
    for work whenever idle-awake and sleeps on a 204 answer, so a ready
-   task reaches it within one dispatcher tick (wake → ask → allocate).
+   task reaches it within one dispatcher tick (wake → ask → allocate). The saddles page
+   renders the daemon's `ssh_public_key` in a dark panel with the text
+   colour lifted for readable contrast.
 
 **Goals** (`/goals`) are the orchestrated path: write the Initial Brief
 and a branch name, start it, and a goal agent investigates (asking
@@ -197,6 +203,30 @@ transitions) and `POST /api/saddle/chunks` (raw PTY batches) — every
 `/api/saddle/*` call is authenticated by the per-saddle bearer token
 (removing the saddle revokes it).
 
+## Users, roles and your profile
+
+Two roles decide what a logged-in user can do (the account model is
+Spry's; this app surfaces it):
+
+- **user** — manages projects, tasks, goals and rules.
+- **admin** — additionally manages **users** (`/users`), **saddles**
+  (register/remove, git identity, capability tags) and the
+  **capability catalogue**. Viewing `/saddles` stays open to every
+  logged-in user; the mutation controls are hidden and server-refused
+  for non-admins.
+
+`/users` (admin-only) lists accounts newest-first with a role badge
+and enabled state: create users, edit profile fields, reset passwords,
+enable/disable, grant/revoke admin, delete. Guards keep you from
+locking yourself out: you cannot delete or disable your own account,
+cannot revoke your own admin permission, and the last enabled admin
+cannot be removed.
+
+Every logged-in user has **`/profile`** (linked beside the logout
+button in the nav corner): shows your account details and changes your
+own password — current, new, confirm. Change the seeded admin's
+`change-me` password there before exposing the app.
+
 ## Monitoring agent progress
 
 - **`/tasks`** — live queue: status chips (`draft → queued → assigned →
@@ -205,6 +235,9 @@ transitions) and `POST /api/saddle/chunks` (raw PTY batches) — every
 - **`/tasks/{id}`** — the control room; it refreshes on every state
   pulse while a saddle works: comments thread (yours and the agent's),
   blocks, goal context, session history, and the agent's final summary.
+  Your mutations on this page (and the goal page's) answer with the
+  refreshed state slots themselves rather than a navigate or reload —
+  the page lands in its post-mutation shape in one round trip.
 - **`/tasks/{id}/terminal`** — the full bash session log: the raw PTY
   stream the daemon pushes (chunk batches land in `session_raw_log` and
   stream over SSE byte-exact), with the typed entry view as the fallback
@@ -215,7 +248,13 @@ transitions) and `POST /api/saddle/chunks` (raw PTY batches) — every
   supervision narrative and the completion review.
 - **`/`** — dashboard: the "needs your attention" pile (goal AND task
   blocks interleaved, newest-first) above the activity timeline, saddle
-  cards and queue summary.
+  cards and queue summary. The Queue card's **queued** figure counts
+  waiting-for-attention work: ready-to-dispatch queued tasks, parked
+  (suspended) tasks — suspended goal-agent runs included — and tasks
+  whose raised blocks are all answered and now wait for a saddle to
+  resume them; queued rows gated on an unfinished auto-enqueue
+  predecessor stay out (the predecessor itself is already counted).
+  **blocked** stays human-answerable rows only.
 
 ## Answering block questions
 
@@ -248,6 +287,27 @@ the task/its projects, or new project notes. The daemon appends it as
 a TURN on resume (prompt-cache friendly) instead of refreshing the
 system prompt; the member is absent when nothing changed.
 
+## Force Wake (human-triggered resume)
+
+A parked agent normally resumes only when the machinery owes it a wake
+(a block answered, an owned task's terminal transition). When a run
+parks with nothing pending — the goal agent suspends itself right
+after a typo'd enqueue, say — no wake ever fires and it would sit
+suspended forever. **Force wake** is the human lever. On an active
+goal whose run task is suspended, the goal page's lifecycle head
+offers a **Force wake** button (beside Pause/Resume); on a suspended
+plain task the task page offers the same, and on a suspended goal-run
+task row it routes to the same goal force-wake path — one lever, no
+duplicates. One press sets the flag; the next dispatcher pass makes
+the run offerable immediately (no coalesce wait), and the resumed
+assignment's `resume_notice` leads with an explicit line — "The user
+explicitly force-woke this goal's supervision — you were parked with
+no pending wake." — followed by the standard batched report, which
+names draft-status tasks as "draft — NOT enqueued" so a parked agent
+that typo'd an enqueue sees it immediately. The flag clears on the
+wake (one wake per press). Paused goals and non-suspended tasks hide
+the button and the API refuses them (400).
+
 ## Browser notifications (Web Push)
 
 The app speaks the real Web Push protocol (VAPID + RFC 8291 payload
@@ -261,14 +321,26 @@ file is the single home for the keys; delete the section to rotate
 To enable notifications in a browser: open the dashboard and press
 **Enable notifications** — the page requests permission, registers the
 service worker (`GET /sw.js`, scope `/`) and subscribes with the app's
-VAPID key. "Turn off notifications" reverses it. Per task, **Notify
-me** / **Don't notify me** (on the task page's status card) opts you
-into that task's first start, failure and completion; task questions,
-RFCs and review requests notify every subscribed user, and goal blocks
-plus goal success/failure ALWAYS notify every subscribed user. Clicking
-a notification opens the task's or goal's page.
-Delivery is best-effort: failed sends retry on the dispatcher tick and
-endpoints answering 410/404 are pruned automatically.
+VAPID key. "Turn off notifications" reverses it. The bell is
+**per-browser**: it compares THIS browser's subscription (by a
+SHA-256 fingerprint of its endpoint — the endpoint URL itself never
+leaves the server) against your subscribed browsers, so each browser
+shows its own state; when you are subscribed elsewhere it says so and
+still offers Enable here. **Send test notification** (visible when
+this browser is subscribed) pushes a real notification through the
+full protocol and reports the true per-endpoint result, so a broken
+setup surfaces its actual reason instead of silence. Per task,
+**Notify me** / **Don't notify me** (on the task page's status card)
+opts you into that task's first start, failure and completion; task
+questions, RFCs and review requests notify every subscribed user, and
+goal blocks plus goal success/failure ALWAYS notify every subscribed
+user. Clicking a notification opens the task's or goal's page.
+Delivery is best-effort and truthful: failed sends retry on the
+dispatcher tick (up to five attempts — after which the drop is logged
+loudly, never silently claimed as delivered), endpoints answering
+410/404 are pruned automatically, and other 4xx refusals (e.g. a VAPID
+rejection) keep the subscription row and retry, since the endpoint
+may recover.
 
 ## Rules and project notes
 
@@ -355,7 +427,9 @@ before it starts.
 `meson test` additionally runs `test/web_stack_test.py` — a
 self-contained suite (no daemon needed) that spawns the app in a scratch
 directory plus an in-process fake saddle daemon speaking the wire
-contract, and asserts the web-stack features: plan-review body hiding,
+contract (thirteen scenario bundles — pages, goalui, push, dispatch,
+answers, replay, subtasks, lineage, users, forcewake, goals, lifecycle,
+allocation), and asserts the web-stack features: plan-review body hiding,
 the effective `rules` array of assignments, project notes with the
 `/api/saddle/note` callback and the payload's `project_notes`/`callback`
 caps, the ask/allocation flip (concurrent asks race one winner, affinity

+ 19 - 0
meson.build

@@ -110,6 +110,18 @@ rules_page = custom_target('rules-page',
     command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'RulesPage', '--ns', 'ClankerSaddle', '@INPUT@'],
     depend_files: template_deps
 )
+users_page = custom_target('users-page',
+    input: 'src/pages/users.html',
+    output: 'UsersPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'UsersPage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
+profile_page = custom_target('profile-page',
+    input: 'src/pages/profile.html',
+    output: 'ProfilePage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'ProfilePage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
 # spry:pages-end
 
 # =============================================================================
@@ -147,6 +159,7 @@ app_sources = files(
     'src/Format.vala',
     'src/Data/Entities.vala',
     'src/Data/Migrations/M0001_Initial.vala',
+    'src/Data/Migrations/M0002_ForceWake.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/GoalService.vala',
@@ -172,6 +185,8 @@ app_sources = files(
     'src/actions/SaddleActions.vala',
     'src/actions/RuleActions.vala',
     'src/actions/CapabilityActions.vala',
+    'src/actions/UserActions.vala',
+    'src/actions/ProfileActions.vala',
     'src/actions/PushActions.vala',
     'src/entrypoints/HomeEntrypoint.vala',
     'src/entrypoints/LoginEntrypoint.vala',
@@ -182,6 +197,8 @@ app_sources = files(
     'src/entrypoints/GoalDetailEntrypoint.vala',
     'src/entrypoints/SaddlesEntrypoint.vala',
     'src/entrypoints/RulesEntrypoint.vala',
+    'src/entrypoints/UsersEntrypoint.vala',
+    'src/entrypoints/ProfileEntrypoint.vala',
     # spry:sources-end
 )
 
@@ -202,6 +219,8 @@ app_generated = [
     goal_detail_page,
     saddles_page,
     rules_page,
+    users_page,
+    profile_page,
     # spry:generated-end
 ]
 

+ 2 - 2
src/Api/SaddleApi.vala

@@ -477,7 +477,7 @@ namespace ClankerSaddle.Api {
                 }
                 if (((!)detail).task.goal_id != goal_id) {
                     return SaddleWire.json_error(400,
-                        @"Task $(task_id) does not belong to your goal");
+                        @"Task $(task_id) does not belong to your goal — nothing was deleted; `saddle tasks` lists this goal's tasks");
                 }
                 if (((!)detail).task.status != STATUS_DRAFT) {
                     return SaddleWire.json_error(400,
@@ -520,7 +520,7 @@ namespace ClankerSaddle.Api {
                 }
                 if (((!)detail).task.goal_id != goal_id) {
                     return SaddleWire.json_error(400,
-                        @"Task $(task_id) does not belong to your goal");
+                        @"Task $(task_id) does not belong to your goal — nothing was enqueued; `saddle tasks` lists this goal's tasks");
                 }
                 yield tasks.enqueue_task(task_id, CREATOR_GOAL);
                 return SaddleWire.json_ok(new Json.Object());

+ 17 - 1
src/Data/Entities.vala

@@ -74,6 +74,13 @@ namespace ClankerSaddle.Data {
         public DateTime? started_at { get; set; default = null; }
         public DateTime? completed_at { get; set; default = null; }
         public DateTime? suspended_at { get; set; default = null; }
+        /**
+         * Goal 4 task 1 (Force Wake): the human pressed Force wake on
+         * the task page — this suspended plain task becomes
+         * parked-offerable again with the explicit wake notice.
+         * Consumed (cleared) by the allocation that resumes it.
+         */
+        public bool force_wake { get; set; default = false; }
         /** The goal this task belongs to (0 = standalone). */
         public int64 goal_id { get; set; default = 0; }
         /** "task" (a worker) or "goal_run" (the goal agent's own run). */
@@ -100,6 +107,7 @@ namespace ClankerSaddle.Data {
                 .column<DateTime?>("started_at", o => o.started_at, (o, v) => o.started_at = v)
                 .column<DateTime?>("completed_at", o => o.completed_at, (o, v) => o.completed_at = v)
                 .column<DateTime?>("suspended_at", o => o.suspended_at, (o, v) => o.suspended_at = v)
+                .column<bool>("force_wake", o => o.force_wake, (o, v) => o.force_wake = v)
                 .column<int64?>("goal_id", o => o.goal_id, (o, v) => o.goal_id = v ?? 0)
                 .column<string>("kind", o => o.kind, (o, v) => o.kind = v)
                 .column<string>("mode", o => o.mode, (o, v) => o.mode = v)
@@ -538,6 +546,13 @@ namespace ClankerSaddle.Data {
         public DateTime? started_at { get; set; default = null; }
         public DateTime? completed_at { get; set; default = null; }
         public DateTime? paused_at { get; set; default = null; }
+        /**
+         * Goal 4 task 1 (Force Wake): the human pressed Force wake on
+         * the goal page — the parked run becomes offerable immediately
+         * and the resume notice leads with the explicit wake line.
+         * Consumed (cleared) by the resume that carries it.
+         */
+        public bool force_wake { get; set; default = false; }
 
         public static void entity_mapping(EntityMapperBuilder<Goal> cfg) {
             cfg.table("goals")
@@ -556,7 +571,8 @@ namespace ClankerSaddle.Data {
                 .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v)
                 .column<DateTime?>("started_at", o => o.started_at, (o, v) => o.started_at = v)
                 .column<DateTime?>("completed_at", o => o.completed_at, (o, v) => o.completed_at = v)
-                .column<DateTime?>("paused_at", o => o.paused_at, (o, v) => o.paused_at = v);
+                .column<DateTime?>("paused_at", o => o.paused_at, (o, v) => o.paused_at = v)
+                .column<bool>("force_wake", o => o.force_wake, (o, v) => o.force_wake = v);
         }
     }
 

+ 40 - 0
src/Data/Migrations/M0002_ForceWake.vala

@@ -0,0 +1,40 @@
+using InvercargillSql.Migrations;
+
+namespace ClankerSaddle.Data.Migrations {
+
+    /**
+     * Goal 4 task 1 (Force Wake): the human-triggered resume flags.
+     * `goals.force_wake` — set by the goal page's Force wake button,
+     * consumed by GoalService.resume_due/take_resume_notice (one wake
+     * per press). `tasks.force_wake` — the plain-task twin: a
+     * suspended worker task flagged by the task page's Force wake,
+     * consumed by AllocationService.offerable_tasks when it becomes
+     * parked-offerable again. Both default false.
+     */
+    public class M0002_ForceWake : Migration {
+
+        public override string name { get { return "Force-wake flags on goals and tasks"; } }
+        public override uint64 serial { get { return 2; } }
+        public override string migration_namespace { get { return "clanker-saddle"; } }
+
+        public override void up(MigrationBuilder b) {
+            b.alter_table("goals", (t) => {
+                t.add_column<bool>("force_wake")
+                    .default_value<bool>(false);
+            });
+            b.alter_table("tasks", (t) => {
+                t.add_column<bool>("force_wake")
+                    .default_value<bool>(false);
+            });
+        }
+
+        public override void down(MigrationBuilder b) {
+            b.alter_table("goals", (t) => {
+                t.drop_column("force_wake");
+            });
+            b.alter_table("tasks", (t) => {
+                t.drop_column("force_wake");
+            });
+        }
+    }
+}

+ 25 - 1
src/Services/AllocationService.vala

@@ -143,6 +143,16 @@ namespace ClankerSaddle.Services {
                         rows.add((!)parked);
                     }
                 }
+                // Goal 4 task 1 (Force Wake): a human pressed Force
+                // wake on a suspended PLAIN task — it becomes offerable
+                // on its affine saddle immediately, with the explicit
+                // wake notice riding the resuming assignment.
+                if (run.kind == TASK_KIND_TASK && run.force_wake) {
+                    var parked = yield parked_offerable(run, true);
+                    if (parked != null) {
+                        rows.add((!)parked);
+                    }
+                }
             }
             foreach (var task in yield tasks.get_ready_tasks()) {
                 rows.add(new OfferableTask() {
@@ -384,7 +394,7 @@ namespace ClankerSaddle.Services {
                 // round (plan approval, rules, notes) rides the
                 // resume as a turn — null when nothing changed.
                 context_update = yield context_update_for(task, claim.session_id, projects);
-                if (claim.suspended_resume) {
+                if (claim.suspended_resume && task.kind == TASK_KIND_GOAL_RUN) {
                     // Plan 12: the goal agent's batched report — every
                     // plan task's status plus the completion reports
                     // that landed while it was parked. Consumes the
@@ -392,6 +402,20 @@ namespace ClankerSaddle.Services {
                     // and ends the parking window.
                     resume_notice = yield goals.take_resume_notice(task.goal_id);
                     yield tasks.clear_suspension(task.id);
+                } else if (claim.suspended_resume) {
+                    // Goal 4 task 1 (Force Wake): a force-woke PLAIN
+                    // task's resume carries the explicit human-wake
+                    // line and consumes the flag (one wake per press).
+                    // Without the flag this arm is unreachable —
+                    // offerable_tasks only offers plain suspended
+                    // tasks under force_wake.
+                    resume_notice = "The user explicitly force-woke this parked task — resume where you left off.";
+                    var woke_detail = yield tasks.get_task(task.id);
+                    if (woke_detail != null) {
+                        ((!)woke_detail).task.force_wake = false;
+                        yield db.update_async<TaskEntity>(((!)woke_detail).task);
+                    }
+                    yield tasks.clear_suspension(task.id);
                 } else {
                     // Answering = allocating (plan 11): the verdict rides
                     // the resuming assignment's resume_notice, injected as

+ 92 - 4
src/Services/GoalService.vala

@@ -231,11 +231,13 @@ namespace ClankerSaddle.Services {
             }
         }
 
-        /** The goal's links, oldest first. */
+        /** The goal's links, newest first (the list-page consistency
+         * sweep). The RULE/effective-context consumers only iterate,
+         * so the flip is invisible to them. */
         public async ImmutableLot<GoalLink> list_links(int64 goal_id) throws Error {
             return yield db.query<GoalLink>()
                 .where(expr("goal_id == $0", elem<int64?>(goal_id)))
-                .order_by(expr("id"))
+                .order_by_desc(expr("id"))
                 .materialise_async();
         }
 
@@ -288,6 +290,43 @@ namespace ClankerSaddle.Services {
             return goal;
         }
 
+
+        /**
+         * Edits a DRAFT goal's title, brief and priority (the brief-card
+         * editor, plan QoL task 12): a draft is the only phase with an
+         * edit surface — starting locks the brief (the run task's
+         * mandate is seeded from it) and every later phase reads it as
+         * history. Each argument is optional (null = leave unchanged),
+         * mirroring {@link TaskService.edit_task}.
+         */
+        public async Goal edit_draft_goal(int64 goal_id, string? title, string? brief,
+                int64? priority) throws Error {
+            var goal = yield require_goal(goal_id);
+            if (((!)goal).status != GOAL_DRAFT) {
+                throw new Error(Quark.from_string("clanker-saddle"), 72,
+                    @"Only draft goals can be edited — $(((!)goal).status) goals have a locked brief");
+            }
+            if (title != null && ((!)title).strip().length == 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 73,
+                    "A goal title is required");
+            }
+            if (brief != null && ((!)brief).strip().length == 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 74,
+                    "A goal brief is required");
+            }
+            if (title != null) {
+                ((!)goal).title = ((!)title).strip();
+            }
+            if (brief != null) {
+                ((!)goal).brief = (!)brief;
+            }
+            if (priority != null) {
+                ((!)goal).priority = ((!)priority).clamp(1, 9);
+            }
+            yield db.update_async<Goal>((!)goal);
+            return goal;
+        }
+
         /**
          * Pauses an active goal: supervision freezes — no goal-agent
          * resumes are scheduled while paused; task events keep
@@ -324,6 +363,32 @@ namespace ClankerSaddle.Services {
             return goal;
         }
 
+        /**
+         * Goal 4 task 1 (Force Wake): the human-triggered resume —
+         * sets {@link Goal.force_wake} so the parked run becomes
+         * offerable immediately (resume_due skips the coalescing
+         * window and the terminal-transition requirement) and the
+         * resume notice leads with the explicit wake line. One wake
+         * per press: take_resume_notice clears the flag.
+         */
+        public async Goal force_wake_goal(int64 goal_id) throws Error {
+            var goal = yield require_goal(goal_id);
+            if (((!)goal).status != GOAL_ACTIVE) {
+                throw new Error(Quark.from_string("clanker-saddle"), 72,
+                    "Only active goals can be force-woke (paused goals freeze supervision — resume the goal instead)");
+            }
+            var run = yield goal_run_task(goal_id);
+            if (run == null || ((!)run).status != STATUS_SUSPENDED) {
+                throw new Error(Quark.from_string("clanker-saddle"), 73,
+                    "The goal agent is not parked — there is nothing to force-woke");
+            }
+            ((!)goal).force_wake = true;
+            yield db.update_async<Goal>((!)goal);
+            yield pulse.bump(0, "goal force-woke");
+            return goal;
+        }
+
+
         /**
          * Cancels a goal: the cascade closes every one of its tasks —
          * non-terminal ones are cancelled (the running goal agent
@@ -699,7 +764,7 @@ namespace ClankerSaddle.Services {
 
         /**
          * The goal's comments (plan 15 C2): the goal-run task's
-         * `task_comments` rows, oldest first — the ONE thread.
+         * `task_comments` rows, newest first — the ONE thread.
          * Empty while the goal has no run task.
          */
         public async ImmutableLot<TaskComment> list_comments(int64 goal_id) throws Error {
@@ -712,7 +777,7 @@ namespace ClankerSaddle.Services {
 
         /**
          * A goal's blocks (plan 15 C1): the goal-run task's
-         * `task_blocks` rows, oldest first — the run task is the
+         * `task_blocks` rows, newest 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.
          */
@@ -966,6 +1031,14 @@ namespace ClankerSaddle.Services {
             if (goal == null || ((!)goal).status != GOAL_ACTIVE) {
                 return false;
             }
+            // Goal 4 task 1 (Force Wake): the human pressed Force
+            // wake — offerable immediately, skipping BOTH the
+            // coalescing window and the terminal-transition
+            // requirement (a parked agent with no pending wake —
+            // the goal-2 stall class — still resumes).
+            if (((!)goal).force_wake) {
+                return true;
+            }
             DateTime? watermark = ((!)goal).last_terminal_watermark;
             DateTime? oldest = null;
             // ONLY owned-task terminal transitions resume the parked
@@ -1017,7 +1090,15 @@ namespace ClankerSaddle.Services {
                 since = ((!)run).suspended_at;
             }
 
+            // Goal 4 task 1 (Force Wake): a human-pressed wake
+            // leads with the explicit line, then the standard batched
+            // report. The flag is CONSUMED by this notice (one wake
+            // per press) — cleared below with the watermark write.
+            bool woken = goal.force_wake;
             var report = new StringBuilder();
+            if (woken) {
+                report.append("The user explicitly force-woke this goal's supervision — you were parked with no pending wake.\n\n");
+            }
             report.append("Goal update — resuming your parked session:\n\n");
             report.append("## Task statuses\n");
             var finished = new Vector<TaskEntity>();
@@ -1036,6 +1117,12 @@ namespace ClankerSaddle.Services {
                 } else if (task.status == STATUS_FAILED) {
                     report.append_printf("- #%s %s — failed: %s\n", task.id.to_string(),
                         task.title, notice_lead(task));
+                } else if (task.status == STATUS_DRAFT) {
+                    // Goal 4 task 1: the goal-2 stall class — a draft
+                    // that was never enqueued produces no transitions,
+                    // so a force-woke agent must see it named as such.
+                    report.append_printf("- #%s %s — draft (NOT enqueued — run `saddle task enqueue %s` or delete it)\n",
+                        task.id.to_string(), task.title, task.id.to_string());
                 } else {
                     report.append_printf("- #%s %s — %s\n", task.id.to_string(), task.title, task.status);
                 }
@@ -1062,6 +1149,7 @@ namespace ClankerSaddle.Services {
                 }
             }
             ((!)goal).last_terminal_watermark = consumed;
+            ((!)goal).force_wake = false;
             yield db.update_async<Goal>(((!)goal));
             return report.str;
         }

+ 177 - 9
src/Services/PushService.vala

@@ -46,6 +46,21 @@ namespace ClankerSaddle.Services {
         public string url { get; set; default = ""; }
     }
 
+    /**
+     * The roll-up of one {@link PushService.send_test} probe: counts
+     * per outcome plus the first failure's detail for the toast.
+     * `first_failure` names the HTTP status ("HTTP 401") or the
+     * transport error text — the dashboard's test button toasts the
+     * REAL end-to-end result, never a blind success.
+     */
+    public class PushTestReport : Object {
+        public int delivered { get; set; }
+        public int failed { get; set; }
+        /** Endpoints that answered 404/410 — pruned during the probe. */
+        public int pruned { get; set; }
+        public string first_failure { get; set; default = ""; }
+    }
+
     /**
      * Web Push delivery (plan 11 task E): real Web Push protocol over
      * VAPID. The keypair is generated once and persisted in the config
@@ -92,6 +107,8 @@ namespace ClankerSaddle.Services {
             public string auth = "";
             public string payload = "";
             public int attempts = 0;
+            /** The last HTTP status the endpoint answered (0 = transport error). */
+            public int last_status = 0;
         }
 
         /** A queued notification before target resolution. */
@@ -301,6 +318,21 @@ namespace ClankerSaddle.Services {
             };
         }
 
+        /**
+         * The "Send test notification" payload (per-browser bell): a
+         * REAL Web Push through the full RFC 8291 + VAPID path whose
+         * arrival the human can see — the end-to-end probe that was
+         * missing entirely.
+         */
+        public static PushNotification test_notification() {
+            return new PushNotification() {
+                title = "Test notification",
+                body = "A live Web Push from Clanker Saddle — if you can"
+                    + " read this, delivery works.",
+                url = "/"
+            };
+        }
+
         // ------------------------------------------------------------------
         // Enqueue + delivery
         // ------------------------------------------------------------------
@@ -358,7 +390,15 @@ namespace ClankerSaddle.Services {
                     if (job == null) {
                         break;
                     }
-                    yield deliver((!)job);
+                    bool requeued = yield deliver((!)job);
+                    if (requeued) {
+                        // A failed send goes back for a LATER pass:
+                        // draining in place would burn every attempt in
+                        // one tight loop. The dispatcher tick (or the
+                        // next enqueue's flush) picks the job back up —
+                        // that spacing IS the retry backoff.
+                        break;
+                    }
                 }
             } finally {
                 flushing = false;
@@ -432,7 +472,15 @@ namespace ClankerSaddle.Services {
             GONE
         }
 
-        private async void deliver(SendJob job) {
+        /**
+         * Applies one job's outcome: DELIVERED drops it, GONE prunes
+         * the subscription row, anything else requeues it for a later
+         * tick. Returns true when the job was requeued — the flush
+         * drain then STOPS (the retry waits for the next tick instead
+         * of burning every attempt back-to-back), which is what makes
+         * MAX_ATTEMPTS a real backoff rather than one hot loop.
+         */
+        private async bool deliver(SendJob job) {
             SendOutcome outcome = SendOutcome.RETRY;
             try {
                 outcome = yield deliver_once(job);
@@ -440,29 +488,87 @@ namespace ClankerSaddle.Services {
                 outcome = SendOutcome.RETRY;
             }
             if (outcome == SendOutcome.DELIVERED) {
-                return;
+                return false;
             }
             if (outcome == SendOutcome.GONE) {
                 yield prune_subscription(job.endpoint);
-                return;
+                return false;
             }
             job.attempts++;
             if (job.attempts >= MAX_ATTEMPTS) {
-                warning("[ClankerSaddle] Push to %s gave up after %d attempts",
+                // Giving up is loud on purpose: a silently dropped
+                // notification looks exactly like a delivered one.
+                warning("[ClankerSaddle] Push to %s gave up after %d attempts"
+                    + " — notification dropped undelivered",
                     job.endpoint, job.attempts);
-                return;
+                return false;
             }
             queue_lock.lock();
             queued.push_tail(job);
             queue_lock.unlock();
+            return true;
+        }
+
+        /**
+         * The dashboard's "Send test notification" probe (per-browser
+         * bell, shown only when THIS browser is subscribed): one REAL
+         * delivery attempt to each of the user's endpoints through the
+         * same {@link deliver_once} crypto + VAPID path the queue
+         * uses, awaited inline so the action can report per-endpoint
+         * outcomes. The shared queue's flush is single-flight and
+         * fire-and-forget — its outcomes are unobservable from a
+         * request — so the probe resolves and sends synchronously
+         * instead of enqueueing. A refused endpoint is reported rather
+         * than retried here (the queue's own backoff still governs
+         * real events); 404/410 endpoints are pruned exactly as the
+         * queue would, so the probe also heals dead rows on demand.
+         */
+        public async PushTestReport send_test(OrmSession db, int64 user_id) throws Error {
+            var report = new PushTestReport();
+            var jobs = new GLib.List<SendJob>();
+            foreach (var row in yield db.query<PushSubscription>()
+                    .where(expr("user_id == $0", elem<int64?>(user_id)))
+                    .materialise_async()) {
+                jobs.append(new SendJob() {
+                    endpoint = row.endpoint,
+                    p256dh = row.p256dh,
+                    auth = row.auth,
+                    payload = payload_json(test_notification())
+                });
+            }
+            foreach (var job in jobs) {
+                SendOutcome outcome = SendOutcome.RETRY;
+                string problem = "";
+                try {
+                    outcome = yield deliver_once(job);
+                } catch (Error e) {
+                    problem = e.message;
+                }
+                if (outcome == SendOutcome.DELIVERED) {
+                    report.delivered++;
+                    continue;
+                }
+                if (outcome == SendOutcome.GONE) {
+                    report.pruned++;
+                    yield prune_subscription(job.endpoint);
+                    continue;
+                }
+                report.failed++;
+                if (report.first_failure.length == 0) {
+                    report.first_failure = job.last_status > 0
+                        ? @"HTTP $(job.last_status)" : problem;
+                }
+            }
+            return report;
         }
 
         /**
          * One Web Push protocol POST: RFC 8291 aes128gcm body, ES256
          * VAPID JWT in the Authorization header. Transport errors and
          * 5xx throw (retry); 404/410 report {@link SendOutcome.GONE}
-         * (prune); any other status counts as delivered (the push
-         * service owns delivery from here).
+         * (prune); any OTHER 4xx is a refusal — logged and retried,
+         * never silently delivered; remaining 2xx/3xx counts as
+         * delivered (the push service owns delivery from here).
          */
         private async SendOutcome deliver_once(SendJob job) throws Error {
             uint8[] client_public = base64url_decode(job.p256dh);
@@ -558,9 +664,25 @@ namespace ClankerSaddle.Services {
                 return SendOutcome.GONE;
             }
             if (message.status_code >= 500) {
+                job.last_status = (int) message.status_code;
                 throw new Error(Quark.from_string("clanker-saddle"), 65,
                     @"push service answered HTTP $(message.status_code)");
             }
+            if (message.status_code >= 400) {
+                // 401/403 (VAPID rejected) and every other 4xx that is
+                // not the terminal 404/410 handled above: the push
+                // service did NOT accept the message. Truthful
+                // delivery — log it with the endpoint and status,
+                // retry on later ticks, give up loudly after
+                // MAX_ATTEMPTS, and never count it as delivered. The
+                // subscription row stays: the endpoint may recover
+                // (e.g. a VAPID key rotation heals on a later send).
+                job.last_status = (int) message.status_code;
+                warning("[ClankerSaddle] Push endpoint %s refused delivery:"
+                    + " HTTP %d — not delivered, retrying",
+                    job.endpoint, (int) message.status_code);
+                return SendOutcome.RETRY;
+            }
             return SendOutcome.DELIVERED;
         }
 
@@ -684,7 +806,12 @@ namespace ClankerSaddle.Services {
             }
         }
 
-        /** True when the user holds any subscription (the bell's state). */
+        /**
+         * True when the user holds any subscription. Convenience only
+         * since the per-browser bell: the authoritative per-browser
+         * state is {@link user_endpoint_fingerprints} matched
+         * client-side against the browser's own subscription.
+         */
         public async bool user_subscribed(OrmSession db, int64 user_id) throws Error {
             foreach (var row in yield db.query<PushSubscription>()
                     .where(expr("user_id == $0", elem<int64?>(user_id)))
@@ -694,6 +821,47 @@ namespace ClankerSaddle.Services {
             return false;
         }
 
+        /**
+         * This user's subscription endpoint fingerprints, newest
+         * first (the per-browser bell state): each entry is
+         * {@link endpoint_fingerprint} of one stored endpoint — never
+         * the endpoint itself, which stays server-private. The browser
+         * fingerprints its own pushManager subscription and looks it
+         * up in the list: present => THIS browser is subscribed (show
+         * "Turn off"), absent => "Enable" (+ a count of the others).
+         */
+        public async string[] user_endpoint_fingerprints(OrmSession db, int64 user_id) throws Error {
+            var fingerprints = new string[0];
+            foreach (var row in yield db.query<PushSubscription>()
+                    .where(expr("user_id == $0", elem<int64?>(user_id)))
+                    .order_by_desc(expr("created_at"))
+                    .order_by_desc(expr("id"))
+                    .materialise_async()) {
+                fingerprints += endpoint_fingerprint(row.endpoint);
+            }
+            return fingerprints;
+        }
+
+        /**
+         * A privacy-safe handle for one endpoint: the first 16 hex
+         * chars of its SHA-256 — enough for a browser to recognise its
+         * own subscription, useless for reconstructing the URL. The
+         * client mirrors this with crypto.subtle (both need a secure
+         * context, so any browser that can subscribe can compare).
+         */
+        public static string endpoint_fingerprint(string endpoint) {
+            var checksum = new Checksum(ChecksumType.SHA256);
+            size_t digest_len = 32;
+            var digest = new uint8[32];
+            checksum.update(endpoint.data, endpoint.length);
+            checksum.get_digest(digest, ref digest_len);
+            var builder = new StringBuilder();
+            for (var index = 0; index < 8; index++) {
+                builder.append_printf("%02x", digest[index]);
+            }
+            return builder.str;
+        }
+
         /** True when the user holds a "Notify me" pref for the task. */
         public async bool has_notify_pref(OrmSession db, int64 user_id,
                 int64 task_id) throws Error {

+ 29 - 22
src/Services/TaskService.vala

@@ -252,7 +252,7 @@ namespace ClankerSaddle.Services {
                 progress = yield list_progress(id),
                 sessions = yield db.query<Session>()
                     .where(expr("task_id == $0", elem<int64?>(id)))
-                    .order_by(expr("id"))
+                    .order_by_desc(expr("id"))
                     .materialise_async()
             };
         }
@@ -333,9 +333,12 @@ namespace ClankerSaddle.Services {
         }
 
         /**
-         * Lists tasks, optionally filtered by status and/or project, ordered
-         * by priority (1 first) then creation time — filtered and sorted
-         * in the query, one materialisation.
+         * Lists tasks, optionally filtered by status and/or project,
+         * newest first (creation descending, id as the same-second
+         * tiebreak) — the list-page consistency sweep: every table
+         * leads with the most recent row. Priority stays a visible
+         * column, no longer a sort key; the dispatcher's candidate
+         * order ({@link get_ready_tasks}) keeps priority-first.
          */
         public async ImmutableLot<TaskEntity> list_tasks(string? status, int64? project_id) throws Error {
             var query = db.query<TaskEntity>();
@@ -346,24 +349,24 @@ namespace ClankerSaddle.Services {
                 query.where(expr("project_id == $0", elem<int64?>(project_id)));
             }
             return yield query
-                .order_by(expr("priority"))
-                .order_by(expr("created_at"))
+                .order_by_desc(expr("created_at"))
+                .order_by_desc(expr("id"))
                 .materialise_async();
         }
 
         /**
          * Every standalone human-workable task row (kind task, no
-         * goal), any status, ordered like {@link list_tasks} — the
-         * auto-enqueue-after dropdowns' option list, queried for
-         * exactly the rows they show instead of every task in the
-         * table.
+         * goal), any status, ordered like {@link list_tasks}
+         * (newest first) — the auto-enqueue-after dropdowns' option
+         * list, queried for exactly the rows they show instead of
+         * every task in the table.
          */
         public async ImmutableLot<TaskEntity> list_standalone_tasks() throws Error {
             return yield db.query<TaskEntity>()
                 .where(expr("kind == $0 && goal_id == $1",
                     elem(TASK_KIND_TASK), elem<int64?>(0)))
-                .order_by(expr("priority"))
-                .order_by(expr("created_at"))
+                .order_by_desc(expr("created_at"))
+                .order_by_desc(expr("id"))
                 .materialise_async();
         }
 
@@ -519,11 +522,11 @@ namespace ClankerSaddle.Services {
             return task;
         }
 
-        /** Every task under `goal_id` (both kinds), oldest first. */
+        /** Every task under `goal_id` (both kinds), newest first. */
         public async ImmutableLot<TaskEntity> list_tasks_for_goal(int64 goal_id) throws Error {
             return yield db.query<TaskEntity>()
                 .where(expr("goal_id == $0", elem<int64?>(goal_id)))
-                .order_by(expr("id"))
+                .order_by_desc(expr("id"))
                 .materialise_async();
         }
 
@@ -1542,29 +1545,33 @@ namespace ClankerSaddle.Services {
             return rows.to_immutable_buffer();
         }
 
-        /** Comments on a task, oldest first. */
+        /** Comments on a task, newest first — the thread leads with
+         * the latest remark (the list-page consistency sweep). */
         public async ImmutableLot<TaskComment> list_comments(int64 task_id) throws Error {
             return yield db.query<TaskComment>()
                 .where(expr("task_id == $0", elem<int64?>(task_id)))
-                .order_by(expr("id"))
+                .order_by_desc(expr("id"))
                 .materialise_async();
         }
 
-        /** Blocks on a task, oldest first. */
+        /** Blocks on a task, newest first — the blocking sections
+         * lead with the most recent question, RFC or review (the
+         * list-page consistency sweep). */
         public async ImmutableLot<TaskBlock> list_blocks(int64 task_id) throws Error {
             return yield db.query<TaskBlock>()
                 .where(expr("task_id == $0", elem<int64?>(task_id)))
-                .order_by(expr("id"))
+                .order_by_desc(expr("id"))
                 .materialise_async();
         }
 
-        /** The task's newest block, or null when it has none. */
+        /** The task's newest block, or null when it has none — the
+         * FIRST row (list_blocks is newest-first since the ordering
+         * sweep; the walker keeps the semantics explicit). */
         public async TaskBlock? newest_block(int64 task_id) throws Error {
-            TaskBlock? newest = null;
             foreach (var block in yield list_blocks(task_id)) {
-                newest = block;
+                return block;
             }
-            return newest;
+            return null;
         }
 
         /**

+ 7 - 2
src/actions/CapabilityActions.vala

@@ -33,7 +33,10 @@ namespace ClankerSaddle {
         private UserService users = inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
-            var guard = yield SpryAuth.require_login(directives(), request, users);
+            // The roles decision: capabilities are admin-managed (the
+            // catalogue feeds the dispatcher's routing).
+            var guard = yield SpryAuth.require_permission(directives(), request, users,
+                SpryAuth.ADMIN_PERMISSION, "/login");
             if (guard != null) {
                 return (!)guard;
             }
@@ -66,7 +69,9 @@ namespace ClankerSaddle {
         protected override Type private_type { get { return typeof(CapabilityActionPrivate); } }
 
         public override async DirectiveBuilder handle() throws GLib.Error {
-            var guard = yield SpryAuth.require_login(directives(), request, users);
+            // The roles decision: capabilities are admin-managed.
+            var guard = yield SpryAuth.require_permission(directives(), request, users,
+                SpryAuth.ADMIN_PERMISSION, "/login");
             if (guard != null) {
                 return (!)guard;
             }

+ 114 - 29
src/actions/GoalActions.vala

@@ -34,6 +34,38 @@ namespace ClankerSaddle {
         }
     }
 
+    /**
+     * Shared base for goal-page actions that answer with the
+     * refreshed goal slot instead of a navigate (goal 2 QoL): owns
+     * the service set {@link GoalState.build} needs, so a mutation
+     * rebuilds the page view with ONE call - {@link goal_detail_slot}
+     * - instead of every action copy-pasting the injections and the
+     * build chain. Success paths answer notify(...).set(slot); flows
+     * whose result genuinely IS navigation keep navigating.
+     */
+    public abstract class GoalDetailRefreshingAction : SealedGoalAction {
+
+        protected GoalService goal_service = inject<GoalService>();
+        protected TaskService task_service = inject<TaskService>();
+        protected ProjectService project_service = inject<ProjectService>();
+        protected SaddleService saddle_service = inject<SaddleService>();
+        protected SaddlePollState poll_state = inject<SaddlePollState>();
+        protected RuleService rule_service = inject<RuleService>();
+        protected InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
+        protected UserService users = inject<UserService>();
+
+        /**
+         * Rebuilds the goal detail slot from current data - the ONE
+         * call a mutation needs to answer with refreshed state (the
+         * same build the page's refresh action runs).
+         */
+        protected async Statum.Slot goal_detail_slot(int64 goal_id) throws GLib.Error {
+            var ctx = new GoalViewContext(goal_service, task_service, project_service,
+                db, action_registry, saddle_service, poll_state, rule_service);
+            return yield GoalState.build(goal_id, ctx, state_service);
+        }
+    }
+
     /**
      * Creates a goal from the goals form (project, title, brief and a
      * required branch name; base branch optional — the project's
@@ -100,12 +132,56 @@ namespace ClankerSaddle {
         }
     }
 
-    /** Pauses an active goal: supervision freezes, events queue. */
-    public class PauseGoalAction : SealedGoalAction {
+    /**
+     * Saves a DRAFT goal's title, brief and priority (the brief-card
+     * editor, plan QoL task 12): draft is the only phase with an edit
+     * surface — starting locks the brief and seeds the run task's
+     * mandate from it. Each field is optional (absent = leave
+     * unchanged), mirroring {@link UpdateTaskAction}'s carried-field
+     * semantics, and {@link GoalService.edit_draft_goal} enforces the
+     * draft-only guard server-side.
+     */
+    public class UpdateDraftGoalAction : SealedGoalAction {
 
         private GoalService goals = inject<GoalService>();
         private UserService users = inject<UserService>();
 
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 goal_id = sealed_goal_id();
+            if (goal_id <= 0) {
+                return directives().navigate("/goals");
+            }
+
+            try {
+                int64? priority = null;
+                string priority_field = Forms.field(request, "priority");
+                if (priority_field.length > 0) {
+                    priority = Forms.integer(request, "priority", 3);
+                }
+                var goal = yield goals.edit_draft_goal(goal_id,
+                    Forms.optional(request, "title"),
+                    request.form != null && request.form.get_field("brief") != null
+                        ? Forms.field_raw(request, "brief") : null,
+                    priority);
+                return directives().notify("success", "Goal saved")
+                    .navigate(@"/goals/$(goal.id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not save the goal: $(e.message)")
+                    .navigate(@"/goals/$(goal_id)");
+            }
+        }
+    }
+
+    /** Pauses an active goal: supervision freezes, events queue. */
+    public class PauseGoalAction : GoalDetailRefreshingAction {
+
+        private GoalService goals = inject<GoalService>();
+
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users);
             if (guard != null) {
@@ -114,7 +190,7 @@ namespace ClankerSaddle {
             try {
                 var goal = yield goals.pause_goal(sealed_goal_id());
                 return directives().notify("success", @"Goal '$(goal.title)' paused")
-                    .navigate(@"/goals/$(goal.id)");
+                    .set(yield goal_detail_slot(goal.id));
             } catch (Error e) {
                 return directives().notify("error", @"Could not pause the goal: $(e.message)").navigate("/goals");
             }
@@ -122,10 +198,9 @@ namespace ClankerSaddle {
     }
 
     /** Resumes a paused goal: queued events coalesce into the next wake. */
-    public class ResumeGoalAction : SealedGoalAction {
+    public class ResumeGoalAction : GoalDetailRefreshingAction {
 
         private GoalService goals = inject<GoalService>();
-        private UserService users = inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users);
@@ -135,13 +210,38 @@ namespace ClankerSaddle {
             try {
                 var goal = yield goals.resume_goal(sealed_goal_id());
                 return directives().notify("success", @"Goal '$(goal.title)' resumed")
-                    .navigate(@"/goals/$(goal.id)");
+                    .set(yield goal_detail_slot(goal.id));
             } catch (Error e) {
                 return directives().notify("error", @"Could not resume the goal: $(e.message)").navigate("/goals");
             }
         }
     }
 
+    /**
+     * Goal 4 task 1 (Force Wake): the human recovery lever for a
+     * parked goal agent — sets the goal force-wake flag so the run
+     * becomes offerable immediately with the explicit wake notice.
+     * Guards live in {@link GoalService.force_wake_goal} (refuses
+     * non-active goals and non-parked runs); the page only authors
+     * the button when they hold.
+     */
+    public class GoalForceWakeAction : GoalDetailRefreshingAction {
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+            try {
+                var goal = yield goal_service.force_wake_goal(sealed_goal_id());
+                return directives().notify("success", @"Goal '$(goal.title)' force-woke — the parked run resumes on its saddle")
+                    .set(yield goal_detail_slot(goal.id));
+            } catch (Error e) {
+                return directives().notify("error", @"Could not force-woke the goal: $(e.message)").navigate("/goals");
+            }
+        }
+    }
+
     /**
      * Cancels a goal: the cascade cancels every non-terminal task under
      * it (drafts are deleted); live daemons hosting its running tasks
@@ -272,10 +372,9 @@ namespace ClankerSaddle {
      * Posts a comment on a goal (the goal page's thread; a selection
      * quote rides along when the popover opened one).
      */
-    public class GoalCommentAction : SealedGoalAction {
+    public class GoalCommentAction : GoalDetailRefreshingAction {
 
         private GoalService goals = inject<GoalService>();
-        private UserService users = inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users);
@@ -295,7 +394,7 @@ namespace ClankerSaddle {
                 yield goals.add_human_comment(sealed_goal_id(), author, body,
                     Forms.optional(request, "quote"));
                 return directives().notify("success", "Comment added")
-                    .navigate(@"/goals/$(sealed_goal_id())");
+                    .set(yield goal_detail_slot(sealed_goal_id()));
             } catch (Error e) {
                 return directives().notify("error", @"Could not add the comment: $(e.message)")
                     .navigate(@"/goals/$(sealed_goal_id())");
@@ -394,16 +493,7 @@ namespace ClankerSaddle {
      * re-hydrates the goal slot with fresh narrative/task/review
      * state, exactly like the task page's refresh.
      */
-    public class RefreshGoalDataAction : SealedGoalAction {
-
-        private GoalService goal_service = inject<GoalService>();
-        private TaskService task_service = inject<TaskService>();
-        private ProjectService project_service = inject<ProjectService>();
-        private SaddleService saddle_service = inject<SaddleService>();
-        private SaddlePollState poll_state = inject<SaddlePollState>();
-        private RuleService rule_service = inject<RuleService>();
-        private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
-        private UserService users = inject<UserService>();
+    public class RefreshGoalDataAction : GoalDetailRefreshingAction {
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users);
@@ -416,18 +506,14 @@ namespace ClankerSaddle {
                 return directives();
             }
 
-            var ctx = new GoalViewContext(goal_service, task_service, project_service,
-                db, action_registry, saddle_service, poll_state, rule_service);
-            var state = yield GoalState.build(goal_id, ctx, state_service);
-            return directives().set(state);
+            return directives().set(yield goal_detail_slot(goal_id));
         }
     }
 
     /** Adds a project link (co-developed/reference) to a goal. */
-    public class AddGoalLinkAction : SealedGoalAction {
+    public class AddGoalLinkAction : GoalDetailRefreshingAction {
 
         private GoalService goals = inject<GoalService>();
-        private UserService users = inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users);
@@ -439,7 +525,7 @@ namespace ClankerSaddle {
                     Forms.integer(request, "linked_project_id", 0),
                     Forms.field(request, "relationship"));
                 return directives().notify("success", "Project linked")
-                    .navigate(@"/goals/$(sealed_goal_id())");
+                    .set(yield goal_detail_slot(sealed_goal_id()));
             } catch (Error e) {
                 return directives().notify("error", @"Could not link the project: $(e.message)")
                     .navigate(@"/goals/$(sealed_goal_id())");
@@ -448,10 +534,9 @@ namespace ClankerSaddle {
     }
 
     /** Removes the goal link identified by `link_id`. */
-    public class RemoveGoalLinkAction : SealedGoalAction {
+    public class RemoveGoalLinkAction : GoalDetailRefreshingAction {
 
         private GoalService goals = inject<GoalService>();
-        private UserService users = inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users);
@@ -462,7 +547,7 @@ namespace ClankerSaddle {
             try {
                 yield goals.remove_link(link_id);
                 return directives().notify("success", "Link removed")
-                    .navigate(@"/goals/$(sealed_goal_id())");
+                    .set(yield goal_detail_slot(sealed_goal_id()));
             } catch (Error e) {
                 return directives().notify("error", @"Could not remove the link: $(e.message)")
                     .navigate(@"/goals/$(sealed_goal_id())");

+ 14 - 0
src/actions/ProfileActions.vala

@@ -0,0 +1,14 @@
+using Statum;
+
+namespace ClankerSaddle {
+
+    /**
+     * The shipped Spry change-own-password action with this app's
+     * post-change destination (the profile page) — the form fields and
+     * verification (current/new/confirm) are Spry's.
+     */
+    public class ChangeMyPasswordAction : Spry.Actions.ChangePasswordAction {
+
+        protected override string login_uri { get { return "/login"; } }
+    }
+}

+ 65 - 0
src/actions/PushActions.vala

@@ -83,6 +83,71 @@ namespace ClankerSaddle {
      * and completion on top of the global block events every
      * push-subscribed user gets.
      */
+    /**
+     * The bell's "Send test notification" probe (per-browser bell):
+     * queues a REAL Web Push — full RFC 8291 + VAPID path — targeted at
+     * the signed-in user's own subscriptions ({@link
+     * PushService.send_test}), then reports the per-endpoint outcome
+     * through the notify so the UI toasts the truth. This is the
+     * missing end-to-end check: a green light here proves keys,
+     * crypto, the push service and the browser's subscription all
+     * line up, and a failure surfaces its actual reason instead of
+     * silence. Also refreshes the dashboard slot so the bell's
+     * endpoint fingerprints (and a pruned dead endpoint) are current.
+     */
+    public class PushTestAction : StatumAction {
+
+        private TaskService task_service = inject<TaskService>();
+        private SaddleService saddle_service = inject<SaddleService>();
+        private TimelineService timeline_service = inject<TimelineService>();
+        private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
+        private PushService push = inject<PushService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var me = SpryAuth.current(request);
+            try {
+                var report = yield push.send_test(db, me.user_id);
+                string message;
+                string kind;
+                if (report.failed == 0 && report.pruned == 0) {
+                    kind = "success";
+                    message = report.delivered > 1
+                        ? @"Test notification sent to $(report.delivered) browsers"
+                        : "Test notification sent";
+                } else if (report.delivered > 0) {
+                    kind = "error";
+                    message = @"Test notification reached $(report.delivered) browser(s)"
+                        + @" but $(report.failed) failed";
+                    if (report.first_failure.length > 0) {
+                        message += @" ($(report.first_failure))";
+                    }
+                } else {
+                    kind = "error";
+                    message = "Test notification failed";
+                    if (report.first_failure.length > 0) {
+                        message += @": $(report.first_failure)";
+                    }
+                    if (report.pruned > 0) {
+                        message += @" — $(report.pruned) dead endpoint(s) removed";
+                    }
+                }
+                var state = yield DashboardState.build(state_service, task_service,
+                    saddle_service, timeline_service, db, action_registry,
+                    push, me.user_id);
+                return directives().notify(kind, message).set(state);
+            } catch (Error e) {
+                return directives().notify("error",
+                    @"Could not send the test notification: $(e.message)");
+            }
+        }
+    }
+
     public class ToggleTaskNotifyAction : SealedTaskAction {
 
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();

+ 96 - 23
src/actions/TaskActions.vala

@@ -3,6 +3,7 @@ using Spry;
 using Spry.Authentication;
 using Statum;
 using ClankerSaddle.Services;
+using ClankerSaddle.Data;
 
 namespace ClankerSaddle {
 
@@ -33,6 +34,43 @@ namespace ClankerSaddle {
         }
     }
 
+    /**
+     * Shared base for task-page actions that answer with the
+     * refreshed task slot instead of a navigate (goal 2 QoL): owns
+     * the service set {@link TaskDetailState.build} needs, so a
+     * mutation rebuilds the page view with ONE call -
+     * {@link task_detail_slot} - instead of every action copy-pasting
+     * the injections and the build chain. Success paths answer
+     * notify(...).set(slot); flows whose result genuinely IS
+     * navigation (create -> detail, delete -> list) keep navigating.
+     */
+    public abstract class TaskDetailRefreshingAction : SealedTaskAction {
+
+        protected TaskService task_service = inject<TaskService>();
+        protected ProjectService project_service = inject<ProjectService>();
+        protected SaddleService saddle_service = inject<SaddleService>();
+        protected RuleService rule_service = inject<RuleService>();
+        protected CapabilityService capability_service = inject<CapabilityService>();
+        protected SaddlePollState poll_state = inject<SaddlePollState>();
+        protected PushService push_service = inject<PushService>();
+        protected InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
+        protected UserService users = inject<UserService>();
+
+        /**
+         * Rebuilds the task detail slot from current data - the ONE
+         * call a mutation needs to answer with refreshed state (the
+         * same build the page's refresh action runs).
+         */
+        protected async Statum.Slot task_detail_slot(int64 task_id) throws GLib.Error {
+            var me = SpryAuth.current(request);
+            return yield TaskDetailState.build(state_service, task_service,
+                project_service, saddle_service, rule_service, capability_service,
+                action_registry, poll_state, push_service, db,
+                me != null ? ((!)me).user_id : 0, task_id);
+        }
+    }
+
+
     /**
      * Creates a task from the queue form (project and title required).
      * The task lands in DRAFT — the human Enqueue action queues it;
@@ -118,10 +156,7 @@ namespace ClankerSaddle {
      * states priority only, in terminal states nothing. Status is not
      * part of the form — Enqueue/Cancel/agent transitions own it.
      */
-    public class UpdateTaskAction : SealedTaskAction {
-
-        private TaskService tasks = inject<TaskService>();
-        private UserService users = inject<UserService>();
+    public class UpdateTaskAction : TaskDetailRefreshingAction {
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users);
@@ -156,7 +191,7 @@ namespace ClankerSaddle {
                 // form actually carried the description field (the
                 // priority-only edit must not blank it — an absent
                 // field is "leave unchanged", same as title).
-                yield tasks.edit_task(task_id,
+                yield task_service.edit_task(task_id,
                     Forms.optional(request, "title"),
                     request.form != null && request.form.get_field("description") != null
                         ? Forms.field(request, "description") : null,
@@ -167,7 +202,7 @@ namespace ClankerSaddle {
                         ? Forms.field(request, "branch_from") : null,
                     mode.length > 0 ? mode : null,
                     enqueue_after);
-                return directives().notify("success", "Task saved").navigate(@"/tasks/$(task_id)");
+                return directives().notify("success", "Task saved").set(yield task_detail_slot(task_id));
             } catch (Error e) {
                 return directives().notify("error", @"Could not save the task: $(e.message)")
                     .navigate(@"/tasks/$(task_id)");
@@ -475,6 +510,59 @@ namespace ClankerSaddle {
         }
     }
 
+    /**
+     * Goal 4 task 1 (Force Wake): the human recovery lever for a
+     * parked PLAIN task — sets the task force-wake flag so the
+     * allocation offers it again on its affine saddle with the
+     * explicit wake notice. A suspended GOAL RUN task routes to the
+     * SAME goal force-wake (GoalForceWakeAction) — one path, no
+     * duplicate semantics; the page authors that variant's button.
+     */
+    public class TaskForceWakeAction : TaskDetailRefreshingAction {
+
+        private GoalService goal_service = inject<GoalService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 task_id = sealed_task_id();
+            if (task_id <= 0) {
+                return directives().navigate("/tasks");
+            }
+
+            try {
+                var detail = yield task_service.get_task(task_id);
+                if (detail == null) {
+                    return directives().notify("error", @"No task exists with id $(task_id)").navigate("/tasks");
+                }
+                var task = ((!)detail).task;
+                if (task.kind == TASK_KIND_GOAL_RUN) {
+                    // One path: the run's wake is the GOAL's wake.
+                    yield goal_service.force_wake_goal(task.goal_id);
+                    return directives().notify("success",
+                            "Goal run force-woke — the parked agent resumes on its saddle")
+                        .navigate(@"/goals/$(task.goal_id)");
+                }
+                if (task.status != STATUS_SUSPENDED) {
+                    return directives().notify("error",
+                            "Only parked tasks can be force-woke — nothing was woken")
+                        .navigate(@"/tasks/$(task_id)");
+                }
+                task.force_wake = true;
+                yield db.update_async<TaskEntity>(task);
+                return directives().notify("success",
+                        "Task force-woke — the parked run resumes on its saddle")
+                    .set(yield task_detail_slot(task_id));
+            } catch (Error e) {
+                return directives().notify("error", @"Could not force-woke the task: $(e.message)")
+                    .navigate(@"/tasks/$(task_id)");
+            }
+        }
+    }
+
     /**
      * Manually requeues a task stuck working/blocked on a saddle that
      * went offline (the automatic staleness probe may never fire when
@@ -516,17 +604,7 @@ namespace ClankerSaddle {
      * blocks, output-driven status changes and completions. Navigates
      * nowhere; a failed reload leaves the client's state untouched.
      */
-    public class RefreshTaskDataAction : SealedTaskAction {
-
-        private TaskService task_service = inject<TaskService>();
-        private ProjectService project_service = inject<ProjectService>();
-        private SaddleService saddle_service = inject<SaddleService>();
-        private RuleService rule_service = inject<RuleService>();
-        private CapabilityService capability_service = inject<CapabilityService>();
-        private SaddlePollState poll_state = inject<SaddlePollState>();
-        private PushService push_service = inject<PushService>();
-        private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
-        private UserService users = inject<UserService>();
+    public class RefreshTaskDataAction : TaskDetailRefreshingAction {
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users);
@@ -539,12 +617,7 @@ namespace ClankerSaddle {
                 return directives();
             }
 
-            var me = SpryAuth.current(request);
-            var state = yield TaskDetailState.build(state_service, task_service,
-                project_service, saddle_service, rule_service, capability_service,
-                action_registry, poll_state, push_service, db,
-                me != null ? ((!)me).user_id : 0, task_id);
-            return directives().set(state);
+            return directives().set(yield task_detail_slot(task_id));
         }
     }
 }

+ 352 - 0
src/actions/UserActions.vala

@@ -0,0 +1,352 @@
+using Inversion;
+using Spry;
+using Spry.Authentication;
+using Statum;
+
+namespace ClankerSaddle {
+
+    /**
+     * Sealed target identity carried by the user-management row actions.
+     * `enabled` is only read by SetManagedUserEnabledAction: the
+     * entrypoint seals the row's NEXT state, so the enable/disable button
+     * needs no form field and can never go stale against the table.
+     */
+    public class ManagedUserPrivate : Object {
+        public int64 user_id { get; set; }
+        public bool enabled { get; set; }
+    }
+
+    /** Maps a shared profile-field rejection onto its message. */
+    private static string profile_rejection_message(Spry.Actions.ProfileValidation.Rejection rejection) {
+        switch (rejection) {
+            case Spry.Actions.ProfileValidation.Rejection.USERNAME_TOO_SHORT:
+                return "Username must be at least 3 characters";
+            case Spry.Actions.ProfileValidation.Rejection.INVALID_EMAIL:
+                return "Please enter a valid email address";
+            case Spry.Actions.ProfileValidation.Rejection.NAME_REQUIRED:
+                return "Please enter a first and last name";
+            case Spry.Actions.ProfileValidation.Rejection.DATE_OF_BIRTH_REQUIRED:
+                return "Please enter a date of birth";
+            case Spry.Actions.ProfileValidation.Rejection.INVALID_DATE_OF_BIRTH:
+            default:
+                return "Please enter a valid date of birth (YYYY-MM-DD)";
+        }
+    }
+
+    /**
+     * Shared plumbing for the users page's row actions: the admin guard
+     * (authoritative, re-validated against the user store) and the
+     * last-enabled-admin protection the shipped Spry actions lack.
+     *
+     * Spry's ready-made user management actions cover the store calls but
+     * none of the deployment-safety guards this app's roles decision
+     * needs (self-disable, self-demotion, the last working admin), so the
+     * page owns its actions and reuses UserService + ProfileValidation
+     * underneath.
+     */
+    public abstract class ManagedUserAction : TypedStatumAction<ManagedUserPrivate> {
+
+        protected UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(ManagedUserPrivate); } }
+
+        /** Admin-only: every users page mutation is an administrator's. */
+        protected async DirectiveBuilder? admin_guard() throws GLib.Error {
+            return yield SpryAuth.require_permission(directives(), request, users,
+                SpryAuth.ADMIN_PERMISSION, "/login");
+        }
+
+        /** The sealed target id (the user_id form field is the fallback). */
+        protected int64 target_id() {
+            var ctx = request_private;
+            return ctx != null ? ((!)ctx).user_id : Forms.integer(request, "user_id", 0);
+        }
+
+        /**
+         * True when `user_id` is an enabled admin and no other enabled
+         * admin exists: deleting, disabling or demoting them would leave
+         * the deployment without a working administrator.
+         *
+         * In practice the acting admin is themselves an enabled admin, so
+         * this only bites on the self-directed paths and any future
+         * non-page caller that skips the per-row self guards. Defence in
+         * depth, not the primary gate.
+         */
+        protected async bool is_last_enabled_admin(int64 user_id) throws GLib.Error {
+            bool target_is_enabled_admin = false;
+            int enabled_admins = 0;
+            foreach (var user in yield users.list_users(0, 1000)) {
+                if (!user.enabled
+                        || !SpryAuth.permission_matches(user.permissions.to_array(),
+                                SpryAuth.ADMIN_PERMISSION)) {
+                    continue;
+                }
+                enabled_admins++;
+                if (user.id == user_id) {
+                    target_is_enabled_admin = true;
+                }
+            }
+            return target_is_enabled_admin && enabled_admins <= 1;
+        }
+    }
+
+    /**
+     * Creates an account from the users page's New user form (admin
+     * only): the profile fields are validated by Spry's shared
+     * ProfileValidation (identical rules to self-registration), and the
+     * optional `admin` checkbox grants the admin permission on create.
+     */
+    public class CreateUserAction : StatumAction {
+
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_permission(directives(), request, users,
+                SpryAuth.ADMIN_PERMISSION, "/login");
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            // Passwords are compared and stored verbatim (never trimmed).
+            string password = Forms.field_raw(request, "password");
+            string confirm = Forms.field_raw(request, "confirm_password");
+
+            Spry.Actions.ProfileValidation.Rejection rejection;
+            var fields = Spry.Actions.ProfileValidation.validate(request, out rejection);
+            if (fields == null) {
+                return directives().notify("error", profile_rejection_message(rejection)).navigate("/users");
+            }
+            var profile = (!)fields;
+
+            if (password.length < 6) {
+                return directives().notify("error", "Password must be at least 6 characters").navigate("/users");
+            }
+            if (password != confirm) {
+                return directives().notify("error", "Passwords do not match").navigate("/users");
+            }
+
+            bool admin = Forms.field(request, "admin").length > 0;
+            try {
+                var user = yield users.register_user(profile.username, profile.email,
+                    profile.forename, profile.surname, profile.date_of_birth, password, true);
+                if (admin) {
+                    yield users.set_user_permission(user.id, SpryAuth.ADMIN_PERMISSION);
+                }
+                return directives().notify("success", @"User '$(user.username)' created").navigate("/users");
+            } catch (Error e) {
+                var duplicate = Spry.Actions.ProfileValidation.duplicate_message(e,
+                    "Username already exists. Please choose another.",
+                    "Email already registered.",
+                    "A user with this information already exists.");
+                if (duplicate != null) {
+                    return directives().notify("error", (!)duplicate).navigate("/users");
+                }
+                return directives().notify("error", @"Could not create the user: $(e.message)").navigate("/users");
+            }
+        }
+    }
+
+    /**
+     * Saves the profile fields of a managed user (the edit expander on
+     * the users page): validated profile fields, the `enabled` checkbox
+     * and an optional `new_password` (the reset-password path; empty
+     * keeps the current hash). Refuses the invoking admin's own account
+     * (self-edits belong on /profile) and disabling the last enabled
+     * admin.
+     */
+    public class AlterManagedUserAction : ManagedUserAction {
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield admin_guard();
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 user_id = target_id();
+            if (user_id <= 0) {
+                return directives().notify("error", "A user is required").navigate("/users");
+            }
+            if (SpryAuth.current(request).user_id == user_id) {
+                return directives().notify("error",
+                        "You cannot edit your own account here. Use your profile page.").navigate("/users");
+            }
+
+            // An absent checkbox means disabled (HTML omits unchecked
+            // boxes), matching Spry's own AlterUserAction semantics.
+            bool enabled = Forms.field(request, "enabled").length > 0;
+            if (!enabled && yield is_last_enabled_admin(user_id)) {
+                return directives().notify("error",
+                        "Cannot disable the last enabled administrator").navigate("/users");
+            }
+
+            string new_password = Forms.field_raw(request, "new_password");
+            if (new_password.length > 0 && new_password.length < 6) {
+                return directives().notify("error", "Password must be at least 6 characters").navigate("/users");
+            }
+
+            Spry.Actions.ProfileValidation.Rejection rejection;
+            var fields = Spry.Actions.ProfileValidation.validate(request, out rejection);
+            if (fields == null) {
+                return directives().notify("error", profile_rejection_message(rejection)).navigate("/users");
+            }
+            var profile = (!)fields;
+
+            try {
+                yield users.alter_user(user_id, profile.username, profile.email,
+                    profile.forename, profile.surname, profile.date_of_birth, enabled);
+                if (new_password.length > 0) {
+                    yield users.set_password(user_id, new_password.dup());
+                }
+                return directives().notify("success", "User saved").navigate("/users");
+            } catch (Error e) {
+                var duplicate = Spry.Actions.ProfileValidation.duplicate_message(e,
+                    "Username already exists. Please choose another.",
+                    "Email already registered.",
+                    "A user with this information already exists.");
+                if (duplicate != null) {
+                    return directives().notify("error", (!)duplicate).navigate("/users");
+                }
+                return directives().notify("error", @"Could not save the user: $(e.message)").navigate("/users");
+            }
+        }
+    }
+
+    /**
+     * Enables or disables a managed user; the next state arrives sealed
+     * (the entrypoint computed it from the row), so the button cannot
+     * race the table. Self-disable and disabling the last enabled admin
+     * are refused.
+     */
+    public class SetManagedUserEnabledAction : ManagedUserAction {
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield admin_guard();
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            if (ctx == null) {
+                return directives().navigate("/users");
+            }
+            int64 user_id = ((!)ctx).user_id;
+            bool enable = ((!)ctx).enabled;
+
+            if (SpryAuth.current(request).user_id == user_id) {
+                return directives().notify("error", "You cannot disable your own account").navigate("/users");
+            }
+            if (!enable && yield is_last_enabled_admin(user_id)) {
+                return directives().notify("error",
+                        "Cannot disable the last enabled administrator").navigate("/users");
+            }
+
+            try {
+                yield users.set_user_enabled(user_id, enable);
+                return directives().notify("success", enable ? "User enabled" : "User disabled")
+                    .navigate("/users");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not update the user: $(e.message)").navigate("/users");
+            }
+        }
+    }
+
+    /**
+     * Grants the admin permission (the role badge on the users page).
+     * Idempotent via UserService; granting admin is always safe. The
+     * guards live on the destructive actions.
+     */
+    public class GrantUserAdminAction : ManagedUserAction {
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield admin_guard();
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 user_id = target_id();
+            if (user_id <= 0) {
+                return directives().notify("error", "A user is required").navigate("/users");
+            }
+
+            try {
+                var target = yield users.get_user(user_id);
+                if (target == null) {
+                    return directives().notify("error", @"No user exists with id $(user_id)").navigate("/users");
+                }
+                yield users.set_user_permission(user_id, SpryAuth.ADMIN_PERMISSION);
+                return directives().notify("success", "Admin granted").navigate("/users");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not grant admin: $(e.message)").navigate("/users");
+            }
+        }
+    }
+
+    /**
+     * Revokes the admin permission. Refuses the invoking admin's own
+     * account (they would lock themselves out of the page mid-session)
+     * and demoting the last enabled admin.
+     */
+    public class RevokeUserAdminAction : ManagedUserAction {
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield admin_guard();
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 user_id = target_id();
+            if (user_id <= 0) {
+                return directives().notify("error", "A user is required").navigate("/users");
+            }
+            if (SpryAuth.current(request).user_id == user_id) {
+                return directives().notify("error",
+                        "You cannot revoke your own admin permission").navigate("/users");
+            }
+            if (yield is_last_enabled_admin(user_id)) {
+                return directives().notify("error",
+                        "Cannot demote the last enabled administrator").navigate("/users");
+            }
+
+            try {
+                yield users.remove_user_permission(user_id, SpryAuth.ADMIN_PERMISSION);
+                return directives().notify("success", "Admin revoked").navigate("/users");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not revoke admin: $(e.message)").navigate("/users");
+            }
+        }
+    }
+
+    /**
+     * Deletes a managed user (their permission rows go with them, via
+     * UserService). Refuses the invoking admin's own account and
+     * deleting the last enabled admin.
+     */
+    public class DeleteManagedUserAction : ManagedUserAction {
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield admin_guard();
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 user_id = target_id();
+            if (user_id <= 0) {
+                return directives().notify("error", "A user is required").navigate("/users");
+            }
+            if (SpryAuth.current(request).user_id == user_id) {
+                return directives().notify("error", "You cannot delete your own account").navigate("/users");
+            }
+            if (yield is_last_enabled_admin(user_id)) {
+                return directives().notify("error",
+                        "Cannot delete the last enabled administrator").navigate("/users");
+            }
+
+            try {
+                yield users.delete_user(user_id);
+                return directives().notify("success", "User deleted").navigate("/users");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not delete the user: $(e.message)").navigate("/users");
+            }
+        }
+    }
+}

+ 31 - 3
src/entrypoints/GoalDetailEntrypoint.vala

@@ -186,6 +186,12 @@ namespace ClankerSaddle {
          */
         public string current_activity { get; set; default = ""; }
         public Statum.Model.ActionDto? start { get; set; }
+        /**
+         * The draft brief-card editor's save (title/brief/priority):
+         * authored ONLY while draft — its presence IS the page's
+         * edit-draft gate, so a started goal renders no editor at all.
+         */
+        public Statum.Model.ActionDto? update { get; set; }
         public Statum.Model.ActionDto? pause { get; set; }
         public Statum.Model.ActionDto? resume { get; set; }
         public Statum.Model.ActionDto? cancel { get; set; }
@@ -199,6 +205,13 @@ namespace ClankerSaddle {
          * — requeue it; the next dispatch restores the workspace.
          */
         public Statum.Model.ActionDto? requeue_run { get; set; }
+        /**
+         * Goal 4 task 1 (Force Wake): the human recovery lever —
+         * authored only while the goal is active and its run task is
+         * parked (suspended); pressing it sets the goal force-wake
+         * flag (GoalService.force_wake_goal holds the guards).
+         */
+        public Statum.Model.ActionDto? force_wake { get; set; }
         public Statum.Model.ActionDto? comment { get; set; }
         public Statum.Model.ActionDto? add_link { get; set; }
         /** The sealed GET refresh action (live pulse rehydration). */
@@ -431,6 +444,7 @@ namespace ClankerSaddle {
                 pub.add_link = ((!)ctx.actions).author_private<AddGoalLinkAction, GoalActionPrivate>(sealed);
                 if (goal.status == GOAL_DRAFT) {
                     pub.start = ((!)ctx.actions).author_private<StartGoalAction, GoalActionPrivate>(sealed);
+                    pub.update = ((!)ctx.actions).author_private<UpdateDraftGoalAction, GoalActionPrivate>(sealed);
                 }
                 if (goal.status == GOAL_ACTIVE) {
                     pub.pause = ((!)ctx.actions).author_private<PauseGoalAction, GoalActionPrivate>(sealed);
@@ -529,6 +543,7 @@ namespace ClankerSaddle {
                 pub.add_link = ((!)ctx.actions).author_private<AddGoalLinkAction, GoalActionPrivate>(sealed);
                 if (goal.status == GOAL_DRAFT) {
                     pub.start = ((!)ctx.actions).author_private<StartGoalAction, GoalActionPrivate>(sealed);
+                    pub.update = ((!)ctx.actions).author_private<UpdateDraftGoalAction, GoalActionPrivate>(sealed);
                 }
                 if (goal.status == GOAL_ACTIVE) {
                     pub.pause = ((!)ctx.actions).author_private<PauseGoalAction, GoalActionPrivate>(sealed);
@@ -607,15 +622,18 @@ namespace ClankerSaddle {
                 if (pending_plan) {
                     plan_pending_open = true;
                 }
+                // Newest-first: list_blocks leads with the latest row,
+                // so appending preserves that order in the view.
                 view.blocks.add(block_view);
             }
             pub.completion_pending = completion_pending;
             // Plan 15 C4: the current (unanswered) question/RFC blocks
             // render in full; the historic answered ones fold behind
             // the answered-blocks expander (the task page's pattern).
-            var block_array = view.blocks.to_array();
-            for (var index = block_array.length - 1; index >= 0; index--) {
-                var block_view = (GoalBlockView) block_array[index];
+            // The rows already arrive newest-first ({@link
+            // TaskService.list_blocks} since the ordering sweep), so a
+            // straight walk keeps both groups newest-first.
+            foreach (var block_view in view.blocks) {
                 if (block_view.answered) {
                     view.blocks_answered.add(block_view);
                 } else if (block_view.is_plan_review) {
@@ -709,6 +727,16 @@ namespace ClankerSaddle {
                         || run_status == STATUS_BLOCKED
                         || run_status == STATUS_REVIEW_REQUESTED
                         || run_status == STATUS_SUSPENDED;
+                    // Goal 4 task 1: Force wake — the run is parked
+                    // on a LIVE saddle under an ACTIVE goal (the
+                    // paused-goal recovery is Resume, the dead-saddle
+                    // one Requeue-to-resume; Force wake covers the
+                    // parked-with-no-pending-wake stall, goal 2).
+                    if (goal.status == GOAL_ACTIVE && run_status == STATUS_SUSPENDED) {
+                        pub.force_wake = ((!)ctx.actions)
+                            .author_private<GoalForceWakeAction, GoalActionPrivate>(
+                                new GoalActionPrivate() { goal_id = goal.id });
+                    }
                     if (!pub.terminal && stale_or_parked) {
                         // Plan 16 D4: a parked round's session is
                         // closed 'parked' — the newest record (open or

+ 85 - 13
src/entrypoints/HomeEntrypoint.vala

@@ -83,6 +83,18 @@ namespace ClankerSaddle {
     /** Public data of the dashboard's `dashboard` slot. */
     public class DashboardPublic : Object {
         public int64 draft_count { get; set; }
+        /**
+         * Waiting-for-attention work (goal 2 QoL): everything the
+         * system owes a dispatch or resume to — ready-to-dispatch
+         * queued tasks (the ready_count subset) + parked work awaiting
+         * resume (suspended tasks, suspended goal-agent runs included)
+         * + tasks whose raised blocks are all answered and now wait
+         * for a saddle to pick the resume up (these leave
+         * blocked_count, which stays human-answerable rows only).
+         * Raw queued rows gated on an unfinished auto-enqueue
+         * predecessor are deliberately OUT — sequenced future work,
+         * the predecessor itself is already counted.
+         */
         public int64 queued_count { get; set; }
         public int64 ready_count { get; set; }
         public int64 active_count { get; set; }
@@ -97,6 +109,8 @@ namespace ClankerSaddle {
         public bool push_subscribed { get; set; }
         public Statum.Model.ActionDto? push_subscribe { get; set; }
         public Statum.Model.ActionDto? push_unsubscribe { get; set; }
+        /** Queues a REAL test push to this user (the bell's probe). */
+        public Statum.Model.ActionDto? push_test { get; set; }
     }
 
     /**
@@ -123,12 +137,27 @@ namespace ClankerSaddle {
             var pub = new DashboardPublic();
             var titles = new Dictionary<string, string>();
             var all_tasks = yield tasks.list_tasks(null, null);
+            // The ready-to-dispatch subset of the queued rows (see the
+            // queued_count docblock), keyed by id so the status loop can
+            // tell a dispatchable queued task from one still gated on
+            // its auto-enqueue predecessor.
+            var ready_ids = new HashSet<string>();
+            int64 ready_count = 0;
+            foreach (var ready in yield tasks.get_ready_tasks()) {
+                ready_ids.add(ready.id.to_string());
+                if (ready.kind != TASK_KIND_GOAL_RUN) {
+                    ready_count++;
+                }
+            }
+            pub.ready_count = ready_count;
             foreach (var task in all_tasks) {
                 titles.set(task.id.to_string(), task.title);
                 // Goal-agent runs are internal tasks — they never count
                 // against the human-facing queue summary (the goal page
-                // owns their visibility).
-                if (task.kind == TASK_KIND_GOAL_RUN) {
+                // owns their visibility) EXCEPT parked: a suspended run
+                // is goal work the system owes a resume, so it joins
+                // queued_count (goal 2 QoL).
+                if (task.kind == TASK_KIND_GOAL_RUN && task.status != STATUS_SUSPENDED) {
                     continue;
                 }
                 switch (task.status) {
@@ -136,15 +165,35 @@ namespace ClankerSaddle {
                         pub.draft_count++;
                         break;
                     case STATUS_QUEUED:
-                        pub.queued_count++;
+                        // Ready rows are the queued metric's core;
+                        // predecessor-gated rows are sequenced future
+                        // work the counters leave out (docblock above).
+                        if (ready_ids.has(task.id.to_string())) {
+                            pub.queued_count++;
+                        }
                         break;
                     case STATUS_ASSIGNED:
                     case STATUS_WORKING:
                         pub.active_count++;
                         break;
+                    case STATUS_SUSPENDED:
+                        // Parked awaiting resume (goal 2 QoL): a
+                        // suspended task waits on the system, not on a
+                        // human — it reads as waiting, not blocked.
+                        pub.queued_count++;
+                        break;
                     case STATUS_BLOCKED:
                     case STATUS_REVIEW_REQUESTED:
-                        pub.blocked_count++;
+                        // Goal 2 QoL: answered blocks mean the human
+                        // already acted — the task now waits for a
+                        // saddle to resume it, so it joins the queued
+                        // metric and leaves blocked_count (which stays
+                        // genuinely-needs-a-human rows).
+                        if (yield blocks_all_answered(tasks, task.id)) {
+                            pub.queued_count++;
+                        } else {
+                            pub.blocked_count++;
+                        }
                         break;
                     case STATUS_DONE:
                         pub.done_count++;
@@ -156,13 +205,6 @@ namespace ClankerSaddle {
                         break;
                 }
             }
-            int64 ready_count = 0;
-            foreach (var task in yield tasks.get_ready_tasks()) {
-                if (task.kind != TASK_KIND_GOAL_RUN) {
-                    ready_count++;
-                }
-            }
-            pub.ready_count = ready_count;
 
 
             var saddle_rows = new Vector<DashboardSaddleView>();
@@ -266,12 +308,24 @@ namespace ClankerSaddle {
                 pub.refresh = ((!)actions).author<RefreshDashboardAction>("GET");
                 pub.push_subscribe = ((!)actions).author<PushSubscribeAction>();
                 pub.push_unsubscribe = ((!)actions).author<PushUnsubscribeAction>();
+                pub.push_test = ((!)actions).author<PushTestAction>();
             }
-            // Web Push bell state (plan 11 task E): the VAPID key the
-            // browser needs + whether this user already subscribed.
+            // Web Push bell state (plan 11 task E; per-browser since
+            // the user-global flag lied to second browsers): the VAPID
+            // key the browser needs, whether this user subscribed
+            // anywhere (convenience), and this user's endpoint
+            // FINGERPRINTS — the client compares its own subscription's
+            // fingerprint against the list, so each browser shows its
+            // own truth.
             pub.push_vapid_key = PushService.vapid_public_key;
+            // Not a DashboardPublic property: GObjectMapping cannot
+            // serialise array-shaped values, so the fingerprint list
+            // rides the props dictionary like every other list here.
+            string[] push_fingerprints = new string[0];
             if (push != null && viewer_id > 0) {
                 pub.push_subscribed = yield ((!)push).user_subscribed(db, viewer_id);
+                push_fingerprints = yield ((!)push)
+                    .user_endpoint_fingerprints(db, viewer_id);
             }
 
             var activity = new Vector<DashboardActivityView>();
@@ -294,6 +348,7 @@ namespace ClankerSaddle {
 
             var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
             props["saddles"] = StateJson.list<DashboardSaddleView>(saddle_rows.to_immutable_buffer());
+            props["push_endpoints"] = StateJson.strings(push_fingerprints);
             props["activity"] = StateJson.list<DashboardActivityView>(activity.to_immutable_buffer());
             props["active_tasks"] = StateJson.list<DashboardActiveView>(active.to_immutable_buffer());
             props["pinned"] = StateJson.list<DashboardBlockView>(pinned_sorted.to_immutable_buffer());
@@ -305,6 +360,23 @@ namespace ClankerSaddle {
             });
         }
 
+        /**
+         * True when the task has at least one block and every one of
+         * them carries an answer (goal 2 QoL): the human already
+         * acted, the dispatcher's resume is the only thing owed. A
+         * task with no blocks at all is NOT answered-waiting — the
+         * counters leave such rows in blocked_count untouched.
+         */
+        private static async bool blocks_all_answered(TaskService tasks, int64 task_id) throws GLib.Error {
+            bool any = false;
+            foreach (var block in yield tasks.list_blocks(task_id)) {
+                any = true;
+                if (block.answer == null) {
+                    return false;
+                }
+            }
+            return any;
+        }
     }
 
     /**

+ 64 - 0
src/entrypoints/ProfileEntrypoint.vala

@@ -0,0 +1,64 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Spry;
+using Spry.Authentication;
+using Statum;
+
+namespace ClankerSaddle {
+
+    /** The logged-in user's own profile card on /profile. */
+    public class ProfileView : Object {
+        public string username { get; set; default = ""; }
+        public string email { get; set; default = ""; }
+        public string name_display { get; set; default = ""; }
+        public bool is_admin { get; set; }
+        public string created_display { get; set; default = ""; }
+        public Statum.Model.ActionDto change_password { get; set; }
+    }
+
+    /**
+     * Entrypoint for /profile: the logged-in user's own account (name,
+     * email, username, role) and the change-own-password form. Open to
+     * every logged-in user; the store row (not the signed login
+     * snapshot) is read so a renamed account shows its current profile.
+     */
+    public class ProfileEntrypoint : StatumEntrypoint {
+
+        private UserService users_service = Inversion.inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users_service);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var me = SpryAuth.current(request);
+            var user = yield users_service.get_user(me.user_id);
+            if (user == null) {
+                // The session slot outlives a deleted account; the guard
+                // re-validation above already handled disabled ones, so
+                // this only trips a mid-request delete. Bounce to login.
+                return directives().notify("error", "Your account no longer exists.").navigate("/login");
+            }
+
+            var view = new ProfileView() {
+                username = ((!)user).username,
+                email = ((!)user).email,
+                name_display = @"$(((!)user).forename) $(((!)user).surname)".strip(),
+                is_admin = SpryAuth.permission_matches(((!)user).permissions.to_array(),
+                    SpryAuth.ADMIN_PERMISSION),
+                created_display = format_stamp(((!)user).created),
+                change_password = action_registry.author<ChangeMyPasswordAction>()
+            };
+
+            var props = (PropertyDictionary) GObjectMapping.to_properties(view);
+
+            var slot = state_service.new_slot(Scope.PAGE, new State() {
+                type_name = "profile",
+                public_data = props,
+                private_data = new PropertyDictionary()
+            });
+            return directives().set(slot);
+        }
+    }
+}

+ 11 - 2
src/entrypoints/SaddlesEntrypoint.vala

@@ -71,6 +71,9 @@ namespace ClankerSaddle {
 
     /** Public data of the `saddles` slot. */
     public class SaddlesPublic : Object {
+        /** True when the viewer may see the admin controls (the actions
+         * re-check the permission; this only shapes the page). */
+        public bool is_admin { get; set; }
         public Statum.Model.ActionDto register { get; set; }
         public Statum.Model.ActionDto create_capability { get; set; }
     }
@@ -90,11 +93,16 @@ namespace ClankerSaddle {
         private UserService users = Inversion.inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
-            var guard = yield SpryAuth.require_permission(directives(), request, users,
-                SpryAuth.ADMIN_PERMISSION, "/login");
+            // Viewing is open to every logged-in user (the roles
+            // decision); the page's controls are admin-only, so the
+            // public slot carries the admin flag the template's stm-if
+            // guards bind against. The actions themselves enforce the
+            // permission server-side.
+            var guard = yield SpryAuth.require_login(directives(), request, users);
             if (guard != null) {
                 return (!)guard;
             }
+            bool is_admin = SpryAuth.current(request).has_permission(SpryAuth.ADMIN_PERMISSION);
 
             var capabilities = yield capability_service.list_capabilities();
             var capability_names = new Dictionary<string, string>();
@@ -217,6 +225,7 @@ namespace ClankerSaddle {
             }
 
             var pub = new SaddlesPublic();
+            pub.is_admin = is_admin;
             pub.register = action_registry.author<RegisterSaddleAction>();
             pub.create_capability = action_registry.author<CreateCapabilityAction>();
 

+ 22 - 8
src/entrypoints/TaskDetailEntrypoint.vala

@@ -110,6 +110,13 @@ namespace ClankerSaddle {
         public string review_summary { get; set; default = ""; }
         public string review_diff_url { get; set; default = ""; }
         public bool resume_available { get; set; }
+        /**
+         * Goal 4 task 1 (Force Wake): the human recovery lever for a
+         * parked task — authored only while the task is suspended
+         * (any kind; a goal_run routes to the goal's wake). Pressing
+         * it sets the task (or goal) force-wake flag.
+         */
+        public Statum.Model.ActionDto? force_wake { get; set; }
         public string saddle_health { get; set; default = ""; }
         public string display { get; set; default = ""; }
         public bool terminal { get; set; }
@@ -257,6 +264,12 @@ namespace ClankerSaddle {
             pub.cancel = actions.author_private<CancelTaskAction, TaskActionPrivate>(sealed);
             pub.retry = actions.author_private<RetryTaskAction, TaskActionPrivate>(sealed);
             pub.resume = actions.author_private<ResumeTaskAction, TaskActionPrivate>(sealed);
+            // Goal 4 task 1: Force wake — only a PARKED task offers
+            // it; a suspended goal run's wake IS the goal's wake
+            // (TaskForceWakeAction routes it — one path).
+            if (task.status == STATUS_SUSPENDED) {
+                pub.force_wake = actions.author_private<TaskForceWakeAction, TaskActionPrivate>(sealed);
+            }
             pub.refresh = actions.author_private<RefreshTaskDataAction, TaskActionPrivate>(sealed, "GET");
             pub.attach_rule = actions.author_private<AttachTaskRuleAction, TaskActionPrivate>(sealed);
             pub.attach_capability = actions.author_private<AttachTaskCapabilityAction, TaskActionPrivate>(sealed);
@@ -310,9 +323,11 @@ namespace ClankerSaddle {
                 });
             }
 
-            // Newest-first (plan 10 phase 3): the blocking sections
-            // lead with the most recent question, RFC or review.
-            var blocks_ascending = new Vector<BlockView>();
+            // Newest-first (the ordering sweep): list_blocks itself
+            // returns newest-first now, so the rows flow straight
+            // through — the blocking sections lead with the most
+            // recent question, RFC or review.
+            var blocks_newest = new Vector<BlockView>();
             foreach (var block in ((!)detail).blocks) {
                 var block_private = new BlockAnswerPrivate() {
                     task_id = task.id,
@@ -340,17 +355,16 @@ namespace ClankerSaddle {
                 // (the review summary), so the block's own body preview —
                 // the same text — stays hidden until the block is answered.
                 view.show_body = view.block_type != BLOCK_REVIEW || view.answered;
-                blocks_ascending.add(view);
+                blocks_newest.add(view);
             }
             var blocks = new Vector<BlockView>();
             var open_blocks = new Vector<BlockView>();
             var answered_blocks = new Vector<BlockView>();
-            var block_array = blocks_ascending.to_array();
-            for (var index = block_array.length - 1; index >= 0; index--) {
-                var view = block_array[index];
+            foreach (var view in blocks_newest) {
                 blocks.add(view);
                 // Plan 11 task D: the current (unanswered) blocks render
-                // in full; the historical ones fold behind an expander.
+                // in full; the historical ones fold behind an expander —
+                // both groups keep the newest-first order.
                 if (view.answered) {
                     answered_blocks.add(view);
                 } else {

+ 11 - 7
src/entrypoints/TaskTerminalEntrypoint.vala

@@ -106,8 +106,13 @@ namespace ClankerSaddle {
             }
             bool found = false;
             int64 latest = 0;
+            // Sessions arrive newest-first ({@link
+            // TaskService.get_task} since the ordering sweep); the
+            // explicit max keeps the default independent of order.
             foreach (var session in sessions) {
-                latest = session.id;
+                if (session.id > latest) {
+                    latest = session.id;
+                }
                 if (session.id == selected) {
                     found = true;
                 }
@@ -138,12 +143,11 @@ namespace ClankerSaddle {
                 }
             }
             string opening_notice = yield opening_brief_notice(((!)detail).task);
-            var ordered = new Vector<ClankerSaddle.Data.Session>();
-            foreach (var session in sessions) {
-                ordered.add(session);
-            }
-            var session_array = ordered.to_array();
-            for (int i = session_array.length - 1; i >= 0; i--) {
+            // Newest-first straight through: the sessions arrive
+            // newest-first ({@link TaskService.get_task}) and the
+            // dropdown keeps that order — no reversal anymore.
+            var session_array = sessions.to_array();
+            for (int i = 0; i < session_array.length; i++) {
                 var session = session_array[i];
                 bool resumed = session.resume_notice != null
                     && ((!)session.resume_notice).length > 0;

+ 123 - 0
src/entrypoints/UsersEntrypoint.vala

@@ -0,0 +1,123 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Spry;
+using Spry.Authentication;
+using Statum;
+
+namespace ClankerSaddle {
+
+    /** One row of the users table, with its row actions. */
+    public class ManagedUserRowView : Object {
+        public int64 id { get; set; }
+        public string username { get; set; default = ""; }
+        public string email { get; set; default = ""; }
+        public string forename { get; set; default = ""; }
+        public string surname { get; set; default = ""; }
+        public string name_display { get; set; default = ""; }
+        public string date_of_birth { get; set; default = ""; }
+        public bool enabled { get; set; }
+        public bool is_admin { get; set; }
+        public bool is_me { get; set; }
+        public string created_display { get; set; default = ""; }
+        public Statum.Model.ActionDto? alter { get; set; }
+        public Statum.Model.ActionDto? set_enabled { get; set; }
+        public Statum.Model.ActionDto? grant_admin { get; set; }
+        public Statum.Model.ActionDto? revoke_admin { get; set; }
+        public Statum.Model.ActionDto? delete { get; set; }
+    }
+
+    /** Public data of the `users` slot. */
+    public class UsersPublic : Object {
+        public Statum.Model.ActionDto create { get; set; }
+    }
+
+    /**
+     * Entrypoint for /users: the account table every row of which
+     * carries its admin role badge, enabled state and row actions (edit
+     * expander, enable/disable, grant/revoke admin, delete). Admin-only
+     * (the authoritative guard; the nav link is hidden for non-admins
+     * but the guard is what keeps the page closed).
+     *
+     * Rows are newest-first: the goal brief's list convention. The
+     * enable/disable action seals the row's NEXT state so the button
+     * never races the table, and the edit expander is prefilled from the
+     * same row (one round trip, no separate fetch).
+     */
+    public class UsersEntrypoint : StatumEntrypoint {
+
+        private UserService users_service = Inversion.inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_permission(directives(), request, users_service,
+                SpryAuth.ADMIN_PERMISSION, "/login");
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var me = SpryAuth.current(request);
+
+            var rows = new Vector<ManagedUserRowView>();
+            foreach (var user in yield users_service.list_users(0, 1000)) {
+                bool is_admin = SpryAuth.permission_matches(user.permissions.to_array(),
+                    SpryAuth.ADMIN_PERMISSION);
+                var sealed_row = new ManagedUserPrivate() {
+                    user_id = user.id,
+                    enabled = !user.enabled
+                };
+                var view = new ManagedUserRowView() {
+                    id = user.id,
+                    username = user.username,
+                    email = user.email,
+                    forename = user.forename,
+                    surname = user.surname,
+                    name_display = @"$(user.forename) $(user.surname)".strip(),
+                    date_of_birth = user.date_of_birth.format("%Y-%m-%d"),
+                    enabled = user.enabled,
+                    is_admin = is_admin,
+                    is_me = me.user_id == user.id,
+                    created_display = format_stamp(user.created),
+                    alter = action_registry.author_private<AlterManagedUserAction, ManagedUserPrivate>(
+                        new ManagedUserPrivate() { user_id = user.id }),
+                    set_enabled = action_registry.author_private<SetManagedUserEnabledAction, ManagedUserPrivate>(sealed_row),
+                    delete = action_registry.author_private<DeleteManagedUserAction, ManagedUserPrivate>(
+                        new ManagedUserPrivate() { user_id = user.id })
+                };
+                if (is_admin) {
+                    view.revoke_admin = action_registry.author_private<RevokeUserAdminAction, ManagedUserPrivate>(
+                        new ManagedUserPrivate() { user_id = user.id });
+                } else {
+                    view.grant_admin = action_registry.author_private<GrantUserAdminAction, ManagedUserPrivate>(
+                        new ManagedUserPrivate() { user_id = user.id });
+                }
+                rows.add(view);
+            }
+
+            // Newest first. The ids are autoincrement, so id-descending
+            // IS created-descending — including the same-minute ties the
+            // display stamp (minute resolution) cannot distinguish.
+            var sorted = rows.to_array();
+            for (int i = 1; i < sorted.length; i++) {
+                var row = sorted[i];
+                int j = i - 1;
+                while (j >= 0 && sorted[j].id < row.id) {
+                    sorted[j + 1] = sorted[j];
+                    j--;
+                }
+                sorted[j + 1] = row;
+            }
+
+            var pub = new UsersPublic();
+            pub.create = action_registry.author<CreateUserAction>();
+
+            var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
+            props["users"] = StateJson.list_array<ManagedUserRowView>(sorted);
+
+            var slot = state_service.new_slot(Scope.PAGE, new State() {
+                type_name = "users",
+                public_data = props,
+                private_data = new PropertyDictionary()
+            });
+            return directives().set(slot);
+        }
+    }
+}

+ 14 - 0
src/main.vala

@@ -42,6 +42,7 @@ int main(string[] args) {
             print(@"[ClankerSaddle] sqlite pragmas failed: $(pragma_error.message)\n");
         }
         db.add_migration<ClankerSaddle.Data.Migrations.M0001_Initial>();
+        db.add_migration<ClankerSaddle.Data.Migrations.M0002_ForceWake>();
         db.add_entity<Project>(Project.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);
@@ -103,6 +104,8 @@ int main(string[] args) {
         statum.add_page<GoalDetailPage, GoalDetailEntrypoint>();
         statum.add_page<SaddlesPage, SaddlesEntrypoint>();
         statum.add_page<RulesPage, RulesEntrypoint>();
+        statum.add_page<UsersPage, UsersEntrypoint>();
+        statum.add_page<ProfilePage, ProfileEntrypoint>();
         // spry:pages-end
 
         // spry:actions-begin
@@ -121,11 +124,14 @@ int main(string[] args) {
         statum.action<CancelTaskAction>();
         statum.action<RetryTaskAction>();
         statum.action<ResumeTaskAction>();
+        statum.action<TaskForceWakeAction>();
         statum.action<RefreshTasksDataAction>();
         statum.action<CreateGoalAction>();
         statum.action<StartGoalAction>();
+        statum.action<UpdateDraftGoalAction>();
         statum.action<PauseGoalAction>();
         statum.action<ResumeGoalAction>();
+        statum.action<GoalForceWakeAction>();
         statum.action<CancelGoalAction>();
         statum.action<RetryGoalRunAction>();
         statum.action<RequeueGoalRunAction>();
@@ -136,6 +142,7 @@ int main(string[] args) {
         statum.action<RefreshGoalDataAction>();
         statum.action<PushSubscribeAction>();
         statum.action<PushUnsubscribeAction>();
+        statum.action<PushTestAction>();
         statum.action<ToggleTaskNotifyAction>();
         statum.action<RefreshTaskDataAction>();
         statum.action<RefreshDashboardAction>();
@@ -157,6 +164,13 @@ int main(string[] args) {
         statum.action<DetachSaddleCapabilityAction>();
         statum.action<AttachTaskCapabilityAction>();
         statum.action<DetachTaskCapabilityAction>();
+        statum.action<CreateUserAction>();
+        statum.action<AlterManagedUserAction>();
+        statum.action<SetManagedUserEnabledAction>();
+        statum.action<GrantUserAdminAction>();
+        statum.action<RevokeUserAdminAction>();
+        statum.action<DeleteManagedUserAction>();
+        statum.action<ChangeMyPasswordAction>();
         // spry:actions-end
 
         // spry:resources-begin

+ 12 - 0
src/pages/app.css

@@ -181,6 +181,11 @@ summary { cursor: pointer; font-weight: 600; }
 /* The saddle's SSH public key display */
 .ssh-key {
     background: #111827;
+    /* Goal 2 QoL: the key text inherits --ink (#1f2328, near-black) —
+       unreadable on the #111827 panel. A light tone keeps the dark
+       aesthetic while clearing WCAG-ish contrast (the page already
+       uses #d1d5db for muted text on dark surfaces). */
+    color: #d1d5db;
     border: 1px solid #374151;
     border-radius: 4px;
     font-size: 0.75rem;
@@ -872,3 +877,10 @@ dialog#quote-comment-dialog blockquote {
     .grid-2 { grid-template-columns: 1fr; }
     th, td { padding: 0.4rem 0.5rem; font-size: 0.9rem; }
 }
+
+/* Admin role badge (users + profile pages): the plan-family violet the
+ * app already reserves for privileged markings. */
+.badge-admin {
+    background: #f3e8ff;
+    color: var(--plan);
+}

+ 39 - 3
src/pages/goal_detail.html

@@ -110,6 +110,11 @@
                 });
             }
 
+            // The brief card's in-place editor (task 12).
+            if (window.initLongFormEditors) {
+                window.initLongFormEditors();
+            }
+
         })();
     }
 </script>
@@ -164,6 +169,11 @@
                     <button type="button" stm-if="goal.requeue_run != null"
                             stm-action="goal.requeue_run"
                             stm-confirm="Requeue the agent's run? The daemon restores its workspace context on the next dispatch.">Requeue to resume</button>
+                    <!-- Goal 4 task 1: Force wake — the parked-agent
+                         recovery for the no-pending-wake stall. -->
+                    <button type="button" stm-if="goal.force_wake != null"
+                            stm-action="goal.force_wake"
+                            stm-confirm="Force-woke this goal's parked agent? It resumes immediately on its saddle — one wake per press.">Force wake</button>
                     <button type="button" class="danger" stm-if="goal.cancel != null"
                             stm-action="goal.cancel"
                             stm-confirm="Cancel this goal? Its tasks are cancelled with it.">Cancel goal</button>
@@ -371,11 +381,37 @@
                 </table>
             </section>
 
-            <!-- The locked brief. -->
-            <section class="card">
+            <!-- The locked brief. Task 12: DRAFT goals swap the
+                 rendered markdown for an in-place editor
+                 (initLongFormEditors in main.html) carrying title,
+                 brief and priority — a draft goal has no other edit
+                 surface (this also unblocks the draft priority, which
+                 nothing else could change). Starting locks the brief:
+                 the editor's stm-if wrapper disappears with the
+                 update action and the card stays the plain read view. -->
+            <section class="card" data-long-form data-field="goal"
+                     data-lf-open="0">
                 <h3>Initial brief
+                    <button type="button" class="inline" stm-if="goal.update != null"
+                            data-lf-toggle>Edit</button>
                     <span class="muted" stm-if="goal.started_display.length > 0">(locked)</span></h3>
-                <div class="md rfc-body goal-body" data-brief-body stm-markdown="goal.brief">brief</div>
+                <div data-lf-view>
+                    <div class="md rfc-body goal-body" data-brief-body stm-markdown="goal.brief">brief</div>
+                </div>
+                <div stm-if="goal.update != null">
+                    <form data-lf-edit hidden stm-action="goal.update">
+                        <div class="form-row"><label>Title</label>
+                            <input type="text" name="title" data-lf-src="goal.title"></div>
+                        <div class="form-row"><label>Brief (markdown)</label>
+                            <textarea name="brief" rows="8"
+                                      data-lf-src="goal.brief">brief</textarea></div>
+                        <div class="form-row"><label>Priority (1–9)</label>
+                            <input type="number" name="priority" min="1" max="9"
+                                   data-lf-src="goal.priority"></div>
+                        <button type="submit">Save</button>
+                        <button type="button" data-lf-toggle>Cancel</button>
+                    </form>
+                </div>
             </section>
 
             <!-- Involved projects. -->

+ 127 - 12
src/pages/home.html

@@ -108,13 +108,121 @@
                 }).catch(function (error) { fail(error); });
             }
 
+            // ---- per-browser bell state (the user-global flag lied) ----
+            // The dashboard exposes the user's endpoint FINGERPRINTS;
+            // this browser's own pushManager subscription is hashed
+            // the same way and looked up in the list. Each browser
+            // then shows its own truth: subscribed here ("Turn off")
+            // vs subscribed elsewhere only ("Enable" + a count). The
+            // client-side check is authoritative — the server flag is
+            // just a convenience for other consumers.
+            var browserSubscribed = null; // null = still asking the browser
+            var hereNow = false;          // the displayed verdict (click acts on it)
+
+            function sha256Hex16(text) {
+                // Mirrors PushService.endpoint_fingerprint: first 8
+                // bytes of the SHA-256, hex — crypto.subtle needs a
+                // secure context, exactly like pushManager itself.
+                var data = new TextEncoder().encode(text);
+                return crypto.subtle.digest('SHA-256', data).then(function (digest) {
+                    var bytes = new Uint8Array(digest).slice(0, 8);
+                    var hex = '';
+                    for (var index = 0; index < bytes.length; index++) {
+                        hex += ('0' + bytes[index].toString(16)).slice(-2);
+                    }
+                    return hex;
+                });
+            }
+
+            function myFingerprint() {
+                if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
+                    return Promise.resolve(null);
+                }
+                return navigator.serviceWorker.getRegistration('/').then(function (registration) {
+                    if (!registration) return null;
+                    return registration.pushManager.getSubscription().then(function (sub) {
+                        return sub ? sha256Hex16(sub.endpoint) : null;
+                    });
+                }).catch(function () { return null; });
+            }
+
+            function otherBrowserCount() {
+                // The dashboard's fingerprints minus this browser's own.
+                var mine = browserSubscribed;
+                var list = dashboard().push_endpoints || [];
+                if (mine === null || mine === undefined) return list.length;
+                var others = 0;
+                for (var index = 0; index < list.length; index++) {
+                    if (list[index] !== mine) others++;
+                }
+                return others;
+            }
+
+            function refreshBell() {
+                return myFingerprint().then(function (mine) {
+                    browserSubscribed = mine;
+                    var list = dashboard().push_endpoints || [];
+                    var card = document.querySelector('.push-bell-card');
+                    if (!card) return;
+                    var here = mine !== null && list.indexOf(mine) !== -1;
+                    var others = otherBrowserCount();
+                    var onBtn = card.querySelector('[data-bell-on]');
+                    var offBtn = card.querySelector('[data-bell-off]');
+                    var help = card.querySelector('[data-bell-help]');
+                    var testBtn = card.querySelector('[data-push-test]');
+                    hereNow = here;
+                    if (onBtn) onBtn.hidden = here;
+                    if (offBtn) offBtn.hidden = !here;
+                    if (testBtn) testBtn.hidden = !here;
+                    if (help) {
+                        if (here) {
+                            help.textContent = others > 0
+                                ? '— this browser receives question, RFC, review and waiting-on-drafts alerts; '
+                                  + others + ' other browser(s) are also subscribed; "Notify me" per task adds its start, failure and completion'
+                                : '— this browser receives question, RFC, review and waiting-on-drafts alerts; "Notify me" per task adds its start, failure and completion';
+                        } else if (list.length > 0) {
+                            help.textContent = '— you are subscribed on ' + list.length
+                                + ' other browser(s); enable here too to be notified in this browser';
+                        } else {
+                            help.textContent = '— be told when an agent asks a question, raises an RFC or review';
+                        }
+                    }
+                });
+            }
+
+            function sendTest() {
+                var state = dashboard();
+                if (!state.push_test) {
+                    fail('not available on this server');
+                    return;
+                }
+                statum.act(state.push_test, {}).catch(function (error) { fail(error); });
+            }
+
+            // The dashboard state refreshes on every pulse; the bell's
+            // per-browser verdict must be re-derived each time (the
+            // browser's own subscription may have changed under us).
+            statum.addStateListener('dashboard', refreshBell);
+            refreshBell();
+
             document.addEventListener('click', function (event) {
                 if (!event.target.closest) return;
+                var card = event.target.closest('.push-bell-card');
+                if (!card) return;
                 var bell = event.target.closest('[data-push-bell]');
-                if (!bell) return;
-                event.preventDefault();
-                if (dashboard().push_subscribed) disablePush();
-                else enablePush();
+                var test = event.target.closest('[data-push-test]');
+                if (bell) {
+                    event.preventDefault();
+                    // Follow the DISPLAYED verdict: a browser holding a
+                    // subscription the server no longer knows (a pruned
+                    // row) shows Enable — and enabling re-registers the
+                    // existing subscription, healing it.
+                    if (hereNow) disablePush();
+                    else enablePush();
+                } else if (test) {
+                    event.preventDefault();
+                    sendTest();
+                }
             });
         })();
     }
@@ -130,17 +238,24 @@
                  browser (permission + service worker + VAPID key) or
                  turn it off. Block events notify every subscribed user;
                  per-task "Notify me" adds start/failure/completion. -->
+            <!-- The notification bell (plan 11 task E; per-browser
+                 since the user-global flag lied to second browsers):
+                 THIS browser's own subscription decides Enable vs Turn
+                 off (the JS fingerprints the browser's endpoint and
+                 matches it against the dashboard's list), the helper
+                 text counts the user's OTHER subscribed browsers, and
+                 "Send test notification" fires the real end-to-end
+                 probe through this user's subscriptions. -->
             <section class="card push-bell-card">
                 <form class="inline">
-                    <button type="button" data-push-bell class="primary"
-                            stm-if="!dashboard.push_subscribed">Enable notifications</button>
-                    <button type="button" data-push-bell
-                            stm-if="dashboard.push_subscribed">Turn off notifications</button>
-                    <span class="muted" stm-if="!dashboard.push_subscribed">— be told when an agent asks a
+                    <button type="button" data-push-bell data-bell-on class="primary"
+                            hidden>Enable notifications</button>
+                    <button type="button" data-push-bell data-bell-off
+                            hidden>Turn off notifications</button>
+                    <button type="button" data-push-test
+                            hidden>Send test notification</button>
+                    <span class="muted" data-bell-help>— be told when an agent asks a
                         question, raises an RFC or review</span>
-                    <span class="muted" stm-if="dashboard.push_subscribed">— this browser receives question,
-                        RFC, review and waiting-on-drafts alerts; turn on "Notify me" per task for its
-                        start, failure and completion</span>
                 </form>
             </section>
 

+ 113 - 0
src/pages/main.html

@@ -231,6 +231,114 @@
         }
     };
 
+    //
+    // initLongFormEditors (plan QoL task 12): in-place editors for the
+    // long-form card fields (task mandate, goal brief). The rendered
+    // markdown view and the editor <form> BOTH live in the page markup;
+    // a per-card `data-lf-open` flag (kept OUTSIDE statum state, the
+    // plan-remarks pattern) toggles which side shows. Why JS-applied
+    // values and not stm-attribute.value: statum's attribute binding
+    // sets the CONTENT attribute, which the browser ignores on
+    // <textarea> (its value lives in the text node) — so the textarea
+    // is re-prefilled from the card's data-value payload after every
+    // render cycle, while the page state carries the source of truth.
+    // The card contract:
+    //   [data-long-form]            the card root; data-field = slot key
+    //                               whose object carries the payload
+    //   [data-lf-view]              the rendered markdown half
+    //   [data-lf-edit]              the editor half (form)
+    //   textarea/input[data-lf-src] a state path ("task.description")
+    //                               whose current value prefills the
+    //                               control on every render
+    //   [data-lf-toggle]            the Edit / Cancel button
+    // Buttons INSIDE the form (Save) submit through the form's
+    // stm-action as usual; toggling is purely presentational, so no
+    // state round-trip and no flicker.
+    //
+    window.initLongFormEditors = function () {
+        if (!window.statum || !statum.afterEvaluate) return;
+
+        // Resolve a "type.field" state path: statum.read returns the
+        // SLOT's public object, so the first segment is the slot and
+        // the rest walks into it.
+        function readPath(path) {
+            if (!path) return '';
+            var parts = path.split('.');
+            var value = statum.read(parts[0]);
+            for (var i = 1; i < parts.length && value != null; i++) {
+                value = value[parts[i]];
+            }
+            return value === undefined || value === null ? '' : String(value);
+        }
+
+        function syncCard(card) {
+            var open = card.getAttribute('data-lf-open') === '1';
+            var view = card.querySelector('[data-lf-view]');
+            var editor = card.querySelector('[data-lf-edit]');
+            if (view) {
+                if (open) view.setAttribute('hidden', '');
+                else view.removeAttribute('hidden');
+            }
+            if (editor) {
+                if (open) editor.removeAttribute('hidden');
+                else editor.setAttribute('hidden', '');
+            }
+        }
+
+        // Prefill bookkeeping: applied ONCE per open — a statum
+        // re-render (pulse refresh, action response) rebinds
+        // attributes but never textarea VALUES, so re-applying on
+        // every cycle would clobber the user's in-progress typing.
+        // Closing the card clears the flag; the next open re-prefills
+        // from the (possibly updated) state.
+        var prefilled = {};
+        function applyPrefills() {
+            var cards = document.querySelectorAll('[data-long-form]');
+            Array.prototype.forEach.call(cards, function (card) {
+                syncCard(card);
+                var key = card.getAttribute('data-field') || '';
+                var open = card.getAttribute('data-lf-open') === '1';
+                if (open && !prefilled[key]) {
+                    var controls = card.querySelectorAll('[data-lf-src]');
+                    Array.prototype.forEach.call(controls, function (control) {
+                        var value = readPath(control.getAttribute('data-lf-src'));
+                        if (document.activeElement !== control) {
+                            control.value = value;
+                        }
+                    });
+                    prefilled[key] = true;
+                }
+                if (!open) delete prefilled[key];
+            });
+        }
+
+        document.addEventListener('click', function (event) {
+            if (!event.target.closest) return;
+            var button = event.target.closest('[data-lf-toggle]');
+            if (!button) return;
+            var card = button.closest('[data-long-form]');
+            if (!card) return;
+            event.preventDefault();
+            var open = card.getAttribute('data-lf-open') === '1';
+            if (open) {
+                card.setAttribute('data-lf-open', '0');
+                delete prefilled[card.getAttribute('data-field') || ''];
+            } else {
+                card.setAttribute('data-lf-open', '1');
+            }
+            syncCard(card);
+            // Prefill immediately (the render cycle applies the state
+            // bindings; the textarea's value needs this direct pass).
+            applyPrefills();
+            if (!open) {
+                var area = card.querySelector('textarea');
+                if (area && area.focus) area.focus();
+            }
+        });
+
+        statum.afterEvaluate(applyPrefills);
+    };
+
     if (window.statum) {
             (function () {
                 'use strict';
@@ -344,8 +452,13 @@
             <a href="/goals">Goals</a>
             <a href="/saddles">Saddles</a>
             <a href="/rules">Rules</a>
+            <!-- The users link is display-guarded (the page's entrypoint
+                 guard is authoritative); auth.permissions rides the
+                 signed auth slot, so an admin sees it everywhere. -->
+            <a stm-if="auth != null && auth.permissions.indexOf('admin') != -1" href="/users">Users</a>
             <!-- spry:nav-end -->
             <span stm-if="auth != null"><strong stm-text="auth.username">user</strong>
+                <a href="/profile" title="Your profile">Profile</a>
                 <button type="button" class="inline" stm-action="auth.logout">Log out</button>
                 <!-- spry:nav-auth-begin -->
                 <!-- spry:nav-auth-end -->

+ 38 - 0
src/pages/profile.html

@@ -0,0 +1,38 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/profile</pstm-uri>
+    <pstm-template>main</pstm-template>
+</head>
+<body>
+    <section>
+        <h2>Your profile</h2>
+        <p stm-if="profile == null">Loading…</p>
+
+        <div stm-if="profile != null">
+            <div class="card">
+                <h3 stm-text="profile.name_display">name</h3>
+                <p><span stm-attribute.class="profile.is_admin ? 'badge badge-admin' : 'badge'"
+                         stm-text="profile.is_admin ? 'admin' : 'user'">role</span></p>
+                <p class="muted">Username: <strong stm-text="profile.username">username</strong></p>
+                <p class="muted">Email: <span stm-text="profile.email">email</span></p>
+                <p class="muted">Member since <span stm-text="profile.created_display">when</span></p>
+            </div>
+
+            <h3>Change password</h3>
+            <form stm-action="profile.change_password" style="max-width: 24rem">
+                <div class="form-row"><label for="current-password">Current password</label>
+                    <input id="current-password" type="password" name="current_password"
+                           required autocomplete="current-password"></div>
+                <div class="form-row"><label for="new-password">New password</label>
+                    <input id="new-password" type="password" name="new_password"
+                           required autocomplete="new-password"></div>
+                <div class="form-row"><label for="confirm-password">Confirm new password</label>
+                    <input id="confirm-password" type="password" name="confirm_password"
+                           required autocomplete="new-password"></div>
+                <button type="submit">Change password</button>
+            </form>
+        </div>
+    </section>
+</body>
+</html>

+ 17 - 11
src/pages/saddles.html

@@ -10,7 +10,7 @@
         <p stm-if="saddles == null">Loading…</p>
 
         <div stm-if="saddles != null">
-            <details>
+            <details stm-if="saddles.is_admin">
                 <summary><strong>Register saddle</strong></summary>
                 <form stm-action="saddles.register">
                     <div class="form-row"><label>Name</label>
@@ -40,9 +40,9 @@
                         <span class="muted" stm-if="saddle.daemon_state.length > 0"
                               stm-text="'daemon ' + saddle.daemon_state + (saddle.uptime_display.length > 0 ? ' · up ' + saddle.uptime_display : '')">daemon</span>
                         <span class="muted" stm-text="'contacted ' + saddle.last_contact_display + ' ago'">contacted</span></p>
-                    <p stm-if="saddle.token.length > 0" class="muted">API token:
+                    <p stm-if="saddles.is_admin && saddle.token.length > 0" class="muted">API token:
                         <code stm-text="saddle.token">token</code></p>
-                    <details stm-if="saddle.ssh_public_key.length > 0">
+                    <details stm-if="saddles.is_admin && saddle.ssh_public_key.length > 0">
                         <summary>SSH public key</summary>
                         <p class="muted">Add this as a <strong>deploy key</strong> on the git server
                             (with write access) so this saddle can clone and push.</p>
@@ -51,9 +51,9 @@
                                 stm-attribute.data-clipboard="saddle.ssh_public_key"
                                 onclick="navigator.clipboard.writeText(this.getAttribute('data-clipboard')).then(function () { this.textContent = 'Copied'; }.bind(this))">Copy key</button>
                     </details>
-                    <p stm-if="saddle.ssh_public_key.length == 0" class="muted">No SSH key reported yet —
+                    <p stm-if="saddles.is_admin && saddle.ssh_public_key.length == 0" class="muted">No SSH key reported yet —
                         it appears here once the daemon generates one.</p>
-                    <details>
+                    <details stm-if="saddles.is_admin">
                         <summary>Git identity</summary>
                         <form stm-action="saddle.update_identity" class="inline">
                             <label>Committed as</label>
@@ -65,15 +65,20 @@
                             <span class="muted">Empty fields fall back to the daemon's default identity.</span>
                         </form>
                     </details>
-                    <p stm-if="saddle.capabilities.length > 0">Capabilities:
+                    <p stm-if="saddles.is_admin && saddle.capabilities.length > 0">Capabilities:
                         <span stm-for-capability-in="saddle.capabilities" stm-key="capability.link_id">
                             <span class="badge"><span stm-text="capability.name">tag</span>
                                 <button type="button" class="inline chip-remove"
                                         stm-action="capability.detach" stm-confirm="Remove this capability?">×</button></span>
                         </span>
                     </p>
+                    <p stm-if="!saddles.is_admin && saddle.capabilities.length > 0">Capabilities:
+                        <span stm-for-capability-in="saddle.capabilities" stm-key="capability.link_id">
+                            <span class="badge"><span stm-text="capability.name">tag</span></span>
+                        </span>
+                    </p>
                     <p stm-if="saddle.capabilities.length == 0" class="muted">No capabilities — matches only tasks with none required.</p>
-                    <form class="inline" stm-if="saddles.capability_options.length > 0"
+                    <form class="inline" stm-if="saddles.is_admin && saddles.capability_options.length > 0"
                           stm-action="saddle.attach_capability">
                         <label stm-attribute.for="'cap-' + saddle.id">Add capability</label>
                         <select stm-attribute.id="'cap-' + saddle.id" name="capability_id" required>
@@ -83,8 +88,9 @@
                         <button type="submit">Add</button>
                     </form>
                     <form class="inline">
-                        <button type="button" stm-action="saddle.refresh">Refresh status</button>
-                        <button type="button" stm-action="saddle.unregister" stm-confirm="Remove this saddle? A running task is re-queued.">Remove</button>
+                        <button type="button" stm-if="saddles.is_admin" stm-action="saddle.refresh">Refresh status</button>
+                        <button type="button" stm-if="saddles.is_admin" stm-action="saddle.unregister"
+                                stm-confirm="Remove this saddle? A running task is re-queued.">Remove</button>
                     </form>
                 </section>
                 <section class="card" stm-if="saddles.saddles.length == 0">
@@ -95,7 +101,7 @@
             <h3>Capabilities</h3>
             <p class="muted">Tags the dispatcher routes by: a task is only assigned to a saddle carrying every
                 tag the task requires. Manage a saddle's tags on its card above; require tags on a task's page.</p>
-            <details>
+            <details stm-if="saddles.is_admin">
                 <summary><strong>New capability</strong></summary>
                 <form stm-action="saddles.create_capability" class="inline">
                     <label for="new-capability">Name</label>
@@ -110,7 +116,7 @@
                         <td><span class="badge" stm-text="capability.name">tag</span></td>
                         <td stm-text="capability.saddle_count">0</td>
                         <td stm-text="capability.task_count">0</td>
-                        <td><button type="button" stm-action="capability.delete"
+                        <td stm-if="saddles.is_admin"><button type="button" stm-action="capability.delete"
                                     stm-confirm="Delete this capability everywhere?">Delete</button></td>
                     </tr>
                     <tr stm-if="saddles.capabilities.length == 0">

+ 44 - 9
src/pages/task_detail.html

@@ -47,6 +47,11 @@
                     }
                 });
             }
+
+            // The mandate card's in-place editor (task 12).
+            if (window.initLongFormEditors) {
+                window.initLongFormEditors();
+            }
         })();
     }
 </script>
@@ -127,6 +132,12 @@
                         <button type="button" stm-if="task.resume_available"
                                 stm-action="task.resume"
                                 stm-confirm="Requeue this task? The daemon restores its workspace context on the next dispatch.">Requeue to resume</button>
+                        <!-- Goal 4 task 1: Force wake — the parked-task
+                             recovery (a goal run routes to the goal's
+                             wake). -->
+                        <button type="button" stm-if="task.force_wake != null"
+                                stm-action="task.force_wake"
+                                stm-confirm="Force-woke this parked task? Its agent resumes immediately on its saddle — one wake per press.">Force wake</button>
                         <button type="button" stm-if="!task.draft && !task.terminal"
                                 stm-action="task.cancel" stm-confirm="Cancel this task?">Cancel task</button>
                     </form>
@@ -154,11 +165,35 @@
                 </section>
 
                 <!-- Plan 12: the mandate — the plan-11 "Description"
-                     card, framed as the binding work order. -->
-                <section class="card">
-                    <h3>Mandate</h3>
-                    <div stm-if="task.description.length > 0" class="md" stm-markdown="task.description">description</div>
-                    <p class="muted" stm-if="task.description.length == 0">No mandate.</p>
+                     card, framed as the binding work order. Task 12:
+                     drafts swap the rendered markdown for an in-place
+                     editor (initLongFormEditors in main.html) — the
+                     expander no longer carries the long-form field,
+                     and non-draft/terminal tasks keep the plain read
+                     view. -->
+                <section class="card" data-long-form data-field="task"
+                         data-lf-open="0">
+                    <h3>Mandate
+                        <button type="button" class="inline" stm-if="task.editable_all"
+                                data-lf-toggle>Edit</button></h3>
+                    <div data-lf-view>
+                        <div stm-if="task.description.length > 0" class="md" stm-markdown="task.description">description</div>
+                        <p class="muted" stm-if="task.description.length == 0">No mandate.</p>
+                    </div>
+                    <!-- The stm-if wrapper (not the form itself) owns
+                         draft-gating: statum toggles `hidden` on
+                         stm-if branches, so the form inside keeps its
+                         visibility JS-owned (initLongFormEditors). -->
+                    <div stm-if="task.editable_all">
+                        <form data-lf-edit hidden stm-action="task.update">
+                            <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                            <div class="form-row"><label>Mandate</label>
+                                <textarea name="description" rows="8"
+                                          data-lf-src="task.description">description</textarea></div>
+                            <button type="submit">Save</button>
+                            <button type="button" data-lf-toggle>Cancel</button>
+                        </form>
+                    </div>
                 </section>
             </div>
 
@@ -178,8 +213,10 @@
             <!-- The edit expander sits UNDER the "Show history" expander
                  (plan 11 task D): draft tasks edit every field except
                  status; non-draft non-terminal tasks edit priority
-                 only; terminal tasks edit nothing. The description
-                 arrives pre-filled whenever it is editable. -->
+                 only; terminal tasks edit nothing. Task 12: the
+                 long-form mandate moved to its own in-place editor on
+                 the mandate card — the expander keeps the short
+                 properties only. -->
             <details class="edit-expander" stm-if="task.editable_all || task.editable_priority">
                 <summary><strong>Edit task</strong>
                     <span class="muted" stm-if="task.editable_all">— every field except status while in draft</span>
@@ -188,8 +225,6 @@
                     <input type="hidden" name="task_id" stm-attribute.value="task.id">
                     <div class="form-row"><label>Title</label>
                         <input type="text" name="title" stm-attribute.value="task.title"></div>
-                    <div class="form-row"><label>Mandate</label>
-                        <textarea name="description" rows="4" stm-attribute.value="task.description"></textarea></div>
                     <div class="form-row" stm-if="!task.is_goal_run"><label>Mode</label>
                         <select name="mode" stm-attribute.value="task.mode">
                             <option value="implement">implement — build the change on a task branch</option>

+ 31 - 30
src/pages/tasks.html

@@ -88,36 +88,6 @@
             <span class="muted">— filters apply instantly</span>
         </form>
 
-        <table>
-            <thead>
-                <tr><th>#</th><th>Title</th><th>Status</th><th>Mode</th><th>Goal</th>
-                    <th>Priority</th><th>Project</th><th>Capabilities</th></tr>
-            </thead>
-            <tbody>
-                <tr stm-for-task-in="tasks.tasks" stm-if="window.taskRowVisible(task)">
-                    <td stm-text="task.id">id</td>
-                    <td>
-                        <a stm-attribute.href="'/tasks/' + task.id" stm-text="task.title">title</a>
-                        <span class="badge badge-yeehaw" stm-if="task.has_yeehaw"
-                              title="The agent celebrated this task (saddle yeehaw)">🤠</span>
-                    </td>
-                    <td><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span></td>
-                    <td><span stm-attribute.class="'badge badge-mode-' + task.mode" stm-text="task.mode">mode</span></td>
-                    <td stm-if="task.goal_id > 0">
-                        <a stm-attribute.href="'/goals/' + task.goal_id" stm-text="task.goal_title">goal</a>
-                        <span class="muted" stm-if="task.creator == 'goal'">(agent)</span>
-                    </td>
-                    <td stm-if="task.goal_id == 0" class="muted">—</td>
-                    <td stm-text="task.priority">5</td>
-                    <td stm-text="task.project_name">project</td>
-                    <td><span stm-if="task.capabilities.length > 0"
-                              stm-text="task.capabilities.join(', ')">caps</span>
-                        <span class="muted" stm-if="task.capabilities.length == 0">—</span></td>
-                </tr>
-                <tr stm-if="tasks.tasks.length == 0"><td colspan="8" class="muted">No tasks.</td></tr>
-            </tbody>
-        </table>
-
         <details class="history-expander">
             <summary><strong>New task</strong></summary>
             <form stm-action="tasks.create">
@@ -169,6 +139,37 @@
                 <button type="submit" class="primary">Create task</button>
             </form>
         </details>
+
+        <table>
+            <thead>
+                <tr><th>#</th><th>Title</th><th>Status</th><th>Mode</th><th>Goal</th>
+                    <th>Priority</th><th>Project</th><th>Capabilities</th></tr>
+            </thead>
+            <tbody>
+                <tr stm-for-task-in="tasks.tasks" stm-if="window.taskRowVisible(task)">
+                    <td stm-text="task.id">id</td>
+                    <td>
+                        <a stm-attribute.href="'/tasks/' + task.id" stm-text="task.title">title</a>
+                        <span class="badge badge-yeehaw" stm-if="task.has_yeehaw"
+                              title="The agent celebrated this task (saddle yeehaw)">🤠</span>
+                    </td>
+                    <td><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span></td>
+                    <td><span stm-attribute.class="'badge badge-mode-' + task.mode" stm-text="task.mode">mode</span></td>
+                    <td stm-if="task.goal_id > 0">
+                        <a stm-attribute.href="'/goals/' + task.goal_id" stm-text="task.goal_title">goal</a>
+                        <span class="muted" stm-if="task.creator == 'goal'">(agent)</span>
+                    </td>
+                    <td stm-if="task.goal_id == 0" class="muted">—</td>
+                    <td stm-text="task.priority">5</td>
+                    <td stm-text="task.project_name">project</td>
+                    <td><span stm-if="task.capabilities.length > 0"
+                              stm-text="task.capabilities.join(', ')">caps</span>
+                        <span class="muted" stm-if="task.capabilities.length == 0">—</span></td>
+                </tr>
+                <tr stm-if="tasks.tasks.length == 0"><td colspan="8" class="muted">No tasks.</td></tr>
+            </tbody>
+        </table>
+
     </div>
 </body>
 </html>

+ 93 - 0
src/pages/users.html

@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/users</pstm-uri>
+    <pstm-template>main</pstm-template>
+</head>
+<body>
+    <section>
+        <h2>Users</h2>
+        <p class="muted">Accounts and their roles. Normal users manage projects, tasks,
+            goals and rules; admins additionally manage users, saddles and capabilities.</p>
+        <p stm-if="users == null">Loading…</p>
+
+        <div stm-if="users != null">
+            <details>
+                <summary><strong>New user</strong></summary>
+                <form stm-action="users.create">
+                    <div class="form-row"><label>Username</label>
+                        <input type="text" name="username" required placeholder="ada"></div>
+                    <div class="form-row"><label>Email</label>
+                        <input type="email" name="email" required placeholder="ada@example.com"></div>
+                    <div class="form-row"><label>Forename</label>
+                        <input type="text" name="forename" required placeholder="Ada"></div>
+                    <div class="form-row"><label>Surname</label>
+                        <input type="text" name="surname" required placeholder="Lovelace"></div>
+                    <div class="form-row"><label>Date of birth</label>
+                        <input type="text" name="date_of_birth" required placeholder="1990-01-31"></div>
+                    <div class="form-row"><label>Password</label>
+                        <input type="password" name="password" required placeholder="At least 6 characters"></div>
+                    <div class="form-row"><label>Confirm password</label>
+                        <input type="password" name="confirm_password" required></div>
+                    <div class="form-row"><label>Role</label>
+                        <label class="inline"><input type="checkbox" name="admin" value="1"> Administrator</label></div>
+                    <button type="submit">Create user</button>
+                </form>
+            </details>
+
+            <table>
+                <thead><tr>
+                    <th>Username</th><th>Email</th><th>Name</th><th>Role</th>
+                    <th>Enabled</th><th>Created</th><th></th>
+                </tr></thead>
+                <tbody>
+                    <tr stm-for-user-in="users.users" stm-key="user.id">
+                        <td><strong stm-text="user.username">username</strong></td>
+                        <td stm-text="user.email">email</td>
+                        <td stm-text="user.name_display">name</td>
+                        <td><span stm-attribute.class="user.is_admin ? 'badge badge-admin' : 'badge'"
+                                  stm-text="user.is_admin ? 'admin' : 'user'">role</span></td>
+                        <td><span stm-attribute.class="user.enabled ? 'badge badge-done' : 'badge badge-offline'"
+                                  stm-text="user.enabled ? 'enabled' : 'disabled'">enabled</span></td>
+                        <td class="muted" stm-text="user.created_display">when</td>
+                        <td>
+                            <details>
+                                <summary>Edit</summary>
+                                <form stm-action="user.alter">
+                                    <div class="form-row"><label>Username</label>
+                                        <input type="text" name="username" required stm-attribute.value="user.username"></div>
+                                    <div class="form-row"><label>Email</label>
+                                        <input type="email" name="email" required stm-attribute.value="user.email"></div>
+                                    <div class="form-row"><label>Forename</label>
+                                        <input type="text" name="forename" required stm-attribute.value="user.forename"></div>
+                                    <div class="form-row"><label>Surname</label>
+                                        <input type="text" name="surname" required stm-attribute.value="user.surname"></div>
+                                    <div class="form-row"><label>Date of birth</label>
+                                        <input type="text" name="date_of_birth" required stm-attribute.value="user.date_of_birth"></div>
+                                    <div class="form-row"><label>Enabled</label>
+                                        <label class="inline"><input type="checkbox" name="enabled" value="1"
+                                               stm-attribute.checked="user.enabled ? '' : undefined"> Account enabled</label></div>
+                                    <div class="form-row"><label>New password</label>
+                                        <input type="password" name="new_password"
+                                               placeholder="Leave empty to keep the current password"></div>
+                                    <button type="submit">Save</button>
+                                    <span class="muted">Your own account is edited on your profile page.</span>
+                                </form>
+                            </details>
+                            <form class="inline">
+                                <button type="button" stm-action="user.set_enabled"
+                                        stm-text="user.enabled ? 'Disable' : 'Enable'">toggle</button>
+                                <button type="button" stm-if="user.is_admin && !user.is_me" stm-action="user.revoke_admin">Revoke admin</button>
+                                <button type="button" stm-if="!user.is_admin" stm-action="user.grant_admin">Make admin</button>
+                                <button type="button" stm-action="user.delete"
+                                        stm-confirm="Delete this user? Their sessions outlive the account.">Delete</button>
+                            </form>
+                        </td>
+                    </tr>
+                    <tr stm-if="users.users.length == 0"><td colspan="7" class="muted">No users.</td></tr>
+                </tbody>
+            </table>
+        </div>
+    </section>
+</body>
+</html>

+ 2 - 1
test/bundles/__init__.py

@@ -4,7 +4,7 @@ the modules' star-imported runtime globals are the shard's own). The
 order here is the parallel runner's shard order."""
 
 from . import (allocation, answers, dispatch, goalui, goals, lineage,
-               lifecycle, pages, push, replay, subtasks)
+               lifecycle, pages, push, replay, subtasks, users)
 
 BUNDLES = {
     "pages": pages,
@@ -18,6 +18,7 @@ BUNDLES = {
     "goals": goals,
     "replay": replay,
     "goalui": goalui,
+    "users": users,
 }
 
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",

+ 58 - 0
test/bundles/allocation.py

@@ -643,3 +643,61 @@ def run(ctx):
           "the slow asker never went offline (guard did not fire)")
     fake2.ask_delay = 0.0
     fake2.ask_on_wake = False  # stays out of the pool below
+
+    print("== goal 4 t1: plain-task force wake — offerable on its affine saddle ==")
+    # A suspended PLAIN task stays parked until the human force-wakes
+    # it; the resuming claim carries the explicit wake notice and
+    # consumes the flag (one wake per press).
+    fake2.ask_on_wake = False
+    fake2.ask_on_idle_status = False
+    fake2.settle_asks()
+    conn = db()
+    conn.execute("insert into tasks (project_id, kind, title, status, priority, mode,"
+                 " creator, created_at, suspended_at) values (1, 'task',"
+                 " 'Park me', 'suspended', 5, 'implement', 'human',"
+                 " strftime('%s','now'), strftime('%s','now'))")
+    plain_parked = conn.execute("select max(id) from tasks").fetchone()[0]
+    # parked_offerable keys the affine saddle off the newest SESSION
+    # row (plan 16 D4): a parked round's row is closed 'parked' — give
+    # the fixture one so the wake offers on the fake saddle.
+    conn.execute("insert into sessions (task_id, saddle_id, started_at, ended_at,"
+                 " outcome) values (?, ?, strftime('%s','now'),"
+                 " strftime('%s','now'), 'parked')",
+                 (plain_parked, fake_id))
+    conn.commit()
+    conn.close()
+    task_page = http_get_page(auth, "/tasks/%d" % plain_parked)
+    check('stm-action="task.force_wake"' in task_page
+          and 'stm-if="task.force_wake != null"' in task_page,
+          "the task page authors the Force wake button for a suspended plain task")
+    # No press, no wake: frozen-ask racer-two gets 204.
+    status, _ = fake2.ask_now()
+    check(status == 204,
+          "without a press the parked plain task stays parked (204)")
+    detail = task_state(auth, plain_parked)
+    check(detail.get("force_wake") is not None,
+          "the task state exposes the offered force-wake action")
+    act(auth, detail["force_wake"], {})
+    status, resume_payload = fake2.ask_now()
+    check(status == 200 and int(resume_payload["task"]["id"]) == plain_parked,
+          "the force-woke plain task is claimable again")
+    woke_notice = resume_payload.get("resume_notice") or ""
+    check("force-woke this parked task" in woke_notice
+          and "resume where you left off" in woke_notice,
+          "the plain-task resume carries the explicit wake notice")
+    conn = db()
+    flag_row = conn.execute("select force_wake from tasks where id = ?",
+                            (plain_parked,)).fetchone()
+    conn.close()
+    check(flag_row is not None and flag_row[0] in (0, False),
+          "the claim consumed the task force-wake flag")
+    # The woken run re-parks (fake daemon finishes then suspends? no —
+    # fake.start_payloads script: the plain task runs to done via the
+    # daemon's own finish path in later suites; here just let it be
+    # requeued by completing it directly to keep the fixture short).
+    check(wait_task_done(auth, plain_parked, timeout=60) is not None,
+          "the woken plain task completes (daemon finish path)")
+    # Guard: a non-suspended task offers no wake action (the template
+    # ships the gated markup statically; absence is a STATE property).
+    check(task_state(auth, plain_parked).get("force_wake") is None,
+          "a done task offers no force-wake action")

+ 195 - 0
test/bundles/forcewake.py

@@ -0,0 +1,195 @@
+"""The forcewake bundle (goal 4 task 1): the human-triggered resume.
+
+Self-contained on purpose: the goals/allocation shards die
+environmentally mid-run in this container (the known framework stack
+corruption) long before their new sections, so the force-wake
+behaviour gets its own short-lived app here. The extended sections
+stay in their home bundles for when task 3 fixes the environment.
+"""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+
+def run(ctx):
+    auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
+
+    def create_goal(fields):
+        act(auth, slot_public(auth, "/goals", "goals")["create"], fields)
+        rows = [g for g in slot_public(auth, "/goals", "goals")["goals"]
+                if g["title"] == fields["title"]]
+        return int(rows[0]["id"])
+
+    def run_task_of(goal_id):
+        conn = db()
+        try:
+            return int(conn.execute(
+                "select id from tasks where goal_id = ? and kind = 'goal_run'",
+                (goal_id,)).fetchone()[0])
+        finally:
+            conn.close()
+
+    def run_status(goal_id):
+        return task_state(auth, run_task_of(goal_id))["status"]
+
+    print("== goal 4 t1: force wake — the goal-2 stall class ==")
+    # Create → plan → approve → parked: the goalui-proven short flow.
+    fake.goal_script = [("plan", "# Wake plan\n\n1. Park safely")]
+    goal = create_goal({"project_id": "1", "title": "Force-wake goal",
+                        "brief": "Parks with a never-enqueued draft.",
+                        "branch_name": "goal/forcewake"})
+    act(auth, goal_state(goal)["start"], {})
+    check(wait_for(lambda: goal_state(goal)["status"] == "plan_review" or False,
+                   timeout=90, what="goal plans") is not None,
+          "the goal reaches plan review")
+    fake.goal_script = [("suspend",)]
+    pending = goal_state(goal)["plan_pending_block"]
+    act(auth, pending["answer_action"], {"verdict": "accept"})
+    check(wait_for(lambda: goal_state(goal)["status"] == "active" or False,
+                   timeout=60, what="plan approved") is not None,
+          "approving the plan activates the goal")
+    check(wait_for(lambda: run_status(goal) == "suspended" or False,
+                   timeout=60, what="agent parks") is not None,
+          "the approved agent parks (saddle suspend)")
+
+    # The stall fixture: a draft that was never enqueued (the typo'd
+    # enqueue class) — no terminal transition will ever fire.
+    conn = db()
+    agent_session = conn.execute(
+        "select max(id) from sessions where task_id = ?",
+        (run_task_of(goal),)).fetchone()[0]
+    conn.close()
+    status, body = saddle_api("POST", "/api/saddle/task/create", fake_token, {
+        "session_id": agent_session, "title": "QA sweep",
+        "mandate": "Run the QA sweep.", "mode": "test"})
+    check(status == 200 and body.get("ok") is True,
+          "the stall fixture draft task creates (%s)" % body.get("error"))
+    stall_draft = int(body["task_id"])
+    # The actionable error tail (goal 4 t1): the typo'd enqueue names
+    # the recovery path while keeping the pinned substring. The wrong
+    # id is a REAL task owned by nobody's goal (a plain human task):
+    # the ownership check fires before the existence-only path.
+    conn = db()
+    conn.execute("insert into tasks (project_id, kind, title, status, priority,"
+                 " mode, creator, created_at) values (1, 'task', 'Wrong id',"
+                 " 'draft', 5, 'implement', 'human', strftime('%s','now'))")
+    wrong_id = conn.execute("select max(id) from tasks").fetchone()[0]
+    conn.commit()
+    conn.close()
+    status, body = saddle_api("POST", "/api/saddle/task/enqueue", fake_token, {
+        "session_id": agent_session, "task_id": wrong_id})
+    check(status == 400 and "does not belong to your goal" in (body.get("error") or "")
+          and "nothing was enqueued" in (body.get("error") or "")
+          and "saddle tasks" in (body.get("error") or ""),
+          "the enqueue error carries the pinned substring + the actionable tail (%s)"
+          % body.get("error"))
+
+    state = goal_state(goal)
+    check(state.get("force_wake") is not None,
+          "an active goal with a parked run offers the force-wake action")
+    goal_page = http_get_page(auth, "/goals/%d" % goal)
+    check('stm-action="goal.force_wake"' in goal_page
+          and 'stm-if="goal.force_wake != null"' in goal_page,
+          "the goal page authors the gated Force wake button")
+    run_page = http_get_page(auth, "/tasks/%d" % run_task_of(goal))
+    check('stm-action="task.force_wake"' in run_page,
+          "the suspended goal-run row offers the task-page wake (routing to the goal)")
+
+    # No press, no wake: a quiet window passes without any resume.
+    parked_runs = len(fake.goal_assignments)
+    check(quiet_window(quiet_seconds(),
+                       lambda: len(fake.goal_assignments) == parked_runs,
+                       "no press, no wake"),
+          "without a press the parked agent never wakes")
+
+    # Press: the wake fires immediately (no coalesce wait) and leads
+    # with the explicit line; the statuses name the draft as NOT
+    # enqueued with the enqueue fix.
+    act(auth, goal_state(goal)["force_wake"], {})
+    check(wait_for(lambda: len(fake.goal_assignments) > parked_runs or False,
+                   timeout=30, what="force-woke agent resumes") is not None,
+          "the force-woke agent resumes")
+    woke_notice = fake.goal_assignments[-1].get("resume_notice") or ""
+    check("force-woke this goal's supervision" in woke_notice
+          and "no pending wake" in woke_notice,
+          "the resume notice leads with the explicit force-woke line")
+    check(("draft (NOT enqueued" in woke_notice)
+          and (("saddle task enqueue %d" % stall_draft) in woke_notice),
+          "the statuses list names the never-enqueued draft with the fix")
+    check("Task statuses" in woke_notice,
+          "the force-wake notice keeps the standard batched report")
+    check(wait_for(lambda: run_status(goal) == "suspended" or False,
+                   timeout=60, what="woken agent re-parks") is not None,
+          "the woken agent parks again (script)")
+
+    # The flag is consumed: a second window stays quiet.
+    woke_runs = len(fake.goal_assignments)
+    check(quiet_window(quiet_seconds(),
+                       lambda: len(fake.goal_assignments) == woke_runs,
+                       "consumed flag never re-fires"),
+          "the consumed flag does not re-wake (one wake per press)")
+
+    print("== goal 4 t1: guards — paused goal, non-suspended run ==")
+    sealed_wake = state["force_wake"]
+    act(auth, goal_state(goal)["pause"], {})
+    state = goal_state(goal)
+    check(state.get("force_wake") is None,
+          "a paused goal offers no force-wake action")
+    # The server-side guard: replay the still-sealed force-wake action
+    # captured while the goal was active — the paused goal refuses it
+    # (error 72; the flag stays clear).
+    replay = act(auth, sealed_wake, {})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error"
+              and "force-woke" in (d.get("message") or "")
+              for d in replay if isinstance(d, dict)),
+          "the paused-goal guard refuses the replayed wake (%s)" % replay)
+    conn = db()
+    cleared = conn.execute("select force_wake from goals where id = ?",
+                           (goal,)).fetchone()
+    conn.close()
+    check(cleared is not None and cleared[0] in (0, False),
+          "the goal force-wake flag stays clear after the guard refusal")
+    act(auth, goal_state(goal)["resume"], {})
+    check(wait_for(lambda: goal_state(goal)["status"] == "active" or False,
+                   timeout=30, what="goal reactivates") is not None,
+          "the goal reactivates")
+
+    print("== goal 4 t1: plain-task force wake on its affine saddle ==")
+    conn = db()
+    conn.execute("insert into tasks (project_id, kind, title, status, priority, mode,"
+                 " creator, created_at, suspended_at) values (1, 'task',"
+                 " 'Plain parked', 'suspended', 5, 'implement', 'human',"
+                 " strftime('%s','now'), strftime('%s','now'))")
+    plain_parked = conn.execute("select max(id) from tasks").fetchone()[0]
+    conn.execute("insert into sessions (task_id, saddle_id, started_at, ended_at,"
+                 " outcome) values (?, ?, strftime('%s','now'),"
+                 " strftime('%s','now'), 'parked')",
+                 (plain_parked, fake_id))
+    conn.commit()
+    conn.close()
+    detail = task_state(auth, plain_parked)
+    check(detail.get("force_wake") is not None,
+          "a suspended plain task offers the force-wake action")
+    task_page = http_get_page(auth, "/tasks/%d" % plain_parked)
+    check('stm-action="task.force_wake"' in task_page
+          and 'stm-if="task.force_wake != null"' in task_page,
+          "the task page authors the gated Force wake button")
+    status, _ = fake.ask_now()
+    check(status == 204,
+          "without a press the parked plain task is not offerable (204)")
+    act(auth, task_state(auth, plain_parked)["force_wake"], {})
+    status, resume_payload = fake.ask_now()
+    check(status == 200 and int(resume_payload["task"]["id"]) == plain_parked,
+          "the force-woke plain task is claimable again")
+    notice = resume_payload.get("resume_notice") or ""
+    check("force-woke this parked task" in notice
+          and "resume where you left off" in notice,
+          "the plain-task resume carries the explicit wake notice")
+    conn = db()
+    flag_row = conn.execute("select force_wake from tasks where id = ?",
+                            (plain_parked,)).fetchone()
+    conn.close()
+    check(flag_row is not None and flag_row[0] in (0, False),
+          "the claim consumed the task force-wake flag")
+    check(wait_task_done(auth, plain_parked, timeout=60) is not None,
+          "the woken plain task completes")
+    check(task_state(auth, plain_parked).get("force_wake") is None,
+          "a done task offers no force-wake action")

+ 98 - 8
test/bundles/goals.py

@@ -144,12 +144,13 @@ def run(ctx):
           "the goal blocks list carries the pending plan block")
 
     print("== feedback: goal visual identity ==")
-    # A goal never reads as a task at a glance: marked nav entry,
-    # iconed list page, and the dashboard pile's goal cards carry
+    # A goal never reads as a task at a glance: the nav carries the
+    # plain Goals entry (5eaab31 dropped the nav-goal accent), the
+    # list page is iconed, and the dashboard pile's goal cards carry
     # their own accent styling.
     home_html = http_get_page(auth, "/")
-    check('class="nav-goal"' in home_html and "&#127919;" in home_html,
-          "the nav carries the marked 🎯 Goals entry beside Tasks")
+    check('<a href="/goals">Goals</a>' in home_html,
+          "the nav carries the Goals entry beside Tasks")
     check(any(p["item_type"] == "goal" for p in
               slot_public(auth, "/", "dashboard")["pinned"]),
           "the goal's pending block pins onto the dashboard pile")
@@ -175,9 +176,11 @@ def run(ctx):
           "a resume without context changes carries no context_update")
     state = goal_state(goal1)
     revisions = state["plan_revisions"]
+    # Newest-first since the ordering sweep (73dcec9): index 1 is the
+    # older rejected revision, index 0 the pending re-submission.
     check(len(revisions) == 2
-          and revisions[0]["answered"] and "Add documentation please" in revisions[0]["answer"]
-          and not revisions[1]["answered"],
+          and revisions[1]["answered"] and "Add documentation please" in revisions[1]["answer"]
+          and not revisions[0]["answered"],
           "rejected revisions stay as answered history (historical-blocks data)")
     # Plan 16 S1 pin: goals.plan is accept-gated — the pending revision
     # lives only in its block; nothing lands on the goal before accept.
@@ -458,8 +461,10 @@ def run(ctx):
           "the green progress feed renders on the goal page")
     # Plan revision history keeps its verdicts (approved + rejected).
     revisions = goal_state(goal1)["plan_revisions"]
-    check(revisions[0]["answered"] is True and revisions[0]["approved"] is False
-          and revisions[1]["approved"] is True,
+    # Newest-first since the ordering sweep (73dcec9): index 0 is the
+    # approved revision, index 1 the older rejected one.
+    check(revisions[1]["answered"] is True and revisions[1]["approved"] is False
+          and revisions[0]["approved"] is True,
           "plan revision history badges approved and rejected verdicts")
 
     print("== plan 12: pause freezes supervision; events coalesce on resume ==")
@@ -1014,3 +1019,88 @@ def run(ctx):
           "standalone resumes carry no plan section")
     check(wait_task_done(auth, probe_id, timeout=60) is not None,
           "the probe task completes after its resume")
+
+    print("== goal 4 t1: force wake — the human resume for a parked agent ==")
+    # The goal-2 stall class: the parked run has NO pending wake (a
+    # draft task never enqueued → no terminal transition → parked
+    # forever). Force wake is the human recovery lever.
+    fake.goal_script = [("suspend",)]
+    check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
+                   or False, timeout=60, what="agent parks for force wake") is not None,
+          "the goal agent parks before the force-wake scenario")
+    # The stall fixture: an agent-created draft that was never enqueued
+    # (typo'd enqueue) — statuses must name it as NOT enqueued.
+    status, body = saddle_api("POST", "/api/saddle/task/create", fake_token, {
+        "session_id": agent_session, "title": "QA sweep",
+        "mandate": "Run the QA sweep.", "mode": "test"})
+    check(status == 200 and body.get("ok") is True,
+          "the stall fixture draft task creates (%s)" % body.get("error"))
+    stall_draft = int(body["task_id"])
+    goal_page = http_get_page(auth, "/goals/%d" % goal1)
+    check('stm-action="goal.force_wake"' in goal_page,
+          "the goal page authors the Force wake button while the run is parked")
+    check('stm-if="goal.force_wake != null"' in goal_page,
+          "the Force wake button is gated on the action being offered")
+    # No wake without the press: parked stays parked.
+    parked_runs = len(fake.goal_assignments)
+    check(quiet_window(quiet_seconds(),
+                       lambda: len(fake.goal_assignments) == parked_runs,
+                       "no press, no wake"),
+          "without a press the parked agent never wakes")
+    state = goal_state(goal1)
+    check(state.get("force_wake") is not None,
+          "the goal state exposes the offered force-wake action")
+    act(auth, state["force_wake"], {})
+    check(wait_for(lambda: len(fake.goal_assignments) > parked_runs or False,
+                   timeout=30, what="force-woke agent resumes") is not None,
+          "the force-woke agent resumes immediately (no coalesce wait)")
+    woke_notice = fake.goal_assignments[-1].get("resume_notice") or ""
+    check("force-woke this goal's supervision" in woke_notice
+          and "no pending wake" in woke_notice,
+          "the force-wake resume leads with the explicit wake line")
+    check(("draft (NOT enqueued" in woke_notice)
+          and ("saddle task enqueue %d" % stall_draft) in woke_notice,
+          "the statuses list names the never-enqueued draft with the enqueue fix")
+    check("Task statuses" in woke_notice,
+          "the force-wake notice still carries the standard batched report")
+    # The flag is consumed: re-park (script), no second wake fires.
+    check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
+                   or False, timeout=60, what="woken agent re-parks") is not None,
+          "the woken agent parks again (script)")
+    woke_runs = len(fake.goal_assignments)
+    check(quiet_window(quiet_seconds(),
+                       lambda: len(fake.goal_assignments) == woke_runs,
+                       "consumed flag never re-fires"),
+          "the consumed flag does not re-wake (one wake per press)")
+    # Guards: a paused goal refuses the wake (Resume is its lever).
+    state = goal_state(goal1)
+    act(auth, state["pause"], {})
+    conn = db()
+    paused_flag = conn.execute("select force_wake from goals where id = ?",
+                               (goal1,)).fetchone()
+    conn.close()
+    check(paused_flag is not None and paused_flag[0] in (0, False),
+          "pausing does not set the flag")
+    state = goal_state(goal1)
+    check(state.get("force_wake") is None,
+          "a paused goal does not author the Force wake action")
+    act(auth, state["resume"], {})
+    check(wait_for(lambda: goal_state(goal1)["status"] == "active" or False,
+                   timeout=30, what="goal reactivates") is not None,
+          "the goal reactivates for the guard checks")
+    # Non-suspended run refuses: run is parked again (script parked it
+    # above), wake it, let it park, then a second press while parked is
+    # fine — the 400 path needs a NON-suspended run. Start there: wake
+    # once more and press while it is working.
+    state = goal_state(goal1)
+    act(auth, state["force_wake"], {})
+    check(wait_for(lambda: len(fake.goal_assignments) > woke_runs or False,
+                   timeout=30, what="second wake fires") is not None,
+          "a second press wakes again (re-press is allowed)")
+    # While it is mid-script (not yet parked), the action is hidden.
+    check(goal_state(goal1).get("force_wake") is None
+          or task_state(auth, run_task)["status"] != "suspended",
+          "the wake action hides while the run is not parked")
+    check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
+                   or False, timeout=60, what="run parks for cleanup") is not None,
+          "the run parks again")

+ 82 - 0
test/bundles/goalui.py

@@ -722,3 +722,85 @@ def run(ctx):
           % s2_wrapped)
     check(goal_state(s2_goal)["status"] == "planning",
           "the feedback rejection returns the agent to planning")
+
+
+    # =====================================================================
+    # Task 12 (goal half): the draft brief-card editor.
+    # =====================================================================
+    print("== task 12: the draft goal brief editor ==")
+    # A fresh DRAFT goal (the fixtures above are all started): the
+    # brief card carries the in-place editor with title/brief/priority
+    # prefilled from state, goal.update saves through the sealed
+    # action, and START removes the editor (the brief locks).
+    act(auth, slot_public(auth, "/goals", "goals")["create"], {
+        "project_id": "1", "title": "Editable draft goal",
+        "brief": "The original brief.",
+        "branch_name": "goal/editable-draft"})
+    conn = db()
+    edit_goal = conn.execute(
+        "select id from goals where title = 'Editable draft goal'"
+    ).fetchone()[0]
+    conn.close()
+
+    draft = goal_state(edit_goal)
+    check("update" in draft,
+          "a draft goal's state carries the update action (the editor's save)")
+    goal_page = http_get_page(auth, "/goals/%d" % edit_goal)
+    check('stm-if="goal.update != null"' in goal_page
+          and 'data-lf-toggle>Edit</button>' in goal_page,
+          "the brief card renders the draft-only Edit button")
+    check('data-lf-src="goal.brief"' in goal_page
+          and 'data-lf-src="goal.title"' in goal_page
+          and 'data-lf-src="goal.priority"' in goal_page,
+          "the editor prefills title, brief and priority from state"
+          " (stm-attribute.value cannot carry a textarea)")
+
+    act(auth, draft["update"], {"title": "Editable draft goal (edited)",
+                                "brief": "The edited brief.",
+                                "priority": "7"})
+    saved = goal_state(edit_goal)
+    check(saved["title"] == "Editable draft goal (edited)"
+          and saved["brief"] == "The edited brief."
+          and saved["priority"] == 7,
+          "the draft edit round-trips title, brief and priority"
+          " (draft priority is finally changeable)")
+
+    # The draft-only guard is server-side: start the goal, then the
+    # update action is gone from state (the editor with it).
+    act(auth, saved["start"], {})
+    check(wait_for(lambda: goal_state(edit_goal)["status"] != "draft"
+                   or False, what="editable draft goal starts"),
+          "the edited draft goal starts")
+    check("update" not in goal_state(edit_goal),
+          "a started goal's state carries no update action (the brief locked)")
+
+    # The server-side guard: replaying the (still-valid) sealed
+    # update action against the now-started goal must refuse — the
+    # editor's disappearance is UI, the refusal is enforcement.
+    replay = act(auth, draft["update"], {"brief": "Sneaked brief."})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error"
+              for d in replay),
+          "the draft-only guard refuses an edit replay against a started goal")
+    check(goal_state(edit_goal)["brief"] == "The edited brief.",
+          "the refused replay left the brief unchanged")
+
+    print("== goal 4 t1: force-wake action stays unoffered unless parked ==")
+    # The page template ships the gated button markup statically (the
+    # stm-if hides it client-side), so absence is asserted on the SLOT
+    # STATE: the action is not AUTHORED. The offered pair (parked run)
+    # is covered by the goals bundle, which owns a parked run.
+    check(goal_state(edit_goal).get("force_wake") is None,
+          "a non-parked (planning) goal state offers no force-wake action")
+    conn = db()
+    any_goal = conn.execute(
+        "select id from goals where status = 'draft' order by id limit 1").fetchone()
+    plain = conn.execute(
+        "select id from tasks where status != 'suspended'"
+        " and kind = 'task' order by id limit 1").fetchone()
+    conn.close()
+    if any_goal:
+        check(goal_state(int(any_goal[0])).get("force_wake") is None,
+              "a draft goal state offers no force-wake action")
+    if plain:
+        check(task_state(auth, int(plain[0])).get("force_wake") is None,
+              "a non-suspended task state offers no force-wake action")

+ 126 - 6
test/bundles/pages.py

@@ -348,10 +348,9 @@ def run(ctx):
           and page.index("<h3>Progress</h3>") < page.index("status-head"),
           "the progress feed renders above the status card")
     check(page.index("grid-2") < page.index("status-head")
-          and page.index("status-head") < page.index("<h3>Mandate</h3>"),
+          and page.index("status-head") < page.index('data-long-form data-field="task"'),
           "status and description sit side by side inside the grid (status first)")
-    check("<h3>Mandate</h3>" in page
-          and "plan-expander" not in page,
+    check("Mandate" in page and "plan-expander" not in page,
           "the task page carries no plan expander (plans live on the goal)")
     # Answered blocks fold behind the history expander (this fixture's
     # three blocks are all answered by now).
@@ -775,10 +774,13 @@ def run(ctx):
     act(auth, task_state(auth, keeper)["update"], {"priority": "2"})
     check("Keep this mandate intact." == task_state(auth, keeper)["description"],
           "a priority-only edit leaves the mandate untouched")
-    # And the full edit form prefills the mandate (carried fix).
+    # Task 12: the mandate's edit surface is the in-place card
+    # editor (the expander no longer carries the field); the editor
+    # prefills from state, not stm-attribute.value (a content
+    # attribute, which textareas ignore).
     task_page_html = http_get_page(auth, "/tasks/%d" % keeper)
-    check('stm-attribute.value="task.description"' in task_page_html,
-          "the edit expander pre-populates the mandate textarea")
+    check('data-lf-src="task.description"' in task_page_html,
+          "the mandate card editor prefills the textarea from state")
 
     print("== plan 13 B4: comments display as footnotes, body-first ==")
     # A quoted agent comment on the keeper task drives the display
@@ -807,3 +809,121 @@ def run(ctx):
     check("item.kind == 'comment' || item.kind == 'goal-comment'"
           " || item.kind == 'goal-system' ? 'footnote'" in home_html,
           "the dashboard timeline renders comment rows as footnotes")
+
+    print("== list ordering: newest first everywhere ==")
+    # The ordering sweep: every table leads with the most recent row.
+    # Fixtures land old-then-new; the first rendered data row must be
+    # the newest task on the queue, and the create expander sits above
+    # the table (verified on the compiled page markup).
+    conn = db()
+    conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
+                 " values (1, 'Order probe old', 'queued', 5, strftime('%s','now') - 100)")
+    conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
+                 " values (1, 'Order probe new', 'queued', 5, strftime('%s','now'))")
+    conn.commit()
+    conn.close()
+    rows = [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+            if t["title"].startswith("Order probe")]
+    check(len(rows) == 2 and rows[0]["title"] == "Order probe new",
+          "the tasks table lists newest first (created desc, newest row leads)")
+    status, queue_page = http("GET", "/tasks", timeout=15)
+    check(status == 200
+          and queue_page.index("New task") < queue_page.index("<table"),
+          "the New task expander sits above the tasks table")
+    # Comments/blocks threads: newest first since the sweep. The
+    # yeehaw task above carries session comments; assert the detail's
+    # comment list leads with the most recently added one.
+    conn = db()
+    conn.execute(
+        "insert into task_comments (task_id, author, body, created_at)"
+        " values (?, 'agent', 'Order probe old remark',"
+        " strftime('%s','now') - 100)", (keeper,))
+    conn.execute(
+        "insert into task_comments (task_id, author, body, created_at)"
+        " values (?, 'agent', 'Order probe new remark',"
+        " strftime('%s','now'))", (keeper,))
+    conn.commit()
+    conn.close()
+    bodies = [c["body"] for c in task_state(auth, keeper)["comments"]
+              if c["body"].startswith("Order probe")]
+    check(len(bodies) == 2 and bodies[0] == "Order probe new remark",
+          "the task comment thread leads with the newest remark")
+
+
+    # =====================================================================
+    # Task 12 (task half): the mandate-card in-place editor.
+    # =====================================================================
+    print("== task 12: the mandate card editor ==")
+    act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
+        "project_id": "1", "title": "Mandate editor task", "priority": "4",
+        "description": "# The original mandate\n\nWith a body."})
+    conn = db()
+    mandate_task = conn.execute(
+        "select id from tasks where title = 'Mandate editor task'"
+    ).fetchone()[0]
+    conn.close()
+
+    detail = task_state(auth, mandate_task)
+    check(detail.get("editable_all") is True and detail.get("draft") is True,
+          "the mandate editor task is a draft (every field editable)")
+    task_page = http_get_page(auth, "/tasks/%d" % mandate_task)
+    card_at = task_page.index('data-long-form data-field="task"')
+    edit_at = task_page.index('data-lf-toggle>Edit</button>', card_at)
+    area_at = task_page.index('data-lf-src="task.description"', card_at)
+    check(card_at >= 0 and edit_at > card_at and area_at > card_at,
+          "the mandate card carries the in-place Edit button and editor")
+    check('name="description" rows="8"' in task_page,
+          "the editor's textarea is the wide in-place one")
+    # The expander no longer carries the mandate field (short
+    # properties only) — and no other description textarea exists.
+    expander_at = task_page.index('class="edit-expander"')
+    expander_end = task_page.index('</details>', expander_at)
+    expander_html = task_page[expander_at:expander_end]
+    check('name="description"' not in expander_html,
+          "the edit expander no longer carries the mandate field")
+
+    # The PREFILL contract: the editor reads task.description from
+    # state at open (JS-applied — stm-attribute.value is a no-op on
+    # textarea). The compiled page therefore carries the state path,
+    # and the save is a description-only update.
+    check(task_page.count('data-lf-src="task.description"') == 1,
+          "exactly one mandate editor textarea exists, keyed to state")
+    act(auth, detail["update"], {"task_id": str(mandate_task),
+                                 "description": "# The edited mandate\n\nSecond."})
+    saved = task_state(auth, mandate_task)
+    check(saved["description"] == "# The edited mandate\n\nSecond."
+          and saved["title"] == "Mandate editor task",
+          "the description-only save round-trips and leaves the title alone")
+
+    # Editability rules unchanged: an ACTIVE task keeps the read-only
+    # mandate (no Edit button, no editor textarea) while priority
+    # remains editable in the expander.
+    conn = db()
+    conn.execute("insert into tasks (project_id, title, status, priority,"
+                 " created_at) values (1, 'Active mandate task', 'working', 5,"
+                 " strftime('%s','now'))")
+    conn.commit()
+    active_task = conn.execute(
+        "select id from tasks where title = 'Active mandate task'"
+    ).fetchone()[0]
+    conn.close()
+    active = task_state(auth, active_task)
+    check(active.get("editable_all") is False
+          and active.get("editable_priority") is True,
+          "an active task edits priority only")
+    # The compiled page is state-independent markup: statum gates the
+    # editor through stm-if, which HIDES (never removes) the branch.
+    # What the raw page can promise: the Edit button and the editor
+    # form are BOTH inside task.editable_all-gated branches (statum
+    # hides them at render), while the rendered mandate view carries
+    # no gate at all.
+    active_page = http_get_page(auth, "/tasks/%d" % active_task)
+    card_at = active_page.find('data-long-form data-field="task"')
+    card_end = active_page.find('</section>', card_at)
+    card_html = active_page[card_at:card_end]
+    toggle_at = card_html.find('data-lf-toggle')
+    editor_at = card_html.find('data-lf-edit')
+    check(toggle_at < 0 or 'stm-if="task.editable_all"' in card_html,
+          "an active task's Edit button is draft-gated (stm-if hides it)")
+    check(editor_at < 0 or card_html.rfind('stm-if="task.editable_all"', 0, editor_at) > 0,
+          "an active task's editor form sits inside the draft-gated branch")

+ 144 - 0
test/bundles/push.py

@@ -376,3 +376,147 @@ def run(ctx):
     act(auth, dash["push_unsubscribe"], {"endpoint": endpoint_main})
     check(subscription_count(endpoint_main) == 0,
           "unsubscribing removes the endpoint's row")
+
+    # ------------------------------------------------------------------
+    # Per-browser bell + truthful delivery + test notification
+    # (the notifications-QoL task): the user-global flag lied to a
+    # second browser, refused deliveries were silently "delivered",
+    # and there was no end-to-end probe.
+    # ------------------------------------------------------------------
+
+    def fingerprints_of(auth_handle):
+        state = slot_public(auth_handle, "/", "dashboard")
+        return state.get("push_endpoints") or []
+
+    def fingerprint_of(endpoint):
+        return hashlib.sha256(endpoint.encode()).hexdigest()[:16]
+
+    print("== per-browser bell: fingerprint state shape ==")
+    # The retry fixture left admin subscribed to /flaky; drop it so
+    # this section owns exactly the subscriptions it asserts on.
+    act(auth, dash["push_unsubscribe"], {"endpoint": endpoint_flaky})
+    # Fresh admin subscriptions on TWO fake endpoints = two browsers.
+    push2 = FakePushService()
+    push2.serve()
+    endpoint_a = push2.url("/a")
+    endpoint_b = push2.url("/b")
+    browser_a = ec.generate_private_key(ec.SECP256R1())
+    browser_a_public = browser_a.public_key().public_bytes(
+        Encoding.X962, PublicFormat.UncompressedPoint)
+    browser_a_auth = os.urandom(16)
+    act(auth, dash["push_subscribe"],
+        {"endpoint": endpoint_a, "p256dh": b64url(browser_a_public),
+         "auth": b64url(browser_a_auth)})
+    browser_b = ec.generate_private_key(ec.SECP256R1())
+    browser_b_public = browser_b.public_key().public_bytes(
+        Encoding.X962, PublicFormat.UncompressedPoint)
+    browser_b_auth = os.urandom(16)
+    act(auth, dash["push_subscribe"],
+        {"endpoint": endpoint_b, "p256dh": b64url(browser_b_public),
+         "auth": b64url(browser_b_auth)})
+    dash = slot_public(auth, "/", "dashboard")
+    prints = dash.get("push_endpoints") or []
+    check(isinstance(prints, list) and len(prints) == 2,
+          "two subscriptions => both listed for the user (%s)" % prints)
+    check(fingerprint_of(endpoint_a) in prints
+          and fingerprint_of(endpoint_b) in prints,
+          "each entry is the sha256/16-hex fingerprint of its endpoint")
+    check(endpoint_a not in json.dumps(dash),
+          "the raw endpoint URL never leaves the server")
+    check(bool(dash.get("push_test")) and bool(dash.get("push_subscribe")),
+          "the dashboard carries the test + subscribe actions")
+
+    print("== per-browser bell: second browser still offered Enable ==")
+    # The old user-global flag showed "Turn off" in EVERY browser once
+    # any subscription existed; the per-browser client check keys on
+    # the fingerprint list instead. A third endpoint the server does
+    # NOT know = an unsubscribed browser of the same user: the state
+    # that drives the client verdict must not contain its fingerprint.
+    endpoint_c = push2.url("/c")
+    check(fingerprint_of(endpoint_c) not in fingerprints_of(auth),
+          "an unknown browser's fingerprint is absent — the bell shows Enable there")
+    # ...while the subscribed browsers still see themselves present.
+    check(fingerprint_of(endpoint_a) in fingerprints_of(auth)
+          and fingerprint_of(endpoint_b) in fingerprints_of(auth),
+          "the two subscribed browsers each see themselves in the list")
+
+    print("== test notification: real end-to-end round-trip ==")
+    a_before = len(push2.hits("/a"))
+    b_before = len(push2.hits("/b"))
+    result = act(auth, dash["push_test"], {})
+    message = action_notify(result) or ""
+    check(wait_for(lambda: len(push2.hits("/a")) > a_before
+                   and len(push2.hits("/b")) > b_before, timeout=15,
+                   what="test notification POSTs") is not None,
+          "push_test fires a REAL push to both of the user's endpoints")
+    _, test_payload = decrypt_webpush(push2.hits("/a")[-1], browser_a,
+                                      browser_a_public, browser_a_auth)
+    check(test_payload["title"] == "Test notification"
+          and test_payload["url"] == "/",
+          "the test payload decrypts with title + url (goes to the dashboard)"
+          " (%s)" % test_payload)
+    check("2 browser" in message,
+          "the action response reports both deliveries (%s)" % message)
+    set_types = [d.get("type") for d in result if d.get("type") == "set"]
+    check(len(set_types) >= 1, "the action also refreshes the dashboard slot")
+
+    print("== test notification: failure surfaces the real reason ==")
+    push2.status_for["/b"] = 401
+    b_before = len(push2.hits("/b"))
+    result = act(auth, dash["push_test"], {})
+    message = action_notify(result) or ""
+    check("reached 1 browser" in message and "HTTP 401" in message,
+          "a refused endpoint reports reached/failed + the status (%s)" % message)
+    check(len(push2.hits("/b")) > b_before, "the refused endpoint was still POSTed")
+
+    print("== truthful delivery: 4xx is refused, retried, never delivered ==")
+    # A refused (401) endpoint must NOT count as delivered; the queue
+    # retries on later ticks and — after MAX_ATTEMPTS — gives up WITH a
+    # warning instead of silently claiming success. The subscription
+    # row survives (a VAPID key rotation may heal it).
+    row_before = subscription_count(endpoint_b)
+    b_before = len(push2.hits("/b"))
+    raise_global_block("Refused delivery question?")
+    check(wait_for(lambda: len(push2.hits("/b")) > b_before, timeout=15,
+                   what="first attempt at the refused endpoint") is not None,
+          "the event still reaches the refused endpoint")
+    def give_up_in_log():
+        try:
+            with open(os.path.join(WORK, "app.log")) as handle:
+                return "gave up after %d attempts" % 5 in handle.read()
+        except IOError:
+            return False
+    check(wait_for(give_up_in_log, timeout=30, what="give-up warning in app.log")
+          is not None,
+          "after MAX_ATTEMPTS the give-up warning lands in the log (never silent)")
+    check(subscription_count(endpoint_b) == row_before,
+          "a refused endpoint is NOT pruned (it may recover)")
+    check(len(fingerprints_of(auth)) == 2,
+          "the bell state still lists both endpoints after give-up")
+
+    print("== truthful delivery: 404/410 pruning regression ==")
+    push2.status_for["/b"] = 410
+    b_before = len(push2.hits("/b"))
+    raise_global_block("Prune after refusal question?")
+    check(wait_for(lambda: len(push2.hits("/b")) > b_before, timeout=15,
+                   what="POST to the now-gone endpoint") is not None,
+          "the event reaches the endpoint before its 410")
+    check(wait_for(lambda: subscription_count(endpoint_b) == 0, timeout=15,
+                   what="410 endpoint pruned after refusal episode") is not None,
+          "a 410 endpoint is pruned even after an earlier refusal episode")
+    check(wait_for(lambda: len(fingerprints_of(auth)) == 1, timeout=15,
+                   what="bell state drops the pruned endpoint") is not None,
+          "the refreshed bell state drops the pruned endpoint")
+
+    print("== per-browser bell: markup + client verdict wiring ==")
+    status, home_page = http("GET", "/", timeout=15)
+    check(status == 200 and "data-push-test" in home_page
+          and "data-bell-on" in home_page and "data-bell-off" in home_page,
+          "the bell card ships the per-browser buttons + test button")
+    check("push_endpoints" in home_page and "crypto.subtle.digest" in home_page,
+          "the client fingerprints its own endpoint and matches the list")
+
+    print("== cleanup: unsubscribe the probe fixtures ==")
+    act(auth, dash["push_unsubscribe"], {"endpoint": endpoint_a})
+    check(subscription_count(endpoint_a) == 0,
+          "unsubscribing removes the probe's endpoint row")

+ 151 - 0
test/bundles/users.py

@@ -0,0 +1,151 @@
+"""The users bundle: the user-management page, roles and the profile
+page (the roles decision: normal users manage projects, tasks, goals and
+rules; admins additionally manage users, saddles and capabilities)."""
+
+from harness import *  # noqa: F401,F403
+
+
+def run(ctx):
+    admin = ctx.auth
+
+    check(True, "login yields an auth slot")
+
+    print("== users page: admin CRUD + newest-first ==")
+    users_state = slot_public(admin, "/users", "users")
+    check(users_state["create"] is not None, "admin sees the create action")
+    seeded = [u for u in users_state["users"] if u["username"] == "admin"]
+    check(len(seeded) == 1 and seeded[0]["is_admin"] and seeded[0]["is_me"],
+          "seeded admin row carries the admin badge and is_me")
+
+    result = act(admin, users_state["create"], {
+        "username": "bob", "email": "bob@example.com",
+        "forename": "Bob", "surname": "Builder",
+        "date_of_birth": "1990-05-05",
+        "password": "bobpass1", "confirm_password": "bobpass1"})
+    check("created" in (action_notify(result) or ""), "create bob")
+    result = act(admin, users_state["create"], {
+        "username": "carol", "email": "carol@example.com",
+        "forename": "Carol", "surname": "Coda",
+        "date_of_birth": "1985-03-03",
+        "password": "carolpass", "confirm_password": "carolpass",
+        "admin": "1"})
+    check("created" in (action_notify(result) or ""), "create carol as admin")
+
+    users_state = slot_public(admin, "/users", "users")
+    order = [u["username"] for u in users_state["users"]]
+    check(order == ["carol", "bob", "admin"], "users table is newest-first: %s" % order)
+
+    bob = [u for u in users_state["users"] if u["username"] == "bob"][0]
+    carol = [u for u in users_state["users"] if u["username"] == "carol"][0]
+    me_row = [u for u in users_state["users"] if u["is_me"]][0]
+    check(bob["is_admin"] is False, "bob has the user badge")
+    check(carol["is_admin"] is True, "carol has the admin badge")
+    # The serialiser drops null properties: a row carries exactly one
+    # of grant/revoke, whichever applies to its current role.
+    check(bob.get("grant_admin") is not None and "revoke_admin" not in bob,
+          "non-admin row carries grant (not revoke)")
+    check(carol.get("revoke_admin") is not None and "grant_admin" not in carol,
+          "admin row carries revoke (not grant)")
+    check(me_row["revoke_admin"] is not None, "own row carries revoke (server refuses it)")
+
+    print("== roles: non-admin refused on /users and the admin mutations ==")
+    bob_auth = login("bob", "bobpass1")
+    frames = directives("/users", bob_auth)
+    navs = [d.get("uri") for d in frames if d.get("type") == "navigate"]
+    check(navs == ["/login"], "non-admin /users entrypoint navigates to /login")
+
+    # The saddles page itself stays OPEN to the non-admin; only its
+    # mutations are admin-gated (the roles decision).
+    saddles_state = slot_public(bob_auth, "/saddles", "saddles")
+    check(saddles_state["is_admin"] is False, "saddles page open, controls hidden for non-admin")
+    result = act(bob_auth, saddles_state["register"], {"name": "x", "host": "h:1"})
+    check(any(d.get("type") == "navigate" for d in result), "non-admin register saddle refused")
+    result = act(bob_auth, saddles_state["create_capability"], {"name": "nope"})
+    check(any(d.get("type") == "navigate" for d in result), "non-admin create capability refused")
+
+    # The admin path stays green after the gates.
+    saddles_state = slot_public(admin, "/saddles", "saddles")
+    result = act(admin, saddles_state["create_capability"], {"name": "gpu"})
+    check("created" in (action_notify(result) or ""), "admin creates a capability")
+
+    print("== self-protection guards ==")
+    result = act(admin, me_row["delete"], {})
+    check("own account" in (action_notify(result) or ""), "self-delete refused")
+    result = act(admin, me_row["set_enabled"], {})
+    check("own account" in (action_notify(result) or ""), "self-disable refused")
+    result = act(admin, me_row["revoke_admin"], {})
+    check("own admin" in (action_notify(result) or ""), "self-demote refused")
+    result = act(admin, me_row["alter"], {
+        "username": "admin", "email": "admin@example.com",
+        "forename": "Admin", "surname": "User",
+        "date_of_birth": "1980-01-01", "enabled": "1"})
+    check("own account" in (action_notify(result) or ""), "self-edit refused (belongs on /profile)")
+
+    print("== admin edits another user: rename + reset password ==")
+    result = act(admin, bob["alter"], {
+        "username": "bob", "email": "bob2@example.com",
+        "forename": "Bob", "surname": "B",
+        "date_of_birth": "1990-05-05", "enabled": "1",
+        "new_password": "bobnewpass"})
+    check("saved" in (action_notify(result) or ""), "edit bob (rename + reset password)")
+    login("bob", "bobnewpass")
+    check(True, "bob logs in with the reset password")
+
+    # disable: the session dies on the next guarded request and the
+    # login is refused.
+    users_state = slot_public(admin, "/users", "users")
+    bob = [u for u in users_state["users"] if u["username"] == "bob"][0]
+    result = act(admin, bob["set_enabled"], {})
+    check("disabled" in (action_notify(result) or ""), "disable bob")
+    frames = directives("/projects", bob_auth)
+    navs = [d.get("uri") for d in frames if d.get("type") == "navigate"]
+    check(navs == ["/login"], "disabled bob's entrypoints bounce to /login")
+
+    result = act(admin, bob["delete"], {})
+    check("deleted" in (action_notify(result) or ""), "delete bob")
+
+    print("== role toggles on another user ==")
+    result = act(admin, carol["revoke_admin"], {})
+    check("revoked" in (action_notify(result) or ""), "demote carol (admin remains)")
+    users_state = slot_public(admin, "/users", "users")
+    carol = [u for u in users_state["users"] if u["username"] == "carol"][0]
+    check(carol["is_admin"] is False, "carol's badge flips to user")
+    result = act(admin, carol["grant_admin"], {})
+    check("granted" in (action_notify(result) or ""), "re-grant carol admin")
+
+    print("== profile: every user changes their own password ==")
+    profile = slot_public(admin, "/profile", "profile")
+    check(profile["username"] == "admin" and profile["is_admin"] is True,
+          "profile shows the own row with the role")
+    result = act(admin, profile["change_password"], {
+        "current_password": "WRONG", "new_password": "newpass9",
+        "confirm_password": "newpass9"})
+    check("incorrect" in (action_notify(result) or ""), "wrong current password refused")
+    result = act(admin, profile["change_password"], {
+        "current_password": "adminpass123", "new_password": "newpass9",
+        "confirm_password": "newpass9"})
+    check("changed" in (action_notify(result) or ""), "password changed")
+    login("admin", "newpass9")
+    check(True, "admin logs in with the new password")
+
+    carol_auth = login("carol", "carolpass")
+    carol_profile = slot_public(carol_auth, "/profile", "profile")
+    check(carol_profile["username"] == "carol", "carol's profile shows her row")
+    result = act(carol_auth, carol_profile["change_password"], {
+        "current_password": "carolpass", "new_password": "carolnew1",
+        "confirm_password": "carolnew1"})
+    check("changed" in (action_notify(result) or ""), "carol changes her password")
+    login("carol", "carolnew1")
+    check(True, "carol logs in with the new password")
+
+    # The seeded admin password has changed mid-bundle; later login
+    # helpers (if any re-login) use the new one.
+    print("== cleanup: single admin remains ==")
+    users_state = slot_public(admin, "/users", "users")
+    carol = [u for u in users_state["users"] if u["username"] == "carol"][0]
+    result = act(admin, carol["revoke_admin"], {})
+    check("revoked" in (action_notify(result) or ""), "demote carol before delete")
+    result = act(admin, carol["delete"], {})
+    check("deleted" in (action_notify(result) or ""), "delete carol")
+    check([u["username"] for u in slot_public(admin, "/users", "users")["users"]] == ["admin"],
+          "the admin remains the sole user")

+ 2 - 1
test/harness.py

@@ -139,6 +139,7 @@ Speed pass (plan 11, task 0):
   SADDLE_STALE_S=3, plus the fake daemon's scripted timings — the
   wire-traffic shapes stay realistic, just faster.
 """
+import hashlib
 import json
 import os
 import re
@@ -1270,7 +1271,7 @@ def start_bundle(name):
 
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
                  "allocation", "lifecycle", "answers", "push", "goals",
-                 "replay", "goalui")
+                 "replay", "goalui", "forcewake", "users")
 
 
 def run_bundle(name):