Просмотр исходного кода

push: plan 11 task E — real Web Push (VAPID) notifications

- VAPID P-256 keypair generated at startup when missing and persisted
  into web-config.json's vapid section (validated on load: the public
  point is re-derived and compared); keys load before the server
  accepts requests so subscribers always see the same key
- real protocol, no third party: RFC 8291 payload encryption (ECDH +
  HKDF-SHA256 + AES-128-GCM, aes128gmm content coding) and an ES256
  VAPID JWT per send, over a small OpenSSL shim (src/Push, C) —
  P-256 keygen/ECDH/ECDSA + AES-GCM behind pointer externs
- service worker at GET /sw.js (origin root, Service-Worker-Allowed):
  push handler shows {title, body, url}; notificationclick focuses an
  open window on the task page or opens one
- subscribe flow: dashboard bell (permission -> SW registration ->
  pushManager.subscribe with the page's VAPID key -> sealed
  push_subscribe action); endpoint/p256dh/auth validated (absolute
  http(s) URL, 65-byte point, 16-byte auth) and stored via the existing
  PushSubscription entity, re-subscribe replaces per endpoint;
  user-scoped unsubscribe
- per-task "Notify me" / "Don't notify me" on the status card backed
  by TaskNotifyPref
- payload matrix fired from the same places the pulse bumps (single
  funnel): question/RFC/review/plan blocks and the suspended-waiting-
  on-drafts condition go to ALL push-subscribed users (auto-answered
  blocks are skipped — they never block a human); first start (at the
  allocation), agent failure and completion go to the task's opted-in
  users only; human cancels do not notify
- best-effort delivery from a singleton queue: immediate flush, failed
  sends retry on the dispatcher tick (capped), 404/410 endpoints are
  pruned; waiting-on-drafts fires from the suspension path and when a
  subtask lands in draft under an already-parked parent
- tests: new push shard (43 checks) — SW asset, VAPID persistence +
  pair validity, subscription CRUD + validation matrix, notify-pref
  persistence, dispatch matrix with a fake push service that DECRYPTS
  the RFC 8291 body and verifies the ES256 JWT (title/body/click-URL,
  envelope record size, TTL/content-type), 410 pruning, retry-next-tick
- README documents the VAPID key location, rotation and the browser
  opt-in flow
clanker 1 месяц назад
Родитель
Сommit
9f5ab7ab36

+ 21 - 0
README.md

@@ -191,6 +191,27 @@ returns "Plan feedback received:\n…" — and the per-task auto-approve
 option (or an ancestor's subtask auto-approve toggle) answers plan
 blocks instantly web-side.
 
+## Browser notifications (Web Push)
+
+The app speaks the real Web Push protocol (VAPID + RFC 8291 payload
+encryption, no third-party service). The VAPID keypair is generated at
+startup on first run and persisted in **`web-config.json`** under the
+`vapid` section (`public_key` / `private_key`, base64url P-256) — that
+file is the single home for the keys; delete the section to rotate
+(regenerate = restart). `SADDLE_VAPID_SUBJECT` overrides the JWT's
+`mailto:` contact (default `mailto:clanker-saddle@localhost`).
+
+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; questions, RFCs,
+review requests and the suspended-waiting-on-drafts condition notify
+every subscribed user. Clicking a notification opens the task's page.
+Delivery is best-effort: failed sends retry on the dispatcher tick and
+endpoints answering 410/404 are pruned automatically.
+
 ## Rules and project notes
 
 **Rules** (`/rules`) are reusable instructions dispatched with every task

+ 14 - 1
meson.build

@@ -17,6 +17,10 @@ inversion_dep = dependency('inversion-0.1')
 astralis_dep = dependency('astralis-0.1')
 statum_dep = dependency('statum-0.1')
 spry_dep = dependency('spry-0.2')
+# Web Push crypto primitives (P-256 ECDH/ECDSA + AES-128-GCM for VAPID
+# and RFC 8291 payload encryption — plan 11 task E). Found via the C
+# compiler (no Vala vapi exists for libcrypto; the shim is C-only).
+libcrypto_dep = meson.get_compiler('c').find_library('crypto')
 
 # Statum build tools — statum-mkpstm compiles pages, statum-mkres embeds
 # resources. Resolved from PATH at setup time.
@@ -118,8 +122,10 @@ app_sources = files(
     'src/Services/CapabilityService.vala',
     'src/Services/SubtaskEventQueue.vala',
     'src/Services/TimelineService.vala',
+    'src/Services/PushService.vala',
     'src/Api/SaddleApi.vala',
     'src/Api/TerminalSse.vala',
+    'src/Api/PushApi.vala',
     'src/actions/Forms.vala',
     'src/actions/LoginAction.vala',
     'src/actions/ProjectActions.vala',
@@ -128,6 +134,7 @@ app_sources = files(
     'src/actions/SaddleActions.vala',
     'src/actions/RuleActions.vala',
     'src/actions/CapabilityActions.vala',
+    'src/actions/PushActions.vala',
     'src/entrypoints/HomeEntrypoint.vala',
     'src/entrypoints/LoginEntrypoint.vala',
     'src/entrypoints/ProjectsEntrypoint.vala',
@@ -139,6 +146,10 @@ app_sources = files(
     # spry:sources-end
 )
 
+# Web Push crypto shim (plan 11 task E): C over OpenSSL's libcrypto —
+# P-256 keygen/ECDH/ECDSA + AES-128-GCM behind web-push-crypto.vapi.
+push_crypto = files('src/Push/web-push-crypto.c')
+
 app_generated = [
     # spry:generated-begin
     home_page,
@@ -154,7 +165,8 @@ app_generated = [
 ]
 
 clanker_saddle_exe = executable('clanker-saddle',
-    app_sources + app_generated + [app_css],
+    app_sources + app_generated + [app_css, push_crypto],
+    include_directories: include_directories('src/Push'),
     dependencies: [
         glib_dep,
         gobject_dep,
@@ -171,6 +183,7 @@ clanker_saddle_exe = executable('clanker-saddle',
         astralis_dep,
         statum_dep,
         spry_dep,
+        libcrypto_dep,
     ],
     install: true
 )

+ 68 - 0
src/Api/PushApi.vala

@@ -0,0 +1,68 @@
+using Astralis;
+using Invercargill.DataStructures;
+using Inversion;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle.Api {
+
+    /**
+     * `GET /sw.js` (plan 11 task E) — the Web Push service worker, a
+     * static asset served at the origin root so its scope covers every
+     * page. The push handler shows the notification the app built
+     * ({title, body, url}); `notificationclick` focuses an open window
+     * on the task page or opens one. Deliberately unauthenticated: the
+     * browser fetches service workers without statum headers.
+     */
+    public class ServiceWorkerEndpoint : Object, Endpoint {
+
+        private const string SCRIPT = """
+self.addEventListener('install', function (event) {
+    event.waitUntil(self.skipWaiting());
+});
+
+self.addEventListener('activate', function (event) {
+    event.waitUntil(self.clients.claim());
+});
+
+// The app's payload: {title, body, url} — see PushService.
+self.addEventListener('push', function (event) {
+    var data = {};
+    try {
+        data = event.data ? event.data.json() : {};
+    } catch (error) {
+        data = {};
+    }
+    var title = data.title || 'ClankerSaddle';
+    event.waitUntil(self.registration.showNotification(title, {
+        body: data.body || '',
+        tag: data.url || 'clanker-saddle',
+        data: { url: data.url || '/' }
+    }));
+});
+
+self.addEventListener('notificationclick', function (event) {
+    event.notification.close();
+    var url = (event.notification.data && event.notification.data.url) || '/';
+    var absolute = new URL(url, self.location.origin).href;
+    event.waitUntil(self.clients.matchAll({ type: 'window' }).then(function (windows) {
+        for (var index = 0; index < windows.length; index++) {
+            var client = windows[index];
+            if (client.url === absolute && 'focus' in client) {
+                return client.focus();
+            }
+        }
+        return self.clients.openWindow(absolute);
+    }));
+});
+""";
+
+        public virtual async HttpResult handle_request(HttpContext http_context,
+                RouteContext route_context) throws Error {
+            var result = new HttpDataResult(new ByteBuffer.from_byte_array(SCRIPT.data));
+            result.set_header("Content-Type", "application/javascript");
+            result.set_header("Service-Worker-Allowed", "/");
+            result.set_header("Cache-Control", "no-cache");
+            return result;
+        }
+    }
+}

+ 5 - 1
src/Api/SaddleApi.vala

@@ -672,9 +672,13 @@ namespace ClankerSaddle.Api {
                 // created under a parent carrying it enqueues immediately
                 // — after its capabilities landed, so the queue only ever
                 // sees the full requirement set. Otherwise the subtask
-                // stays in draft for the human Enqueue action.
+                // stays in draft for the human Enqueue action (and, when
+                // the parent is parked, (re)arms the waiting-on-drafts
+                // push).
                 if (created && ((!)parent).task.auto_enqueue_subtasks) {
                     yield tasks.enqueue_task(task.id);
+                } else if (created) {
+                    yield tasks.notify_waiting_on_drafts_if_parked(parent_task_id);
                 }
                 var response = new Json.Object();
                 response.set_int_member("task_id", task.id);

+ 192 - 0
src/Push/web-push-crypto.c

@@ -0,0 +1,192 @@
+/* Web Push crypto primitives (plan 11 task E): the P-256 and AES-GCM
+   operations VAPID + RFC 8291 payload encryption need, as plain C over
+   OpenSSL's libcrypto. Raw in, raw out — the Vala side owns HKDF,
+   base64url and the envelope/JWT shapes.
+
+   Every function returns 1 on success, 0 on failure (bad input lengths
+   or an OpenSSL error); callers fail the send, never the request. */
+#define OPENSSL_SUPPRESS_DEPRECATED
+#include <openssl/bn.h>
+#include <openssl/ec.h>
+#include <openssl/ecdh.h>
+#include <openssl/ecdsa.h>
+#include <openssl/evp.h>
+#include <openssl/rand.h>
+#include <string.h>
+#include "web-push-crypto.h"
+
+static EC_GROUP *p256_group(void)
+{
+	static EC_GROUP *group = NULL;
+	if (group == NULL)
+		group = EC_GROUP_new_by_curve_name(NID_X9_62_prime256v1);
+	return group;
+}
+
+static EC_KEY *key_from_private(const unsigned char priv[32])
+{
+	BIGNUM *scalar = BN_bin2bn(priv, 32, NULL);
+	EC_KEY *key = EC_KEY_new();
+	EC_POINT *public_point = NULL;
+	if (key == NULL || scalar == NULL)
+		goto fail;
+	if (EC_KEY_set_group(key, p256_group()) != 1)
+		goto fail;
+	if (EC_KEY_set_private_key(key, scalar) != 1)
+		goto fail;
+	public_point = EC_POINT_new(p256_group());
+	if (public_point == NULL)
+		goto fail;
+	if (EC_POINT_mul(p256_group(), public_point, scalar, NULL, NULL, NULL) != 1)
+		goto fail;
+	if (EC_KEY_set_public_key(key, public_point) != 1)
+		goto fail;
+	EC_POINT_free(public_point);
+	BN_free(scalar);
+	return key;
+fail:
+	EC_POINT_free(public_point);
+	BN_free(scalar);
+	EC_KEY_free(key);
+	return NULL;
+}
+
+int ws_p256_generate(unsigned char priv[32], unsigned char pub[65])
+{
+	EC_KEY *key = EC_KEY_new_by_curve_name(NID_X9_62_prime256v1);
+	const BIGNUM *scalar = NULL;
+	const EC_POINT *point = NULL;
+	size_t written = 0;
+	int ok = 0;
+
+	if (key == NULL)
+		return 0;
+	if (EC_KEY_generate_key(key) != 1)
+		goto out;
+	scalar = EC_KEY_get0_private_key(key);
+	point = EC_KEY_get0_public_key(key);
+	if (scalar == NULL || point == NULL)
+		goto out;
+	if (BN_num_bytes(scalar) > 32)
+		goto out;
+	memset(priv, 0, 32);
+	BN_bn2bin(scalar, priv + (32 - BN_num_bytes(scalar)));
+	written = EC_POINT_point2oct(p256_group(), point,
+		POINT_CONVERSION_UNCOMPRESSED, pub, 65, NULL);
+	ok = written == 65;
+out:
+	EC_KEY_free(key);
+	return ok;
+}
+
+int ws_p256_public_from_private(const unsigned char priv[32],
+                                unsigned char pub[65])
+{
+	EC_KEY *key = key_from_private(priv);
+	const EC_POINT *point = NULL;
+	int ok = 0;
+
+	if (key == NULL)
+		return 0;
+	point = EC_KEY_get0_public_key(key);
+	if (point != NULL)
+		ok = EC_POINT_point2oct(p256_group(), point,
+			POINT_CONVERSION_UNCOMPRESSED, pub, 65, NULL) == 65;
+	EC_KEY_free(key);
+	return ok;
+}
+
+int ws_p256_ecdh(const unsigned char priv[32], const unsigned char peer[65],
+                 unsigned char secret[32])
+{
+	EC_KEY *key = key_from_private(priv);
+	EC_POINT *peer_point = NULL;
+	int ok = 0;
+
+	if (key == NULL)
+		return 0;
+	peer_point = EC_POINT_new(p256_group());
+	if (peer_point == NULL)
+		goto out;
+	if (EC_POINT_oct2point(p256_group(), peer_point, peer, 65, NULL) != 1)
+		goto out;
+	if (EC_POINT_is_on_curve(p256_group(), peer_point, NULL) != 1)
+		goto out;
+	if (ECDH_compute_key(secret, 32, peer_point, key, NULL) != 32)
+		goto out;
+	ok = 1;
+out:
+	EC_POINT_free(peer_point);
+	EC_KEY_free(key);
+	return ok;
+}
+
+/* ES256's raw r||s (64 bytes) over a 32-byte SHA-256 digest. */
+int ws_p256_sign(const unsigned char priv[32], const unsigned char digest[32],
+                 unsigned char sig[64])
+{
+	EC_KEY *key = key_from_private(priv);
+	ECDSA_SIG *signature = NULL;
+	const BIGNUM *r = NULL;
+	const BIGNUM *s = NULL;
+	int ok = 0;
+
+	if (key == NULL)
+		return 0;
+	signature = ECDSA_do_sign(digest, 32, key);
+	if (signature == NULL)
+		goto out;
+	ECDSA_SIG_get0(signature, &r, &s);
+	if (BN_num_bytes(r) > 32 || BN_num_bytes(s) > 32)
+		goto out;
+	memset(sig, 0, 64);
+	BN_bn2bin(r, sig + (32 - BN_num_bytes(r)));
+	BN_bn2bin(s, sig + 32 + (32 - BN_num_bytes(s)));
+	ok = 1;
+out:
+	ECDSA_SIG_free(signature);
+	EC_KEY_free(key);
+	return ok;
+}
+
+/* AES-128-GCM (16-byte key, 12-byte IV): out receives ciphertext ||
+   16-byte tag; *out_len is the total. out must hold pt_len + 16. */
+int ws_aes128gcm_encrypt(const unsigned char *key, const unsigned char *iv,
+                         const unsigned char *plaintext, size_t pt_len,
+                         unsigned char *out, size_t *out_len)
+{
+	EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
+	size_t total = 0;
+	int written = 0;
+	int ok = 0;
+
+	if (ctx == NULL)
+		return 0;
+	if (EVP_EncryptInit_ex(ctx, EVP_aes_128_gcm(), NULL, NULL, NULL) != 1)
+		goto out;
+	if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, 12, NULL) != 1)
+		goto out;
+	if (EVP_EncryptInit_ex(ctx, NULL, NULL, key, iv) != 1)
+		goto out;
+	if (pt_len > 0
+	    && EVP_EncryptUpdate(ctx, out, &written, plaintext, (int) pt_len) != 1)
+		goto out;
+	total = (size_t) written;
+	if (EVP_EncryptFinal_ex(ctx, out + total, &written) != 1)
+		goto out;
+	total += (size_t) written;
+	if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, 16, out + total) != 1)
+		goto out;
+	total += 16;
+	*out_len = total;
+	ok = 1;
+out:
+	EVP_CIPHER_CTX_free(ctx);
+	return ok;
+}
+
+/* CSPRNG bytes (OpenSSL's RAND): 1 on success. */
+int ws_random(unsigned char *buf, size_t len)
+{
+	return RAND_bytes(buf, (int) len) == 1;
+}

+ 42 - 0
src/Push/web-push-crypto.h

@@ -0,0 +1,42 @@
+/* See web-push-crypto.c — raw P-256 / AES-GCM primitives for Web Push. */
+#ifndef CLANKER_SADDLE_WEB_PUSH_CRYPTO_H
+#define CLANKER_SADDLE_WEB_PUSH_CRYPTO_H
+
+#include <stddef.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* Generates a P-256 keypair: priv = 32-byte scalar, pub = 65-byte
+   uncompressed point (0x04 || X || Y). */
+int ws_p256_generate(unsigned char priv[32], unsigned char pub[65]);
+
+/* Derives the 65-byte public point of a 32-byte private scalar. */
+int ws_p256_public_from_private(const unsigned char priv[32],
+                                unsigned char pub[65]);
+
+/* ECDH: the 32-byte shared x coordinate of priv and the 65-byte peer
+   point. */
+int ws_p256_ecdh(const unsigned char priv[32], const unsigned char peer[65],
+                 unsigned char secret[32]);
+
+/* ECDSA over a 32-byte digest; sig = raw 64-byte r||s (the ES256/JWS
+   signature form). */
+int ws_p256_sign(const unsigned char priv[32], const unsigned char digest[32],
+                 unsigned char sig[64]);
+
+/* AES-128-GCM (16-byte key, 12-byte IV); out holds ciphertext || 16-byte
+   tag, so must have pt_len + 16 bytes of capacity. */
+int ws_aes128gcm_encrypt(const unsigned char *key, const unsigned char *iv,
+                         const unsigned char *plaintext, size_t pt_len,
+                         unsigned char *out, size_t *out_len);
+
+/* CSPRNG bytes (OpenSSL RAND). */
+int ws_random(unsigned char *buf, size_t len);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif

+ 13 - 1
src/Services/AllocationService.vala

@@ -36,6 +36,8 @@ namespace ClankerSaddle.Services {
         public int64 session_id { get; set; }
         public bool parked { get; set; }
         public bool suspended_resume { get; set; }
+        /** The task had never started before this claim (its first dispatch). */
+        public bool first_start { get; set; }
     }
 
     /**
@@ -73,6 +75,7 @@ namespace ClankerSaddle.Services {
         private CapabilityService capabilities = inject<CapabilityService>();
         private SubtaskEventQueue subtask_events = inject<SubtaskEventQueue>();
         private StatePulse pulse = inject<StatePulse>();
+        private PushService push = inject<PushService>();
         private SaddlePollState poll_state = inject<SaddlePollState>();
 
         /**
@@ -256,6 +259,7 @@ namespace ClankerSaddle.Services {
                     session_id = session.id;
                 }
 
+                bool first_start = ((!)task).started_at == null;
                 ((!)task).status = STATUS_ASSIGNED;
                 ((!)task).started_at = new DateTime.now_local();
                 db.update<TaskEntity>((!)task);
@@ -270,7 +274,8 @@ namespace ClankerSaddle.Services {
                     task_id = candidate.task.id,
                     session_id = session_id,
                     parked = candidate.parked,
-                    suspended_resume = candidate.suspended_resume
+                    suspended_resume = candidate.suspended_resume,
+                    first_start = first_start
                 };
             } catch (Error e) {
                 try {
@@ -360,6 +365,13 @@ namespace ClankerSaddle.Services {
             yield saddles.mark_task_comments_delivered(task.id);
             subtask_events.record_dispatch(task);
             yield pulse.bump(task.id, "task dispatched");
+            // Web Push (plan 11 task E): the FIRST dispatch of a task
+            // notifies its opted-in users — fired from the same place
+            // the dispatch pulse bumps (single funnel).
+            if (claim.first_start) {
+                push.notify_opted_in(task.id, PushService.lifecycle_notification(
+                    task.title, task.id, "started", null));
+            }
             print(@"[ClankerSaddle] Allocated task $(task.id) to saddle '$(saddle.name)' on ask (session $(claim.session_id))\n");
             return payload;
         }

+ 5 - 0
src/Services/Dispatcher.vala

@@ -29,6 +29,7 @@ namespace ClankerSaddle.Services {
 
         private Container container = inject<Container>();
         private SaddlePollState poll_state = inject<SaddlePollState>();
+        private PushService push = inject<PushService>();
         private bool ticking = false;
         private int64 tick_started_ms = 0;
         private bool warned_no_llm = false;
@@ -126,6 +127,10 @@ namespace ClankerSaddle.Services {
             yield saddles.requeue_tasks_on_offline_saddles(tasks);
             yield saddles.deliver_pending_subtask_events(tasks);
 
+            // Web Push retry tick (plan 11 task E): anything a failed
+            // send left queued retries here (best-effort delivery).
+            yield push.flush();
+
             foreach (var saddle in yield saddles.list_busy_saddles()) {
                 // Human comments ride POST /task-update to the RUNNING
                 // daemon (best effort — a transport failure stays

+ 790 - 0
src/Services/PushService.vala

@@ -0,0 +1,790 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql;
+using InvercargillSql.Orm;
+using Inversion;
+using Soup;
+using Astralis;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    // ---- OpenSSL shim (src/Push/web-push-crypto.c): raw P-256 and
+    // AES-GCM primitives. Pointer parameters deliberately — no array
+    // lengths cross the boundary. ----
+    [CCode (cname = "ws_p256_generate")]
+    internal static extern int crypto_p256_generate(uint8 *priv, uint8 *pub);
+
+    [CCode (cname = "ws_p256_public_from_private")]
+    internal static extern int crypto_p256_public_from_private(uint8 *priv, uint8 *pub);
+
+    [CCode (cname = "ws_p256_ecdh")]
+    internal static extern int crypto_p256_ecdh(uint8 *priv, uint8 *peer, uint8 *secret);
+
+    [CCode (cname = "ws_p256_sign")]
+    internal static extern int crypto_p256_sign(uint8 *priv, uint8 *digest, uint8 *sig);
+
+    [CCode (cname = "ws_aes128gcm_encrypt")]
+    internal static extern int crypto_aes128gcm_encrypt(uint8 *key, uint8 *iv,
+        uint8 *plaintext, size_t pt_len, uint8 *out, out size_t out_len);
+
+    [CCode (cname = "ws_random")]
+    internal static extern int crypto_random(uint8 *buf, size_t len);
+
+    /**
+     * One outgoing notification (plan 11 task E): `title` carries the
+     * task title plus the event kind, `body` the question or summary,
+     * `url` the task page the service worker routes a click to. Pure
+     * data — the payload builder below is deterministic so tests can
+     * assert on it end-to-end (the test harness owns the client keys,
+     * so it can decrypt what was sent).
+     */
+    public class PushNotification : Object {
+        public string title { get; set; default = ""; }
+        public string body { get; set; default = ""; }
+        public string url { 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
+     * dir (a `vapid` section inside web-config.json); payloads are
+     * encrypted per RFC 8291 (ECDH P-256 + HKDF + AES-128-GCM,
+     * aes128gcm content coding) and authorised by an ES256 VAPID JWT.
+     *
+     * Targeting: BLOCK events and the suspended-waiting-on-drafts
+     * condition go to every push-subscribed user globally; a task's
+     * first start, failure and completion go only to users holding a
+     * `task_notify_prefs` row for it ("Notify me").
+     *
+     * Delivery is best-effort from a singleton queue: enqueues are
+     * in-memory (they never block the request), a flush is kicked
+     * immediately and re-run by the dispatcher's tick while anything
+     * remains (a failed send retries next tick, up to
+     * {@link MAX_ATTEMPTS}); endpoints answering 410 Gone (or 404) are
+     * pruned — the browser already destroyed the subscription.
+     */
+    public class PushService : Object {
+
+        private const int MAX_ATTEMPTS = 5;
+
+        /** The persisted keypair, base64url (empty before {@link ensure_vapid_keys}). */
+        public static string vapid_public_key { get; private set; default = ""; }
+        private static string vapid_private_key = "";
+
+        private Container container = inject<Container>();
+        private WebConfig web_config = inject<WebConfig>();
+        private Soup.Session http = new Soup.Session() {
+            timeout = 10,
+            idle_timeout = 10
+        };
+
+        /** {@link ensure_vapid_keys} with the injected config (main's startup call). */
+        public void ensure_keys_from_config() {
+            ensure_vapid_keys(web_config);
+        }
+
+        /** A resolved send: one endpoint with its keys and plaintext payload. */
+        private class SendJob : Object {
+            public string endpoint = "";
+            public string p256dh = "";
+            public string auth = "";
+            public string payload = "";
+            public int attempts = 0;
+        }
+
+        /** A queued notification before target resolution. */
+        private class PendingTarget : Object {
+            public bool global = false;
+            public int64 task_id = 0;
+            public PushNotification notification;
+        }
+
+        private GLib.Queue<PendingTarget> pending = new GLib.Queue<PendingTarget>();
+        private GLib.Queue<SendJob> queued = new GLib.Queue<SendJob>();
+        private Mutex queue_lock = Mutex();
+        private bool flushing = false;
+
+        // ------------------------------------------------------------------
+        // VAPID keys
+        // ------------------------------------------------------------------
+
+        /**
+         * Loads the VAPID keypair from the config's `vapid` section,
+         * generating and persisting a fresh one when missing (or when
+         * the stored pair does not match — the public point is derived
+         * from the private scalar and compared). Called once from
+         * `main` before the server accepts requests, so every
+         * subscriber always sees the same public key.
+         */
+        public static void ensure_vapid_keys(WebConfig config) {
+            string stored_public = "";
+            string stored_private = "";
+            var section = config != null ? config.get_section("vapid") : null;
+            if (section != null) {
+                stored_public = ((!)section).get_string("public_key", "");
+                stored_private = ((!)section).get_string("private_key", "");
+            }
+            if (keypair_valid(stored_public, stored_private)) {
+                vapid_public_key = stored_public;
+                vapid_private_key = stored_private;
+                return;
+            }
+
+            var priv = new uint8[32];
+            var pub = new uint8[65];
+            if (crypto_p256_generate((uint8*) priv, (uint8*) pub) != 1) {
+                warning("[ClankerSaddle] VAPID keypair generation failed;"
+                    + " web push disabled for this run");
+                return;
+            }
+            vapid_private_key = base64url_encode(priv);
+            vapid_public_key = base64url_encode(pub);
+            persist_vapid_keys(config);
+            print("[ClankerSaddle] Generated VAPID keypair (public key: %s)\n",
+                vapid_public_key);
+        }
+
+        /** True when the stored pair decodes and the public point matches. */
+        private static bool keypair_valid(string public_b64, string private_b64) {
+            uint8[] priv = base64url_decode(private_b64);
+            uint8[] pub = base64url_decode(public_b64);
+            if (priv.length != 32 || pub.length != 65 || pub[0] != 0x04) {
+                return false;
+            }
+            var derived = new uint8[65];
+            if (crypto_p256_public_from_private((uint8*) priv, (uint8*) derived) != 1) {
+                return false;
+            }
+            for (var index = 0; index < 65; index++) {
+                if (derived[index] != pub[index]) {
+                    return false;
+                }
+            }
+            return true;
+        }
+
+        /**
+         * Writes the `vapid` section into the config file the app
+         * loaded (first loaded file; when nothing was loaded, the first
+         * discovered path — created). Existing keys and sections are
+         * preserved: the file is re-parsed and only `vapid` replaced.
+         */
+        private static void persist_vapid_keys(WebConfig config) {
+            string path = "";
+            string[] loaded = config != null ? config.get_loaded_files() : new string[0];
+            if (loaded.length > 0) {
+                path = loaded[0];
+            } else {
+                try {
+                    string[] discovered = WebConfigLoader.discover_config_paths();
+                    if (discovered.length > 0) {
+                        path = discovered[0];
+                    }
+                } catch (Error e) {
+                    path = "";
+                }
+            }
+            if (path.length == 0) {
+                warning("[ClankerSaddle] No config file to persist the VAPID"
+                    + " keypair into; keys are in-memory for this run only");
+                return;
+            }
+
+            try {
+                string text = "{}";
+                if (FileUtils.test(path, FileTest.EXISTS)) {
+                    string contents;
+                    FileUtils.get_contents(path, out contents);
+                    text = contents.strip().length > 0 ? contents : "{}";
+                }
+                var parser = new Json.Parser();
+                parser.load_from_data(text);
+                var root = parser.get_root();
+                var document = root != null && root.get_node_type() == Json.NodeType.OBJECT
+                    ? root.get_object() : new Json.Object();
+
+                var vapid = new Json.Object();
+                vapid.set_string_member("public_key", vapid_public_key);
+                vapid.set_string_member("private_key", vapid_private_key);
+                document.set_object_member("vapid", vapid);
+
+                var generator = new Json.Generator() {
+                    pretty = true,
+                    indent = 2
+                };
+                generator.set_root(new Json.Node(Json.NodeType.OBJECT).init_object(document));
+                FileUtils.set_contents(path, generator.to_data(null) + "\n");
+            } catch (Error e) {
+                warning("[ClankerSaddle] Persisting the VAPID keypair into %s"
+                    + " failed: %s", path, e.message);
+            }
+        }
+
+        // ------------------------------------------------------------------
+        // Payload builder
+        // ------------------------------------------------------------------
+
+        /** The human label of a block kind in notification titles. */
+        public static string block_kind_label(string block_type) {
+            switch (block_type) {
+                case BLOCK_RFC:
+                    return "RFC raised";
+                case BLOCK_REVIEW:
+                    return "review requested";
+                case BLOCK_PLAN:
+                    return "plan review";
+                default:
+                    return "question raised";
+            }
+        }
+
+        /**
+         * The block-event payload (global audience): title = task title
+         * + kind, body = the question, click-through = the task page.
+         */
+        public static PushNotification block_notification(string task_title,
+                int64 task_id, string block_type, string question) {
+            return new PushNotification() {
+                title = @"$(task_title) — $(block_kind_label(block_type))",
+                body = question,
+                url = @"/tasks/$(task_id)"
+            };
+        }
+
+        /**
+         * The opted-in lifecycle payload: `started` (first start),
+         * `completed` or `failed`, with the task's summary as the body.
+         */
+        public static PushNotification lifecycle_notification(string task_title,
+                int64 task_id, string kind, string? summary) {
+            return new PushNotification() {
+                title = @"$(task_title) — $(kind)",
+                body = summary != null && ((!)summary).length > 0
+                    ? (!)summary : @"Task $(task_id) $(kind)",
+                url = @"/tasks/$(task_id)"
+            };
+        }
+
+        /**
+         * The waiting-on-drafts payload (global audience, fired like a
+         * block event): the parent is parked and cannot finish until
+         * the human enqueues its draft subtasks.
+         */
+        public static PushNotification waiting_drafts_notification(string task_title,
+                int64 task_id, int64 draft_count) {
+            return new PushNotification() {
+                title = @"$(task_title) — waiting on subtasks to be enqueued",
+                body = @"Parked with $(draft_count) subtask(s) still in draft —"
+                    + " enqueue them so this task can finish",
+                url = @"/tasks/$(task_id)"
+            };
+        }
+
+        // ------------------------------------------------------------------
+        // Enqueue + delivery
+        // ------------------------------------------------------------------
+
+        /** Queues a notification for every push-subscribed user (block events). */
+        public void notify_global(PushNotification notification) {
+            enqueue_target(new PendingTarget() {
+                global = true,
+                notification = notification
+            });
+        }
+
+        /**
+         * Queues a notification for the users holding a "Notify me"
+         * pref for `task_id` (first start / failure / completion).
+         */
+        public void notify_opted_in(int64 task_id, PushNotification notification) {
+            enqueue_target(new PendingTarget() {
+                global = false,
+                task_id = task_id,
+                notification = notification
+            });
+        }
+
+        private void enqueue_target(PendingTarget target) {
+            if (vapid_private_key.length == 0) {
+                return;
+            }
+            queue_lock.lock();
+            pending.push_tail(target);
+            queue_lock.unlock();
+            flush.begin();
+        }
+
+        /**
+         * Delivers everything queued: resolves pending targets into
+         * per-endpoint jobs (a fresh database scope — subscriptions may
+         * have changed since the event), then sends each job. Failures
+         * stay queued for the next run (the dispatcher's tick calls
+         * this); 410/404 endpoints are pruned instead of retried.
+         * Single-flight: a concurrent call returns immediately while a
+         * flush is in progress.
+         */
+        public async void flush() {
+            if (flushing) {
+                return;
+            }
+            flushing = true;
+            try {
+                yield resolve_pending();
+                while (true) {
+                    queue_lock.lock();
+                    SendJob? job = queued.pop_head();
+                    queue_lock.unlock();
+                    if (job == null) {
+                        break;
+                    }
+                    yield deliver((!)job);
+                }
+            } finally {
+                flushing = false;
+            }
+        }
+
+        /** Moves pending targets into per-endpoint send jobs. */
+        private async void resolve_pending() {
+            queue_lock.lock();
+            var targets = new GLib.List<PendingTarget>();
+            while (!pending.is_empty()) {
+                targets.append(pending.pop_head());
+            }
+            queue_lock.unlock();
+            if (targets.length() == 0) {
+                return;
+            }
+
+            try {
+                var scope = container.create_scope();
+                var db = scope.resolve<OrmSession>();
+                var subscriptions = yield db.query<PushSubscription>().materialise_async();
+                var jobs = new GLib.Queue<SendJob>();
+                foreach (var target in targets) {
+                    foreach (var row in subscriptions) {
+                        if (!target.global
+                                && !(yield pref_held(db, row.user_id, target.task_id))) {
+                            continue;
+                        }
+                        jobs.push_tail(new SendJob() {
+                            endpoint = row.endpoint,
+                            p256dh = row.p256dh,
+                            auth = row.auth,
+                            payload = payload_json(target.notification)
+                        });
+                    }
+                }
+                if (jobs.is_empty()) {
+                    return;
+                }
+                queue_lock.lock();
+                while (!jobs.is_empty()) {
+                    queued.push_tail(jobs.pop_head());
+                }
+                queue_lock.unlock();
+            } catch (Error e) {
+                warning("[ClankerSaddle] Push target resolution failed: %s", e.message);
+                // Targets stay lost this pass; the event is best-effort.
+            }
+        }
+
+        /** The wire payload JSON (compact, exactly what the SW parses). */
+        private static string payload_json(PushNotification notification) {
+            var builder = new Json.Builder();
+            builder.begin_object();
+            builder.set_member_name("title");
+            builder.add_string_value(notification.title);
+            builder.set_member_name("body");
+            builder.add_string_value(notification.body);
+            builder.set_member_name("url");
+            builder.add_string_value(notification.url);
+            builder.end_object();
+            var generator = new Json.Generator();
+            generator.set_root(builder.get_root());
+            return generator.to_data(null);
+        }
+
+        private enum SendOutcome {
+            DELIVERED,
+            RETRY,
+            GONE
+        }
+
+        private async void deliver(SendJob job) {
+            SendOutcome outcome = SendOutcome.RETRY;
+            try {
+                outcome = yield deliver_once(job);
+            } catch (Error e) {
+                outcome = SendOutcome.RETRY;
+            }
+            if (outcome == SendOutcome.DELIVERED) {
+                return;
+            }
+            if (outcome == SendOutcome.GONE) {
+                yield prune_subscription(job.endpoint);
+                return;
+            }
+            job.attempts++;
+            if (job.attempts >= MAX_ATTEMPTS) {
+                warning("[ClankerSaddle] Push to %s gave up after %d attempts",
+                    job.endpoint, job.attempts);
+                return;
+            }
+            queue_lock.lock();
+            queued.push_tail(job);
+            queue_lock.unlock();
+        }
+
+        /**
+         * 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).
+         */
+        private async SendOutcome deliver_once(SendJob job) throws Error {
+            uint8[] client_public = base64url_decode(job.p256dh);
+            uint8[] auth_secret = base64url_decode(job.auth);
+            if (client_public.length != 65 || auth_secret.length != 16) {
+                return SendOutcome.GONE;
+            }
+
+            // RFC 8291: ephemeral ECDH -> HKDF -> AES-128-GCM.
+            var eph_priv = new uint8[32];
+            var eph_pub = new uint8[65];
+            if (crypto_p256_generate((uint8*) eph_priv, (uint8*) eph_pub) != 1) {
+                throw new Error(Quark.from_string("clanker-saddle"), 60,
+                    "ephemeral P-256 generation failed");
+            }
+            var secret = new uint8[32];
+            if (crypto_p256_ecdh((uint8*) eph_priv, (uint8*) client_public, (uint8*) secret) != 1) {
+                throw new Error(Quark.from_string("clanker-saddle"), 61,
+                    "ECDH with the subscription key failed");
+            }
+            var ikm = new uint8[48];
+            for (var index = 0; index < 32; index++) {
+                ikm[index] = secret[index];
+            }
+            for (var index = 0; index < 16; index++) {
+                ikm[32 + index] = auth_secret[index];
+            }
+
+            var salt = new uint8[16];
+            if (crypto_random((uint8*) salt, 16) != 1) {
+                throw new Error(Quark.from_string("clanker-saddle"), 62,
+                    "salt generation failed");
+            }
+            // "WebPush: info" || 0x00 || eph_pub || client_pub
+            var context = new uint8[14 + 65 + 65];
+            var label = "WebPush: info".data;
+            for (var index = 0; index < 13; index++) {
+                context[index] = label[index];
+            }
+            context[13] = 0x00;
+            for (var index = 0; index < 65; index++) {
+                context[14 + index] = eph_pub[index];
+                context[14 + 65 + index] = client_public[index];
+            }
+            uint8[] cek = hkdf(salt, ikm, context, 16);
+            uint8[] nonce = hkdf(salt, ikm, context, 12);
+
+            uint8[] plaintext = job.payload.data;
+            var framed = new uint8[plaintext.length + 1];
+            for (var index = 0; index < plaintext.length; index++) {
+                framed[index] = plaintext[index];
+            }
+            framed[plaintext.length] = 0x02; // last-record delimiter
+            var ciphertext = new uint8[framed.length + 16];
+            size_t written = 0;
+            if (crypto_aes128gcm_encrypt((uint8*) cek, (uint8*) nonce, (uint8*) framed,
+                    framed.length, (uint8*) ciphertext, out written) != 1) {
+                throw new Error(Quark.from_string("clanker-saddle"), 63,
+                    "payload encryption failed");
+            }
+
+            var body = new uint8[16 + 4 + 1 + 65 + written];
+            for (var index = 0; index < 16; index++) {
+                body[index] = salt[index];
+            }
+            int64 record_size = plaintext.length + 17;
+            body[16] = (uint8) ((record_size >> 24) & 0xff);
+            body[17] = (uint8) ((record_size >> 16) & 0xff);
+            body[18] = (uint8) ((record_size >> 8) & 0xff);
+            body[19] = (uint8) (record_size & 0xff);
+            body[20] = 65;
+            for (var index = 0; index < 65; index++) {
+                body[21 + index] = eph_pub[index];
+            }
+            for (var index = 0; index < written; index++) {
+                body[21 + 65 + index] = ciphertext[index];
+            }
+
+            var message = new Soup.Message("POST", job.endpoint);
+            message.set_request_body_from_bytes("application/octet-stream",
+                new Bytes(body));
+            message.request_headers.append("TTL", "60");
+            message.request_headers.append("Urgency", "high");
+            message.request_headers.append("Authorization",
+                @"vapid t=$(vapid_jwt(job.endpoint)), k=$(vapid_public_key)");
+            try {
+                yield http.send_and_read_async(message, Priority.DEFAULT, null);
+            } catch (Error e) {
+                throw new Error(Quark.from_string("clanker-saddle"), 64,
+                    @"push POST failed: $(e.message)");
+            }
+            if (message.status_code == 404 || message.status_code == 410) {
+                return SendOutcome.GONE;
+            }
+            if (message.status_code >= 500) {
+                throw new Error(Quark.from_string("clanker-saddle"), 65,
+                    @"push service answered HTTP $(message.status_code)");
+            }
+            return SendOutcome.DELIVERED;
+        }
+
+        /**
+         * The ES256 VAPID JWT for an endpoint: aud = its origin, exp =
+         * now + 12h, sub = a mailto contact (`SADDLE_VAPID_SUBJECT`
+         * overrides), signature = raw r||s.
+         */
+        private static string vapid_jwt(string endpoint) {
+            string origin = endpoint;
+            int scheme = endpoint.index_of("://");
+            if (scheme > 0) {
+                int path = endpoint.index_of("/", scheme + 3);
+                origin = path > 0 ? endpoint.substring(0, path) : endpoint;
+            }
+            string subject = Environment.get_variable("SADDLE_VAPID_SUBJECT")
+                ?? "mailto:clanker-saddle@localhost";
+            string header = "{\"typ\":\"JWT\",\"alg\":\"ES256\"}";
+            string claims = "{\"aud\":\"" + origin + "\",\"exp\":"
+                + (new DateTime.now_utc().to_unix() + 43200).to_string()
+                + ",\"sub\":\"" + subject + "\"}";
+            string header_b64 = base64url_encode(header.data);
+            string claims_b64 = base64url_encode(claims.data);
+            string signing_input = header_b64 + "." + claims_b64;
+
+            var checksum = new Checksum(ChecksumType.SHA256);
+            size_t digest_len = 32;
+            var digest = new uint8[32];
+            checksum.update(signing_input.data, signing_input.length);
+            checksum.get_digest(digest, ref digest_len);
+
+            uint8[] priv = base64url_decode(vapid_private_key);
+            var signature = new uint8[64];
+            if (crypto_p256_sign((uint8*) priv, (uint8*) digest, (uint8*) signature) != 1) {
+                warning("[ClankerSaddle] VAPID JWT signing failed");
+                return "";
+            }
+            return signing_input + "." + base64url_encode(signature);
+        }
+
+        /** Deletes subscriptions whose endpoint went 404/410 Gone. */
+        private async void prune_subscription(string endpoint) {
+            try {
+                var scope = container.create_scope();
+                var db = scope.resolve<OrmSession>();
+                foreach (var row in yield db.query<PushSubscription>()
+                        .where(expr("endpoint == $0", elem(endpoint)))
+                        .materialise_async()) {
+                    yield db.delete_async<PushSubscription>(row);
+                }
+                print("[ClankerSaddle] Pruned gone push subscription %s\n",
+                    endpoint.substring(0, int.min(endpoint.length, 72)));
+            } catch (Error e) {
+                warning("[ClankerSaddle] Pruning push subscription failed: %s", e.message);
+            }
+        }
+
+        // ------------------------------------------------------------------
+        // Subscriptions + notify prefs (caller's database scope)
+        // ------------------------------------------------------------------
+
+        /**
+         * Validates a browser subscription registration: the endpoint
+         * must be an absolute http(s) URL, `p256dh` a base64url 65-byte
+         * uncompressed P-256 point and `auth` a base64url 16-byte
+         * secret. Returns null when valid, else the shown error.
+         */
+        public static string? validate_subscription(string endpoint,
+                string p256dh, string auth) {
+            if (!(endpoint.has_prefix("http://") || endpoint.has_prefix("https://"))
+                    || endpoint.length <= 8) {
+                return "The endpoint must be an absolute http(s) URL";
+            }
+            uint8[] public_point = base64url_decode(p256dh);
+            if (public_point.length != 65 || public_point[0] != 0x04) {
+                return "p256dh must be a base64url 65-byte uncompressed P-256 point";
+            }
+            uint8[] auth_secret = base64url_decode(auth);
+            if (auth_secret.length != 16) {
+                return "auth must be a base64url 16-byte secret";
+            }
+            return null;
+        }
+
+        /**
+         * Stores (or replaces — the endpoint is unique per browser) a
+         * user's subscription.
+         */
+        public async void upsert_subscription(OrmSession db, int64 user_id,
+                string endpoint, string p256dh, string auth) throws Error {
+            var existing = yield db.query<PushSubscription>()
+                .where(expr("endpoint == $0", elem(endpoint)))
+                .first_async();
+            if (existing != null) {
+                ((!)existing).user_id = user_id;
+                ((!)existing).p256dh = p256dh;
+                ((!)existing).auth = auth;
+                yield db.update_async<PushSubscription>((!)existing);
+                return;
+            }
+            yield db.insert_async<PushSubscription>(new PushSubscription() {
+                user_id = user_id,
+                endpoint = endpoint,
+                p256dh = p256dh,
+                auth = auth,
+                created_at = new DateTime.now_local()
+            });
+        }
+
+        /**
+         * Removes one of the user's subscription rows by endpoint (a
+         * no-op when the endpoint belongs to someone else or is gone).
+         */
+        public async void remove_subscription(OrmSession db, int64 user_id,
+                string endpoint) throws Error {
+            foreach (var row in yield db.query<PushSubscription>()
+                    .where(expr("endpoint == $0 && user_id == $1",
+                        elem(endpoint), elem<int64?>(user_id)))
+                    .materialise_async()) {
+                yield db.delete_async<PushSubscription>(row);
+            }
+        }
+
+        /** True when the user holds any subscription (the bell's state). */
+        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)))
+                    .materialise_async()) {
+                return true;
+            }
+            return false;
+        }
+
+        /** 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 {
+            return yield pref_held(db, user_id, task_id);
+        }
+
+        /** Inserts or removes the per-task "Notify me" pref. */
+        public async void set_notify_pref(OrmSession db, int64 user_id,
+                int64 task_id, bool enabled) throws Error {
+            foreach (var row in yield db.query<TaskNotifyPref>()
+                    .where(expr("task_id == $0 && user_id == $1",
+                        elem<int64?>(task_id), elem<int64?>(user_id)))
+                    .materialise_async()) {
+                if (!enabled) {
+                    yield db.delete_async<TaskNotifyPref>(row);
+                }
+                return;
+            }
+            if (enabled) {
+                yield db.insert_async<TaskNotifyPref>(new TaskNotifyPref() {
+                    task_id = task_id,
+                    user_id = user_id
+                });
+            }
+        }
+
+        private static async bool pref_held(OrmSession db, int64 user_id,
+                int64 task_id) throws Error {
+            var row = yield db.query<TaskNotifyPref>()
+                .where(expr("task_id == $0 && user_id == $1",
+                    elem<int64?>(task_id), elem<int64?>(user_id)))
+                .first_async();
+            return row != null;
+        }
+
+        // ------------------------------------------------------------------
+        // Primitives
+        // ------------------------------------------------------------------
+
+        /** HKDF (RFC 5869, SHA-256): extract(salt, ikm) then expand(info). */
+        private static uint8[] hkdf(uint8[] salt, uint8[] ikm, uint8[] info,
+                size_t length) {
+            var extract_hmac = new Hmac(ChecksumType.SHA256, salt);
+            extract_hmac.update(ikm);
+            size_t prk_len = 32;
+            var prk = new uint8[32];
+            extract_hmac.get_digest(prk, ref prk_len);
+
+            var output = new uint8[length];
+            var counter = new uint8[1];
+            uint8[] accumulator = new uint8[0];
+            size_t produced = 0;
+            uint8 block_index = 1;
+            while (produced < length) {
+                var expand_hmac = new Hmac(ChecksumType.SHA256, prk);
+                expand_hmac.update(accumulator);
+                expand_hmac.update(info);
+                counter[0] = block_index;
+                expand_hmac.update(counter);
+                size_t block_len = 32;
+                var block = new uint8[32];
+                expand_hmac.get_digest(block, ref block_len);
+                size_t take = 32;
+                if (length - produced < 32) {
+                    take = length - produced;
+                }
+                for (var index = 0; index < take; index++) {
+                    output[produced + index] = block[index];
+                }
+                produced += take;
+                accumulator = block;
+                block_index++;
+            }
+            return output;
+        }
+
+        /** Standard base64 -> base64url (unpadded). */
+        public static string base64url_encode(uint8[] data) {
+            string encoded = Base64.encode(data);
+            var builder = new StringBuilder();
+            for (var index = 0; index < encoded.length; index++) {
+                char character = encoded[index];
+                if (character == '+') {
+                    builder.append_c('-');
+                } else if (character == '/') {
+                    builder.append_c('_');
+                } else if (character != '=') {
+                    builder.append_c(character);
+                }
+            }
+            return builder.str;
+        }
+
+        /** base64url (padded or not) -> bytes; junk decodes empty. */
+        public static uint8[] base64url_decode(string text) {
+            var normalised = new StringBuilder();
+            for (var index = 0; index < text.length; index++) {
+                char character = text[index];
+                if (character == '-') {
+                    normalised.append_c('+');
+                } else if (character == '_') {
+                    normalised.append_c('/');
+                } else {
+                    normalised.append_c(character);
+                }
+            }
+            while (normalised.len % 4 != 0) {
+                normalised.append_c('=');
+            }
+            return Base64.decode(normalised.str);
+        }
+    }
+}

+ 7 - 0
src/Services/SaddleService.vala

@@ -96,6 +96,7 @@ namespace ClankerSaddle.Services {
         }
         private SaddlePollState poll_state = inject<SaddlePollState>();
         private StatePulse pulse = inject<StatePulse>();
+        private PushService push = inject<PushService>();
         private RuleService rules = inject<RuleService>();
         private CapabilityService capability_service = inject<CapabilityService>();
         private TaskService task_service = inject<TaskService>();
@@ -1554,6 +1555,12 @@ namespace ClankerSaddle.Services {
                             && ((!)current).started_at == null) {
                         ((!)current).started_at = new DateTime.now_local();
                         yield db.update_async<TaskEntity>((!)current);
+                        // The first-start push lives at the allocation
+                        // (where started_at is stamped for dispatched
+                        // tasks); this path only covers legacy status
+                        // pushes for never-allocated work.
+                        push.notify_opted_in(task_id, PushService.lifecycle_notification(
+                            ((!)current).title, task_id, "started", null));
                     }
                     break;
                 default:

+ 70 - 1
src/Services/TaskService.vala

@@ -107,6 +107,7 @@ namespace ClankerSaddle.Services {
         private Connection connection = inject<Connection>();
         private StatePulse pulse = inject<StatePulse>();
         private SubtaskEventQueue subtask_events = inject<SubtaskEventQueue>();
+        private PushService push = inject<PushService>();
 
         /**
          * Records a subtask's current state for the daemon hosting its
@@ -688,9 +689,33 @@ namespace ClankerSaddle.Services {
                 yield db.update_async<TaskEntity>((!)task);
             }
             yield pulse.bump(task_id, "block raised");
+            // Web Push (plan 11 task E): every raised block notifies all
+            // push-subscribed users globally — except the ones a lever
+            // answers instantly web-side (their "block" never becomes
+            // visible to a human, so a notification would be noise).
+            if (task != null && !(yield block_would_auto_answer(((!)task), block))) {
+                push.notify_global(PushService.block_notification(
+                    ((!)task).title, task_id, block.block_type, block.question));
+            }
             return block;
         }
 
+        /**
+         * True when one of the instant-approve levers would answer a
+         * just-raised block web-side (the coverage check
+         * {@link auto_answer_if_covered} applies) — such blocks never
+         * wait for a human, so they also never push-notify.
+         */
+        private async bool block_would_auto_answer(TaskEntity task, TaskBlock block) throws Error {
+            if (block.block_type == BLOCK_REVIEW) {
+                return yield is_auto_approve_covered(task.id);
+            }
+            if (block.block_type == BLOCK_PLAN) {
+                return task.plan_auto_approve || (yield is_auto_approve_covered(task.id));
+            }
+            return false;
+        }
+
         /**
          * Records a review request (`saddle review`): a review-type block
          * carrying the summary markdown, with the task flipped to
@@ -1028,10 +1053,11 @@ namespace ClankerSaddle.Services {
                 throw new Error(Quark.from_string("clanker-saddle"), 7,
                     @"No task exists with id $(parent_task_id)");
             }
-            return yield create_task(((!)parent).project_id, title, description,
+            var child = yield create_task(((!)parent).project_id, title, description,
                 ((!)parent).priority, parent_task_id,
                 ((!)parent).task_branch, ((!)parent).branch_from,
                 false, ((!)parent).auto_enqueue_subtasks, false, STATUS_DRAFT);
+            return child;
         }
 
         /**
@@ -1047,6 +1073,10 @@ namespace ClankerSaddle.Services {
             if (child.auto_enqueue_subtasks) {
                 yield enqueue_task(child.id);
                 child.status = STATUS_QUEUED;
+            } else if (child.status == STATUS_DRAFT) {
+                // A subtask that stays draft under a parked parent
+                // (re)arms the waiting-on-drafts push.
+                yield notify_waiting_on_drafts_if_parked(parent_task_id);
             }
             return child;
         }
@@ -1153,6 +1183,10 @@ namespace ClankerSaddle.Services {
             yield close_open_sessions(task_id, outcome);
             yield free_saddles_running(task_id);
             yield pulse.bump(task_id, "task completed");
+            // Web Push (plan 11 task E): completion notifies the users
+            // holding a "Notify me" pref for this task.
+            push.notify_opted_in(task_id, PushService.lifecycle_notification(
+                ((!)task).title, task_id, "completed", summary));
             note_subtask_event(((!)task));
         }
 
@@ -1216,6 +1250,11 @@ namespace ClankerSaddle.Services {
             yield close_open_sessions(task_id, "failed");
             yield free_saddles_running(task_id);
             yield pulse.bump(task_id, "task failed");
+            // Web Push (plan 11 task E): an agent-reported failure
+            // notifies the opted-in users (a human cancellation does
+            // not — the cancellator already knows).
+            push.notify_opted_in(task_id, PushService.lifecycle_notification(
+                ((!)task).title, task_id, "failed", summary));
             note_subtask_event(((!)task));
         }
 
@@ -1236,6 +1275,36 @@ namespace ClankerSaddle.Services {
             ((!)task).suspended_at = new DateTime.now_local();
             yield db.update_async<TaskEntity>((!)task);
             yield pulse.bump(task_id, "task suspended");
+            yield notify_waiting_on_drafts_if_parked(task_id);
+        }
+
+        /**
+         * The suspended-waiting-on-drafts push (plan 11 task E): fires
+         * like a block event — to every push-subscribed user — when a
+         * parked parent still has draft subtasks the human must
+         * enqueue. Called from the suspension path and whenever a
+         * subtask lands in draft under an already-parked parent (the
+         * condition can (re)arise from either side); the count-based
+         * check makes it self-correcting — no drafts, no notification.
+         */
+        public async void notify_waiting_on_drafts_if_parked(int64 parent_task_id) throws Error {
+            var parent = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(parent_task_id)))
+                .first_async();
+            if (parent == null || ((!)parent).status != STATUS_SUSPENDED) {
+                return;
+            }
+            int64 drafts = 0;
+            foreach (var child in yield db.query<TaskEntity>()
+                    .where(expr("parent_task_id == $0 && status == $1",
+                        elem<int64?>(parent_task_id), elem(STATUS_DRAFT)))
+                    .materialise_async()) {
+                drafts++;
+            }
+            if (drafts > 0) {
+                push.notify_global(PushService.waiting_drafts_notification(
+                    ((!)parent).title, parent_task_id, drafts));
+            }
         }
 
         /** Withdraws the suspension stamp after a successful resume. */

+ 4 - 1
src/actions/DashboardActions.vala

@@ -17,6 +17,7 @@ namespace ClankerSaddle {
         private SaddleService saddle_service = inject<SaddleService>();
         private TimelineService timeline_service = inject<TimelineService>();
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
+        private PushService push_service = inject<PushService>();
         private UserService users = inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -25,8 +26,10 @@ namespace ClankerSaddle {
                 return (!)guard;
             }
 
+            var me = SpryAuth.current(request);
             var state = yield DashboardState.build(state_service, task_service,
-                saddle_service, timeline_service, db);
+                saddle_service, timeline_service, db, action_registry, push_service,
+                me != null ? ((!)me).user_id : 0);
             return directives().set(state);
         }
     }

+ 118 - 0
src/actions/PushActions.vala

@@ -0,0 +1,118 @@
+using Inversion;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /**
+     * Stores the browser's Web Push subscription for the signed-in
+     * user (plan 11 task E): `endpoint`, `p256dh` and `auth` arrive
+     * from the dashboard bell's subscribe flow
+     * (`pushManager.subscribe` + the VAPID key from the page state).
+     * Validated per {@link PushService.validate_subscription}; the
+     * endpoint is unique per browser, so a re-subscribe replaces the
+     * stored keys.
+     */
+    public class PushSubscribeAction : StatumAction {
+
+        private InvercargillSql.Orm.OrmSession db = Inversion.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;
+            }
+
+            string endpoint = Forms.field(request, "endpoint");
+            string p256dh = Forms.field(request, "p256dh");
+            string auth = Forms.field(request, "auth");
+            string problem = PushService.validate_subscription(endpoint, p256dh, auth);
+            if (problem != null) {
+                return directives().notify("error", @"Could not enable notifications: $((!)problem)")
+                    .navigate("/");
+            }
+
+            var me = SpryAuth.current(request);
+            try {
+                yield push.upsert_subscription(db, me.user_id, endpoint, p256dh, auth);
+                return directives().notify("success",
+                        "Notifications enabled — you'll be told about questions and reviews")
+                    .navigate("/");
+            } catch (Error e) {
+                return directives().notify("error",
+                        @"Could not store the subscription: $(e.message)").navigate("/");
+            }
+        }
+    }
+
+    /**
+     * Removes the signed-in user's subscription for an endpoint (the
+     * bell's off switch; the browser already unsubscribed its side).
+     */
+    public class PushUnsubscribeAction : StatumAction {
+
+        private InvercargillSql.Orm.OrmSession db = Inversion.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;
+            }
+
+            string endpoint = Forms.field(request, "endpoint");
+            try {
+                var me = SpryAuth.current(request);
+                yield push.remove_subscription(db, me.user_id, endpoint);
+                return directives().notify("success", "Notifications turned off").navigate("/");
+            } catch (Error e) {
+                return directives().notify("error",
+                        @"Could not turn notifications off: $(e.message)").navigate("/");
+            }
+        }
+    }
+
+    /**
+     * Flips the signed-in user's per-task "Notify me" pref (plan 11
+     * task E): opted-in users receive the task's first start, failure
+     * and completion on top of the global block events every
+     * push-subscribed user gets.
+     */
+    public class ToggleTaskNotifyAction : SealedTaskAction {
+
+        private InvercargillSql.Orm.OrmSession db = Inversion.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;
+            }
+
+            int64 task_id = sealed_task_id();
+            if (task_id <= 0) {
+                return directives().navigate("/tasks");
+            }
+
+            var me = SpryAuth.current(request);
+            try {
+                bool enabled = !(yield push.has_notify_pref(db, me.user_id, task_id));
+                yield push.set_notify_pref(db, me.user_id, task_id, enabled);
+                return directives().notify("success", enabled
+                        ? "You'll be notified when this task starts, fails or completes"
+                        : "Task notifications turned off")
+                    .navigate(@"/tasks/$(task_id)");
+            } catch (Error e) {
+                return directives().notify("error",
+                        @"Could not toggle notifications: $(e.message)")
+                    .navigate(@"/tasks/$(task_id)");
+            }
+        }
+    }
+}

+ 5 - 1
src/actions/TaskActions.vala

@@ -610,6 +610,8 @@ namespace ClankerSaddle {
         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 override async DirectiveBuilder handle() throws GLib.Error {
@@ -623,9 +625,11 @@ 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, task_id);
+                action_registry, poll_state, push_service, db,
+                me != null ? ((!)me).user_id : 0, task_id);
             return directives().set(state);
         }
     }

+ 20 - 2
src/entrypoints/HomeEntrypoint.vala

@@ -66,6 +66,12 @@ namespace ClankerSaddle {
         public int64 failed_count { get; set; }
         public int64 idle_saddle_count { get; set; }
         public Statum.Model.ActionDto? refresh { get; set; }
+        /** The VAPID public key the browser subscribes with ("" when push is unavailable). */
+        public string push_vapid_key { get; set; default = ""; }
+        /** The signed-in user holds at least one push subscription. */
+        public bool push_subscribed { get; set; }
+        public Statum.Model.ActionDto? push_subscribe { get; set; }
+        public Statum.Model.ActionDto? push_unsubscribe { get; set; }
     }
 
     /**
@@ -104,7 +110,8 @@ namespace ClankerSaddle {
         public static async Statum.Slot build(StateService state_service, TaskService tasks,
                 SaddleService saddles, TimelineService timeline,
                 InvercargillSql.Orm.OrmSession db,
-                ActionRegistry? actions = null) throws GLib.Error {
+                ActionRegistry? actions = null, PushService? push = null,
+                int64 viewer_id = 0) throws GLib.Error {
             var pub = new DashboardPublic();
             var titles = new Dictionary<string, string>();
             var all_tasks = yield tasks.list_tasks(null, null);
@@ -238,6 +245,14 @@ namespace ClankerSaddle {
 
             if (actions != null) {
                 pub.refresh = ((!)actions).author<RefreshDashboardAction>("GET");
+                pub.push_subscribe = ((!)actions).author<PushSubscribeAction>();
+                pub.push_unsubscribe = ((!)actions).author<PushUnsubscribeAction>();
+            }
+            // Web Push bell state (plan 11 task E): the VAPID key the
+            // browser needs + whether this user already subscribed.
+            pub.push_vapid_key = PushService.vapid_public_key;
+            if (push != null && viewer_id > 0) {
+                pub.push_subscribed = yield ((!)push).user_subscribed(db, viewer_id);
             }
 
             var activity = new Vector<DashboardActivityView>();
@@ -293,6 +308,7 @@ namespace ClankerSaddle {
         private SaddleService saddle_service = Inversion.inject<SaddleService>();
         private TimelineService timeline_service = Inversion.inject<TimelineService>();
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
+        private PushService push_service = Inversion.inject<PushService>();
         private UserService users = Inversion.inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -301,8 +317,10 @@ namespace ClankerSaddle {
                 return (!)guard;
             }
 
+            var me = SpryAuth.current(request);
             var state = yield DashboardState.build(state_service, task_service,
-                saddle_service, timeline_service, db, action_registry);
+                saddle_service, timeline_service, db, action_registry, push_service,
+                me != null ? ((!)me).user_id : 0);
             return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
         }
     }

+ 17 - 2
src/entrypoints/TaskDetailEntrypoint.vala

@@ -148,6 +148,9 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? refresh { get; set; }
         public Statum.Model.ActionDto? attach_rule { get; set; }
         public Statum.Model.ActionDto? attach_capability { get; set; }
+        /** The signed-in user holds a "Notify me" pref for this task. */
+        public bool notify_me { get; set; }
+        public Statum.Model.ActionDto? toggle_notify { get; set; }
     }
 
     /**
@@ -167,7 +170,9 @@ namespace ClankerSaddle {
         public static async Slot build(StateService state_service, TaskService tasks,
                 ProjectService projects, SaddleService saddles, RuleService rules,
                 CapabilityService capabilities, ActionRegistry actions,
-                SaddlePollState polls, int64 task_id) throws GLib.Error {
+                SaddlePollState polls, PushService push,
+                InvercargillSql.Orm.OrmSession db, int64 viewer_id,
+                int64 task_id) throws GLib.Error {
             var detail = yield tasks.get_task(task_id);
             if (detail == null) {
                 return slot_for(state_service, new TaskDetailPublic() { exists = false },
@@ -232,6 +237,12 @@ namespace ClankerSaddle {
             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);
+            // Web Push notify-me state (plan 11 task E): the signed-in
+            // user's per-task opt-in for start/failure/completion.
+            if (viewer_id > 0) {
+                pub.notify_me = yield push.has_notify_pref(db, viewer_id, task.id);
+            }
+            pub.toggle_notify = actions.author_private<ToggleTaskNotifyAction, TaskActionPrivate>(sealed);
 
             var comments = new Vector<CommentView>();
             foreach (var comment in ((!)detail).comments) {
@@ -482,6 +493,8 @@ namespace ClankerSaddle {
         private RuleService rule_service = Inversion.inject<RuleService>();
         private CapabilityService capability_service = Inversion.inject<CapabilityService>();
         private SaddlePollState poll_state = Inversion.inject<SaddlePollState>();
+        private PushService push_service = Inversion.inject<PushService>();
+        private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private UserService users = Inversion.inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -491,9 +504,11 @@ namespace ClankerSaddle {
             }
 
             int64 task_id = int64.parse(request.route("task_id") ?? "0");
+            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, task_id);
+                action_registry, poll_state, push_service, db,
+                me != null ? ((!)me).user_id : 0, task_id);
             return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
         }
     }

+ 13 - 0
src/main.vala

@@ -60,8 +60,16 @@ int main(string[] args) {
         application.add_singleton<SubtaskEventQueue>();
         application.add_singleton<SaddlePollState>();
         application.add_singleton<StatePulse>();
+        application.add_singleton<PushService>();
         application.add_singleton<Dispatcher>();
 
+        // Web Push (plan 11 task E): the VAPID keypair loads or
+        // generates + persists BEFORE the server accepts requests, so
+        // every subscriber sees the same public key from the first
+        // page render.
+        application.container.create_scope()
+            .resolve<PushService>().ensure_keys_from_config();
+
         var statum = application.configure_with<StatumConfigurator>();
         statum.global<ClankerSaddle.Services.PulsePublic>(StatePulse.SLOT_TYPE,
             new ClankerSaddle.Services.PulsePublic());
@@ -98,6 +106,9 @@ int main(string[] args) {
         statum.action<RetryTaskAction>();
         statum.action<ResumeTaskAction>();
         statum.action<ToggleAutoApproveAction>();
+        statum.action<PushSubscribeAction>();
+        statum.action<PushUnsubscribeAction>();
+        statum.action<ToggleTaskNotifyAction>();
         statum.action<RefreshTaskDataAction>();
         statum.action<RefreshDashboardAction>();
         statum.action<RegisterSaddleAction>();
@@ -149,6 +160,8 @@ int main(string[] args) {
             new EndpointRoute("/api/saddle/capabilities", Method.GET));
         application.add_endpoint<ClankerSaddle.Api.TerminalSseEndpoint>(
             new EndpointRoute("/terminal-stream/{session_id}", Method.GET));
+        application.add_endpoint<ClankerSaddle.Api.ServiceWorkerEndpoint>(
+            new EndpointRoute("/sw.js", Method.GET));
 
         GLib.Idle.add(() => {
             seed_admin.begin(application.container);

+ 117 - 0
src/pages/home.html

@@ -7,6 +7,9 @@
     // Live dashboard: the pulse subscription (see the entrypoint)
     // re-runs the sealed refresh action whenever task or saddle state
     // changes, so progress, blocks and review requests stream in.
+    // The notification bell drives the Web Push subscribe flow (plan
+    // 11 task E): permission -> service worker -> pushManager.subscribe
+    // with the page's VAPID key -> the sealed push_subscribe action.
     if (window.statum) {
         (function () {
             'use strict';
@@ -17,6 +20,102 @@
                 }
             });
 
+            function toBase64Url(buffer) {
+                var bytes = new Uint8Array(buffer);
+                var binary = '';
+                for (var index = 0; index < bytes.length; index++) {
+                    binary += String.fromCharCode(bytes[index]);
+                }
+                return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+            }
+
+            function vapidKeyBytes(base64Url) {
+                var padding = '='.repeat((4 - base64Url.length % 4) % 4);
+                var base64 = (base64Url + padding).replace(/-/g, '+').replace(/_/g, '/');
+                var raw = atob(base64);
+                var bytes = new Uint8Array(raw.length);
+                for (var index = 0; index < raw.length; index++) {
+                    bytes[index] = raw.charCodeAt(index);
+                }
+                return bytes;
+            }
+
+            function dashboard() {
+                return statum.read('dashboard') || {};
+            }
+
+            function fail(error) {
+                if (window.showToast) {
+                    showToast('Notifications: ' + (error && error.message ? error.message : error), 'error');
+                }
+            }
+
+            function enablePush() {
+                var state = dashboard();
+                if (!state.push_vapid_key) {
+                    fail('not available on this server');
+                    return;
+                }
+                if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
+                    fail('this browser has no Web Push support');
+                    return;
+                }
+                Notification.requestPermission().then(function (permission) {
+                    if (permission !== 'granted') {
+                        fail('permission denied in the browser');
+                        return;
+                    }
+                    // The service worker lives at the origin root (GET
+                    // /sw.js, Service-Worker-Allowed: /), so its scope
+                    // covers every page.
+                    return navigator.serviceWorker.register('/sw.js', { scope: '/' })
+                        .then(function () { return navigator.serviceWorker.ready; })
+                        .then(function (registration) {
+                            return registration.pushManager.getSubscription().then(function (existing) {
+                                if (existing) return existing;
+                                return registration.pushManager.subscribe({
+                                    userVisibleOnly: true,
+                                    applicationServerKey: vapidKeyBytes(state.push_vapid_key)
+                                });
+                            });
+                        })
+                        .then(function (subscription) {
+                            var keys = subscription.toJSON().keys || {};
+                            return statum.act(state.push_subscribe, {
+                                endpoint: subscription.endpoint,
+                                p256dh: keys.p256dh,
+                                auth: keys.auth
+                            });
+                        })
+                        .catch(function (error) { fail(error); });
+                });
+            }
+
+            function disablePush() {
+                var state = dashboard();
+                navigator.serviceWorker.getRegistration('/').then(function (registration) {
+                    if (!registration) return null;
+                    return registration.pushManager.getSubscription();
+                }).then(function (subscription) {
+                    var done = subscription ? subscription.unsubscribe() : Promise.resolve(true);
+                    return done.then(function () {
+                        if (subscription && state.push_unsubscribe) {
+                            return statum.act(state.push_unsubscribe, {
+                                endpoint: subscription.endpoint
+                            });
+                        }
+                    });
+                }).catch(function (error) { fail(error); });
+            }
+
+            document.addEventListener('click', function (event) {
+                if (!event.target.closest) return;
+                var bell = event.target.closest('[data-push-bell]');
+                if (!bell) return;
+                event.preventDefault();
+                if (dashboard().push_subscribed) disablePush();
+                else enablePush();
+            });
         })();
     }
 </script>
@@ -27,6 +126,24 @@
         <p stm-if="dashboard == null">Loading…</p>
 
         <div stm-if="dashboard != null">
+            <!-- The notification bell (plan 11 task E): subscribe this
+                 browser (permission + service worker + VAPID key) or
+                 turn it off. Block events notify every subscribed user;
+                 per-task "Notify me" adds start/failure/completion. -->
+            <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
+                        question, raises an RFC or review, or waits on draft subtasks</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>
+
             <div class="cards">
                 <section class="card">
                     <h3>Queue</h3>

+ 7 - 0
src/pages/task_detail.html

@@ -301,6 +301,13 @@
                         <button type="button" stm-if="!task.draft && !task.terminal"
                                 stm-action="task.cancel" stm-confirm="Cancel this task?">Cancel task</button>
                     </form>
+                    <form class="inline">
+                        <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                        <button type="button" stm-action="task.toggle_notify"
+                                stm-text="task.notify_me ? 'Don&#39;t notify me' : 'Notify me'">Notify me</button>
+                        <span class="muted">— this task&#39;s first start, failure and completion
+                            (questions and reviews always notify subscribed users)</span>
+                    </form>
                 </section>
 
                 <section class="card">

+ 451 - 1
test/web_stack_test.py

@@ -80,6 +80,22 @@ Plan 10 phase 3 (capability awareness + UI):
 
 Prints PASS/FAIL lines Statum-style; exits non-zero on any failure.
 
+Plan 11 task D+E (UI + notifications), the `push` shard:
+
+- task page: progress feed above the status card, status + description
+  in a 50/50 grid, edit expander under the history expander, plan
+  behind an expander, answered blocks behind an expander; queue screen:
+  New Task expander above the filters; dashboard waiting-on-drafts
+  block with links to where Enqueue lives (pages shard)
+- real Web Push over VAPID: keypair generated at startup into
+  web-config.json, GET /sw.js service worker with click-through,
+  subscribe/unsubscribe + per-task Notify me actions, block events and
+  waiting-on-drafts pushed globally, first start/failure/completion to
+  opted-in users, best-effort queue with dispatcher-tick retry, 410/404
+  endpoint pruning — payloads verified protocol-level (RFC 8291
+  decryption + ES256 JWT signature) because the harness owns the
+  browser-side keys
+
 Speed pass (plan 11, task 0):
 
 - SHARDING: the scenario groups are partitioned into seven independent
@@ -119,6 +135,17 @@ import urllib.parse
 import urllib.request
 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
 
+# Web Push protocol verification (plan 11 task E): the test harness
+# owns the browser-side keys, so it can do everything the push service
+# does — decrypt the RFC 8291 body and verify the ES256 VAPID JWT.
+import base64
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.asymmetric import ec
+from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
+
 REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 APP = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.join(REPO, "builddir", "clanker-saddle")
 
@@ -945,6 +972,129 @@ def dump_app_stacks(pid):
             continue
 
 
+# =============================================================================
+# Fake push service + Web Push protocol helpers (plan 11 task E)
+# =============================================================================
+
+def b64url(raw):
+    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
+
+
+def b64url_dec(text):
+    return base64.urlsafe_b64decode(text + "=" * ((4 - len(text) % 4) % 4))
+
+
+class FakePushService:
+    """In-process Web Push endpoint server: every subscription the tests
+    register points at a path here (a distinct "browser endpoint").
+    Responses are scriptable per path — a fixed status (the 410-prune
+    fixture) or N transient 500s (the retry-next-tick fixture)."""
+
+    def __init__(self):
+        self.requests = []            # [{"path", "headers", "body", "at"}]
+        self.fail_next = {}           # path -> remaining 500s
+        self.status_for = {}          # path -> fixed status
+        self.lock = threading.Lock()
+        self.port = None
+        self.server = None
+
+    def serve(self):
+        service = self
+
+        class Handler(BaseHTTPRequestHandler):
+            protocol_version = "HTTP/1.1"
+
+            def log_message(self, fmt, *args):
+                pass
+
+            def do_POST(self):
+                length = int(self.headers.get("Content-Length") or 0)
+                body = self.rfile.read(length)
+                status = 201
+                if self.path in service.status_for:
+                    status = service.status_for[self.path]
+                elif service.fail_next.get(self.path, 0) > 0:
+                    service.fail_next[self.path] -= 1
+                    status = 500
+                with service.lock:
+                    service.requests.append({
+                        "path": self.path,
+                        "headers": {key.lower(): value
+                                    for key, value in self.headers.items()},
+                        "body": body,
+                        "at": time.monotonic(),
+                    })
+                payload = b"{}"
+                self.send_response(status)
+                self.send_header("Content-Type", "application/json")
+                self.send_header("Content-Length", str(len(payload)))
+                self.end_headers()
+                self.wfile.write(payload)
+
+        self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+        self.port = self.server.server_address[1]
+        threading.Thread(target=self.server.serve_forever, daemon=True).start()
+        return self.server
+
+    def url(self, path):
+        return "http://127.0.0.1:%d%s" % (self.port, path)
+
+    def hits(self, prefix):
+        with self.lock:
+            return [request for request in self.requests
+                    if request["path"].startswith(prefix)]
+
+
+def decrypt_webpush(request, client_private_key, client_public, auth_secret):
+    """RFC 8291 decryption of one captured push: returns
+    (record_size, payload dict) — the payload builder's exact output."""
+    body = request["body"]
+    salt = body[:16]
+    record_size = int.from_bytes(body[16:20], "big")
+    key_id_len = body[20]
+    eph_public = body[21:21 + key_id_len]
+    ciphertext = body[21 + key_id_len:]
+    eph_point = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(),
+                                                             eph_public)
+    shared = client_private_key.exchange(ec.ECDH(), eph_point)
+    info = b"WebPush: info\x00" + eph_public + client_public
+    cek = HKDF(algorithm=hashes.SHA256(), length=16, salt=salt,
+               info=info).derive(shared + auth_secret)
+    nonce = HKDF(algorithm=hashes.SHA256(), length=12, salt=salt,
+                 info=info).derive(shared + auth_secret)
+    plaintext = AESGCM(cek).decrypt(nonce, ciphertext, None)
+    return record_size, json.loads(plaintext[:-1])  # strip the 0x02 delimiter
+
+
+def verify_vapid(request, vapid_public, vapid_private):
+    """Verifies the ES256 VAPID JWT on one captured push; returns its
+    claims dict (aud/exp/sub) — raises on a bad signature."""
+    header = request["headers"].get("authorization", "")
+    assert header.startswith("vapid t="), "no vapid authorization header"
+    token_part = header[len("vapid t="):].split(",")[0]
+    sent_key = header.split("k=")[1]
+    head_b64, claims_b64, sig_b64 = token_part.split(".")
+    signature = b64url_dec(sig_b64)
+    key = ec.derive_private_key(
+        int.from_bytes(b64url_dec(vapid_private), "big"), ec.SECP256R1())
+    key.public_key().verify(
+        encode_dss_signature(int.from_bytes(signature[:32], "big"),
+                             int.from_bytes(signature[32:], "big")),
+        (head_b64 + "." + claims_b64).encode(),
+        ec.ECDSA(hashes.SHA256()))
+    claims = json.loads(b64url_dec(claims_b64))
+    assert sent_key == vapid_public, "the k= key is not the app's VAPID key"
+    return claims
+
+
+def action_notify(result):
+    """The message of the first notify directive an action returned."""
+    for directive in result:
+        if directive.get("type") == "notify":
+            return directive.get("message", "")
+    return None
+
+
 # =============================================================================
 # Shard orchestration
 # =============================================================================
@@ -4360,8 +4510,308 @@ def bundle_answers(ctx):
     check(True, "fleet re-asks after the restart (sleeping re-established)")
 
 
+# =============================================================================
+# Bundle: push — plan 11 task E: real Web Push over VAPID. VAPID
+# persistence, the service worker, subscription CRUD + validation,
+# notify-me prefs, the block/lifecycle dispatch matrix (global vs
+# opted-in), 410 pruning, retry-next-tick delivery and protocol-level
+# payload verification (RFC 8291 decryption + ES256 JWT) against the
+# fake push service.
+# =============================================================================
+
+def bundle_push(ctx):
+    auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
+
+    def subscription_count(endpoint):
+        conn = db()
+        try:
+            return conn.execute(
+                "select count(*) from push_subscriptions where endpoint = ?",
+                (endpoint,)).fetchone()[0]
+        finally:
+            conn.close()
+
+    print("== task E: service worker asset ==")
+    status, sw = http("GET", "/sw.js", timeout=15)
+    check(status == 200 and "addEventListener('push'" in sw
+          and "notificationclick" in sw and "clients.openWindow" in sw,
+          "the service worker serves at the origin root with push + click routing")
+
+    print("== task E: VAPID keypair generation + persistence ==")
+    config = json.load(open(os.path.join(WORK, "web-config.json")))
+    vapid = config.get("vapid") or {}
+    check(bool(vapid.get("public_key")) and bool(vapid.get("private_key")),
+          "the VAPID keypair persists into web-config.json's vapid section")
+    vapid_private_key = ec.derive_private_key(
+        int.from_bytes(b64url_dec(vapid["private_key"]), "big"), ec.SECP256R1())
+    derived_public = vapid_private_key.public_key().public_bytes(
+        Encoding.X962, PublicFormat.UncompressedPoint)
+    check(b64url_dec(vapid["public_key"]) == derived_public,
+          "the persisted public key matches the private scalar (a valid P-256 pair)")
+    dash = slot_public(auth, "/", "dashboard")
+    check(dash.get("push_vapid_key") == vapid["public_key"],
+          "the dashboard exposes the same VAPID key to subscribers")
+    check(dash.get("push_subscribed") is False and bool(dash.get("push_subscribe")),
+          "the bell starts unsubscribed and carries the subscribe action")
+
+    print("== task E: subscription CRUD + auth validation ==")
+    browser = ec.generate_private_key(ec.SECP256R1())
+    browser_public = browser.public_key().public_bytes(
+        Encoding.X962, PublicFormat.UncompressedPoint)
+    browser_auth = os.urandom(16)
+    subscribe_fields = {"p256dh": b64url(browser_public), "auth": b64url(browser_auth)}
+    valid_endpoint = "http://127.0.0.1:1/x"
+    check("absolute http(s)" in (action_notify(act(auth, dash["push_subscribe"],
+        {"endpoint": "ftp://example.com/push", **subscribe_fields})) or ""),
+        "a non-http endpoint is rejected")
+    check("p256dh" in (action_notify(act(auth, dash["push_subscribe"],
+        {"endpoint": valid_endpoint, "p256dh": b64url(os.urandom(64)),
+         "auth": subscribe_fields["auth"]})) or ""),
+        "a malformed p256dh point is rejected")
+    check("auth" in (action_notify(act(auth, dash["push_subscribe"],
+        {"endpoint": valid_endpoint, "p256dh": subscribe_fields["p256dh"],
+         "auth": b64url(os.urandom(15))})) or ""),
+        "a malformed auth secret is rejected")
+
+    push = FakePushService()
+    push.serve()
+    endpoint_main = push.url("/main")
+    act(auth, dash["push_subscribe"],
+        {"endpoint": endpoint_main, **subscribe_fields})
+    conn = db()
+    rows = conn.execute("select user_id, p256dh, auth from push_subscriptions"
+                        " where endpoint = ?", (endpoint_main,)).fetchall()
+    admin_id = conn.execute("select id from spry_users where username = 'admin'"
+                            ).fetchone()[0]
+    conn.close()
+    check(len(rows) == 1 and rows[0][0] == admin_id,
+          "a valid subscription stores against the signed-in user")
+    # Re-subscribing the same endpoint replaces (never duplicates).
+    rotated_auth = os.urandom(16)
+    act(auth, dash["push_subscribe"],
+        {"endpoint": endpoint_main, "p256dh": subscribe_fields["p256dh"],
+         "auth": b64url(rotated_auth)})
+    conn = db()
+    rows = conn.execute("select auth from push_subscriptions where endpoint = ?",
+                        (endpoint_main,)).fetchall()
+    conn.close()
+    check(len(rows) == 1 and rows[0][0] == b64url(rotated_auth),
+          "re-subscribing an endpoint replaces its keys in place")
+
+    print("== task E: notify-me pref persistence ==")
+    act(auth, slot_public(auth, "/tasks", "tasks")["create"],
+        {"project_id": "1", "title": "Push pref task", "priority": "5"})
+    pref_task = task_id_of("Push pref task")
+    act(auth, task_state(auth, pref_task)["toggle_notify"], {})
+    conn = db()
+    check(conn.execute("select count(*) from task_notify_prefs where task_id = ?"
+                       " and user_id = ?", (pref_task, admin_id)).fetchone()[0] == 1,
+          "Notify me persists a task_notify_prefs row for the user")
+    conn.close()
+    check(task_state(auth, pref_task).get("notify_me") is True,
+          "the task slot reflects notify_me after the toggle")
+    act(auth, task_state(auth, pref_task)["toggle_notify"], {})
+    conn = db()
+    check(conn.execute("select count(*) from task_notify_prefs where task_id = ?",
+                       (pref_task,)).fetchone()[0] == 0,
+          "Don't notify me removes the pref row")
+    conn.close()
+    check(task_state(auth, pref_task).get("notify_me") is False,
+          "the task slot reflects the opt-out")
+
+    print("== task E: dispatch matrix — global blocks vs opted-in lifecycle ==")
+    # Two "browsers" for admin (/main, /gone) and one for another user
+    # (id 999, rows seeded directly — push targeting is per user).
+    endpoint_gone = push.url("/gone")
+    push.status_for["/gone"] = 410
+    act(auth, dash["push_subscribe"],
+        {"endpoint": endpoint_gone, **subscribe_fields})
+    other_browser = ec.generate_private_key(ec.SECP256R1())
+    other_public = other_browser.public_key().public_bytes(
+        Encoding.X962, PublicFormat.UncompressedPoint)
+    other_auth = os.urandom(16)
+    endpoint_other = push.url("/other")
+    conn = db()
+    conn.execute("insert into push_subscriptions (user_id, endpoint, p256dh, auth,"
+                 " created_at) values (999, ?, ?, ?, strftime('%s','now'))",
+                 (endpoint_other, b64url(other_public), b64url(other_auth)))
+    conn.commit()
+    conn.close()
+
+    # Task 1: admin opted in via the UI toggle; the other user is not.
+    # Baselines are captured BEFORE the enqueue: the first-start push
+    # fires at the allocation, the instant the dispatch lands.
+    act(auth, slot_public(auth, "/tasks", "tasks")["create"],
+        {"project_id": "1", "title": "Push lifecycle task", "priority": "5"})
+    task1 = task_id_of("Push lifecycle task")
+    act(auth, task_state(auth, task1)["toggle_notify"], {})
+    main_before = len(push.hits("/main"))
+    other_before = len(push.hits("/other"))
+    gone_before = len(push.hits("/gone"))
+    enqueue_task(auth, task1)
+    check(wait_for(lambda: fake.running_task == task1 or False, timeout=30,
+                   what="task 1 assignment") is not None,
+          "task 1 dispatches to the fake daemon")
+    task1_session = fake.session_id
+    check(wait_for(lambda: len(push.hits("/main")) > main_before, timeout=15,
+                   what="first-start push to the opted-in admin") is not None,
+          "first start notifies the opted-in user's subscription")
+    check(wait_for(lambda: len(push.hits("/gone")) > gone_before, timeout=15,
+                   what="first-start push to the gone endpoint") is not None,
+          "first start reaches every subscription of the opted-in user")
+    check(quiet_window(quiet_seconds(),
+                       lambda: len(push.hits("/other")) == other_before,
+                       "no first-start push to the not-opted-in user"),
+          "first start never reaches users without the notify pref")
+    check(wait_task_done(auth, task1, timeout=60) is not None,
+          "task 1 completes")
+    _, done_payload = decrypt_webpush(push.hits("/main")[-1], browser,
+                                      browser_public, rotated_auth)
+    check(done_payload["title"] == "Push lifecycle task — completed"
+          and done_payload["url"] == "/tasks/%d" % task1,
+          "completion notifies the opted-in user (decrypted payload)")
+    check(len(push.hits("/other")) == other_before,
+          "completion stays with the opted-in user only")
+    check(wait_for(lambda: subscription_count(endpoint_gone) == 0, timeout=15,
+                   what="410 endpoint pruned") is not None,
+          "a 410 Gone endpoint is pruned from push_subscriptions")
+
+    # Block events go to EVERY push-subscribed user globally.
+    main_before = len(push.hits("/main"))
+    other_before = len(push.hits("/other"))
+    saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
+               {"type": "block", "session_id": task1_session, "data": {
+                   "question": "Global question for everyone?", "type": "question"}})
+    check(wait_for(lambda: len(push.hits("/main")) > main_before
+                   and len(push.hits("/other")) > other_before, timeout=15,
+                   what="global block push") is not None,
+          "a raised block notifies every push-subscribed user globally")
+    block_hit = push.hits("/other")[-1]
+    _, payload = decrypt_webpush(block_hit, other_browser, other_public, other_auth)
+    check(payload["title"] == "Push lifecycle task — question raised"
+          and payload["body"] == "Global question for everyone?"
+          and payload["url"] == "/tasks/%d" % task1,
+          "the block payload carries task title + kind, question and click URL (%s)"
+          % payload)
+    ciphertext_len = len(block_hit["body"]) - (21 + 65)
+    envelope = block_hit["body"]
+    record_size = int.from_bytes(envelope[16:20], "big")
+    # rs = plaintext + delimiter (1) + tag (16) == ciphertext-with-tag len.
+    check(record_size == ciphertext_len,
+          "the RFC 8291 envelope record size covers payload + delimiter + tag")
+    claims = verify_vapid(block_hit, vapid["public_key"], vapid["private_key"])
+    check(claims.get("aud") == "http://127.0.0.1:%d" % push.port
+          and claims.get("sub", "").startswith("mailto:")
+          and claims.get("exp", 0) > time.time(),
+          "the VAPID JWT verifies: aud = the endpoint origin, mailto sub, future exp")
+    check(block_hit["headers"].get("ttl") == "60"
+          and block_hit["headers"].get("content-type") == "application/octet-stream",
+          "the push POST carries TTL and the encrypted content type")
+
+    # Task 2: only the OTHER user opted in — start + failure reach them
+    # alone. Baseline again before the enqueue (first start fires at
+    # the allocation).
+    fake.fail_next = True
+    act(auth, slot_public(auth, "/tasks", "tasks")["create"],
+        {"project_id": "1", "title": "Push failure task", "priority": "5"})
+    task2 = task_id_of("Push failure task")
+    conn = db()
+    conn.execute("insert into task_notify_prefs (task_id, user_id) values (?, 999)",
+                 (task2,))
+    conn.commit()
+    conn.close()
+    main_before2 = len(push.hits("/main"))
+    other_before2 = len(push.hits("/other"))
+    enqueue_task(auth, task2)
+    check(wait_for(lambda: fake.running_task == task2 or False, timeout=30,
+                   what="task 2 assignment") is not None,
+          "task 2 dispatches (scripted to fail)")
+    check(wait_for(lambda: task_state(auth, task2)["status"] == "failed"
+                   or False, timeout=30, what="task 2 failed") is not None,
+          "task 2 fails")
+    check(wait_for(lambda: len(push.hits("/other")) >= other_before2 + 2,
+                   timeout=15, what="opted-in user's started+failed pushes"),
+          "the opted-in user gets first start AND failure for the task")
+    started_hit = [hit for hit in push.hits("/other")[other_before2:]][0]
+    _, started_payload = decrypt_webpush(started_hit, other_browser,
+                                         other_public, other_auth)
+    check(started_payload["title"] == "Push failure task — started",
+          "the first-start payload names task + kind (%s)" % started_payload)
+    _, failure_payload = decrypt_webpush(push.hits("/other")[-1], other_browser,
+                                         other_public, other_auth)
+    check(failure_payload["title"] == "Push failure task — failed"
+          and failure_payload["url"] == "/tasks/%d" % task2,
+          "the failure payload names the task and links its page")
+    check(len(push.hits("/main")) == main_before2,
+          "an agent failure never reaches users without the pref")
+
+    print("== task E: retry next tick (best-effort delivery) ==")
+    endpoint_flaky = push.url("/flaky")
+    act(auth, dash["push_subscribe"],
+        {"endpoint": endpoint_flaky, **subscribe_fields})
+    push.fail_next["/flaky"] = 1
+    flaky_before = len(push.hits("/flaky"))
+    saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
+               {"type": "block", "session_id": task1_session, "data": {
+                   "question": "Flaky delivery question?", "type": "question"}})
+    check(wait_for(lambda: len(push.hits("/flaky")) >= flaky_before + 2,
+                   timeout=20, what="flaky endpoint retried") is not None,
+          "a failed send retries on the next dispatcher tick and lands")
+
+    print("== task E: waiting-on-drafts notifies like a block event ==")
+    fake.suspend_on_title = "Push parked task"
+    act(auth, slot_public(auth, "/tasks", "tasks")["create"],
+        {"project_id": "1", "title": "Push parked task", "priority": "5"})
+    task3 = task_id_of("Push parked task")
+    enqueue_task(auth, task3)
+    check(wait_for(lambda: fake.running_task == task3 or False, timeout=30,
+                   what="task 3 assignment") is not None,
+          "task 3 dispatches (scripted to suspend)")
+    conn = db()
+    conn.execute("insert into tasks (project_id, title, status, priority,"
+                 " parent_task_id, created_at)"
+                 " values (1, 'Push draft subtask', 'draft', 5, ?,"
+                 " strftime('%s','now'))", (task3,))
+    conn.commit()
+    conn.close()
+    main_before = len(push.hits("/main"))
+    other_before = len(push.hits("/other"))
+    check(wait_for(lambda: task_state(auth, task3)["status"] == "suspended"
+                   or False, timeout=30, what="task 3 suspended") is not None,
+          "task 3 parks (suspended)")
+    check(wait_for(lambda: len(push.hits("/main")) > main_before
+                   and len(push.hits("/other")) > other_before, timeout=15,
+                   what="waiting-on-drafts global push") is not None,
+          "the waiting-on-drafts condition notifies globally like a block event")
+    _, waiting_payload = decrypt_webpush(push.hits("/main")[-1], browser,
+                                         browser_public, rotated_auth)
+    check(waiting_payload["title"]
+          == "Push parked task — waiting on subtasks to be enqueued"
+          and "still in draft" in waiting_payload["body"]
+          and waiting_payload["url"] == "/tasks/%d" % task3,
+          "the waiting-on-drafts payload carries the clear wording + link (%s)"
+          % waiting_payload)
+    # A subtask created under the ALREADY-parked parent re-arms the push.
+    main_before = len(push.hits("/main"))
+    saddle_api("POST", "/api/saddle/subtask", fake_token,
+               {"parent_task_id": task3, "title": "Parked-late child"})
+    check(wait_for(lambda: len(push.hits("/main")) > main_before, timeout=15,
+                   what="waiting-on-drafts re-armed push") is not None,
+          "a draft subtask created under a parked parent re-arms the push")
+    check(task_state(auth, task_id_of("Parked-late child"))["status"] == "draft",
+          "the parked parent's child lands in draft (no auto-enqueue)")
+
+    print("== task E: unsubscribe removes the subscription ==")
+    dash = slot_public(auth, "/", "dashboard")
+    check(dash.get("push_subscribed") is True,
+          "the bell shows subscribed while endpoints exist")
+    act(auth, dash["push_unsubscribe"], {"endpoint": endpoint_main})
+    check(subscription_count(endpoint_main) == 0,
+          "unsubscribing removes the endpoint's row")
+
+
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
-                 "allocation", "lifecycle", "answers")
+                 "allocation", "lifecycle", "answers", "push")
 
 
 def run_bundle(name):