Browse Source

Plan 17 M1+M2: consolidated M0001, unified events ledger, chunks ingestion

Single fresh M0001 (M0002 force_wake folded in). The events table is
exactly the binding contract (session_id nullable FK, per-session seq,
kind/actor/scope/payload/created_at, UNIQUE(session_id, seq));
session_commands, task_progress, session_raw_log and
saddle_entry_dedup are gone with every reader and writer.

POST /api/saddle/chunks speaks 7.2: events[] with monotonic per-session
seq, idempotent consume on the unique pair, kinds turn_text/tool_call/
tool_result/command_output/progress/lifecycle/usage/state_change,
actors model|harness, daemon ts authoritative; a seq-less event refuses
the batch wholesale. POST /api/saddle/results keeps its state
transitions; an entries member is consumed silently.

Web-originated events append on domain mutation (session_id NULL):
state_change on every task/goal transition and dispatch, comment
events referencing the task_comments row, verdict/answer on block
resolution, force_wake, cancel. Timelines, goal narrative, resume
coalescing and staleness windows derive from the ledger. Vendor the
spry/statum vapi directory the build now points at.
clanker 1 month ago
parent
commit
d56d8e185a

+ 23 - 22
meson.build

@@ -80,10 +80,10 @@ task_detail_page = custom_target('task-detail-page',
     command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'TaskDetailPage', '--ns', 'ClankerSaddle', '@INPUT@'],
     command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'TaskDetailPage', '--ns', 'ClankerSaddle', '@INPUT@'],
     depend_files: template_deps
     depend_files: template_deps
 )
 )
-task_terminal_page = custom_target('task-terminal-page',
-    input: 'src/pages/task_terminal.html',
-    output: 'TaskTerminalPage.vala',
-    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'TaskTerminalPage', '--ns', 'ClankerSaddle', '@INPUT@'],
+task_log_page = custom_target('task-log-page',
+    input: 'src/pages/task_log.html',
+    output: 'TaskLogPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'TaskLogPage', '--ns', 'ClankerSaddle', '@INPUT@'],
     depend_files: template_deps
     depend_files: template_deps
 )
 )
 goals_page = custom_target('goals-page',
 goals_page = custom_target('goals-page',
@@ -159,11 +159,11 @@ app_sources = files(
     'src/Format.vala',
     'src/Format.vala',
     'src/Data/Entities.vala',
     'src/Data/Entities.vala',
     'src/Data/Migrations/M0001_Initial.vala',
     'src/Data/Migrations/M0001_Initial.vala',
-    'src/Data/Migrations/M0002_ForceWake.vala',
     'src/Services/ProjectService.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/TaskService.vala',
     'src/Services/GoalService.vala',
     'src/Services/GoalService.vala',
     'src/Services/SaddleService.vala',
     'src/Services/SaddleService.vala',
+    'src/Services/EventService.vala',
     'src/Services/LifecycleService.vala',
     'src/Services/LifecycleService.vala',
     'src/Services/SaddlePollState.vala',
     'src/Services/SaddlePollState.vala',
     'src/Services/AllocationService.vala',
     'src/Services/AllocationService.vala',
@@ -174,7 +174,7 @@ app_sources = files(
     'src/Services/TimelineService.vala',
     'src/Services/TimelineService.vala',
     'src/Services/PushService.vala',
     'src/Services/PushService.vala',
     'src/Api/SaddleApi.vala',
     'src/Api/SaddleApi.vala',
-    'src/Api/TerminalSse.vala',
+    'src/Api/EventLogApi.vala',
     'src/Api/PushApi.vala',
     'src/Api/PushApi.vala',
     'src/actions/Forms.vala',
     'src/actions/Forms.vala',
     'src/actions/LoginAction.vala',
     'src/actions/LoginAction.vala',
@@ -193,7 +193,7 @@ app_sources = files(
     'src/entrypoints/ProjectsEntrypoint.vala',
     'src/entrypoints/ProjectsEntrypoint.vala',
     'src/entrypoints/TasksEntrypoint.vala',
     'src/entrypoints/TasksEntrypoint.vala',
     'src/entrypoints/TaskDetailEntrypoint.vala',
     'src/entrypoints/TaskDetailEntrypoint.vala',
-    'src/entrypoints/TaskTerminalEntrypoint.vala',
+    'src/entrypoints/TaskLogEntrypoint.vala',
     'src/entrypoints/GoalDetailEntrypoint.vala',
     'src/entrypoints/GoalDetailEntrypoint.vala',
     'src/entrypoints/SaddlesEntrypoint.vala',
     'src/entrypoints/SaddlesEntrypoint.vala',
     'src/entrypoints/RulesEntrypoint.vala',
     'src/entrypoints/RulesEntrypoint.vala',
@@ -214,7 +214,7 @@ app_generated = [
     project_detail_page,
     project_detail_page,
     tasks_page,
     tasks_page,
     task_detail_page,
     task_detail_page,
-    task_terminal_page,
+    task_log_page,
     goals_page,
     goals_page,
     goal_detail_page,
     goal_detail_page,
     saddles_page,
     saddles_page,
@@ -227,22 +227,23 @@ app_generated = [
 clanker_saddle_exe = executable('clanker-saddle',
 clanker_saddle_exe = executable('clanker-saddle',
     app_sources + app_generated + [app_css, favicon_png, emblem_png, push_crypto],
     app_sources + app_generated + [app_css, favicon_png, emblem_png, push_crypto],
     include_directories: include_directories('src/Push'),
     include_directories: include_directories('src/Push'),
+    vala_args: ['--vapidir=' + join_paths(meson.current_source_dir(), 'vapi')],
     dependencies: [
     dependencies: [
-        glib_dep,
-        gobject_dep,
-        gio_dep,
-        json_glib_dep,
-        soup_dep,
-        libxml_dep,
-        sqlite_dep,
-        invercargill_dep,
-        invercargill_json_dep,
-        invercargill_sql_dep,
-        invercargill_sql_inversion_dep,
-        inversion_dep,
-        astralis_dep,
-        statum_dep,
         spry_dep,
         spry_dep,
+        statum_dep,
+        astralis_dep,
+        inversion_dep,
+        invercargill_sql_inversion_dep,
+        invercargill_sql_dep,
+        invercargill_json_dep,
+        invercargill_dep,
+        sqlite_dep,
+        libxml_dep,
+        soup_dep,
+        json_glib_dep,
+        gio_dep,
+        gobject_dep,
+        glib_dep,
         libcrypto_dep,
         libcrypto_dep,
     ],
     ],
     install: true
     install: true

+ 158 - 0
src/Api/EventLogApi.vala

@@ -0,0 +1,158 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql.Orm;
+using Inversion;
+using Astralis;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle.Api {
+
+    /**
+     * The plan-17 event log endpoints (§7.4), replacing the terminal
+     * view's wire: `GET /tasks/{task_id}/events?before=<id>&limit=30`
+     * serves one newest-first JSON page of the task's ledger, and
+     * `GET /tasks/{task_id}/events/stream` serves the SSE live view —
+     * the initial most-recent-30 window, then `event: append` per new
+     * ledger row (the same database-polling push path the terminal
+     * stream used). Both are unauthenticated like the terminal stream
+     * before them (the task id is an unguessable capability).
+     */
+    public class TaskEventsEndpoint : Object, Endpoint {
+
+        private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
+        private EventService events = inject<EventService>();
+
+        public virtual async HttpResult handle_request(HttpContext http_context,
+                RouteContext route_context) throws Error {
+            string id_text = "";
+            route_context.mapped_parameters.try_get("task_id", out id_text);
+            int64 task_id = int64.parse(id_text);
+
+            int64 before = 0;
+            string before_text = "";
+            if (http_context.request.query_params.try_get_any("before", out before_text)
+                    && before_text != null && before_text.length > 0) {
+                before = int64.parse(before_text);
+            }
+            int64 limit = EVENT_PAGE_SIZE;
+            string limit_text = "";
+            if (http_context.request.query_params.try_get_any("limit", out limit_text)
+                    && limit_text != null) {
+                int64 parsed = int64.parse(limit_text);
+                if (parsed > 0 && parsed <= 200) {
+                    limit = parsed;
+                }
+            }
+
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(task_id)))
+                .first_async();
+            if (task == null) {
+                return ClankerSaddle.Api.SaddleWire.json_error(404,
+                    @"No task exists with id $(task_id)");
+            }
+
+            var body = new Json.Object();
+            var array = new Json.Array();
+            foreach (var record in yield events.page_for_task(task_id, before, limit + 1)) {
+                if (array.get_length() >= limit) {
+                    body.set_boolean_member("more", true);
+                    break;
+                }
+                array.add_object_element(EventLogJson.event_object(record));
+            }
+            body.set_array_member("events", array);
+            return ClankerSaddle.Api.SaddleWire.json_ok(body);
+        }
+    }
+
+    /**
+     * `GET /tasks/{task_id}/events/stream` — the event log's SSE
+     * channel: the initial most-recent-30 events (oldest first, each
+     * carrying `id:` for EventSource reconnect resume), then a
+     * once-per-second cursor walk streaming every newer row as
+     * `event: append`, with heartbeat pings on quiet seconds.
+     */
+    public class EventStreamSseEndpoint : SseEndpoint {
+
+        private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
+        private EventService events = inject<EventService>();
+
+        /** Reconnect hint for browsers (EventSource retries sooner). */
+        public override uint retry_interval { get { return 3000; } }
+
+        public override async void new_connection(HttpContext http_context,
+                RouteContext route_context, SseStream stream) {
+            string id_text = "";
+            route_context.mapped_parameters.try_get("task_id", out id_text);
+            int64 task_id = int64.parse(id_text);
+
+            int64 cursor = 0;
+            try {
+                var initial = yield events.page_for_task(task_id, 0, EVENT_PAGE_SIZE);
+                var rows = new Vector<EventRecord>();
+                foreach (var record in initial) {
+                    rows.add(record);
+                }
+                for (int i = (int) rows.length - 1; i >= 0; i--) {
+                    var record = rows.get(i);
+                    yield stream.send_event(new SseEvent.with_id(
+                        record.id.to_string(), EventLogJson.event_payload(record)));
+                    cursor = record.id;
+                }
+                while (!stream.is_closed) {
+                    bool pushed = false;
+                    foreach (var record in yield events.newer_than(task_id, cursor)) {
+                        yield stream.send_event(new SseEvent.with_id(
+                            record.id.to_string(), EventLogJson.event_payload(record)));
+                        cursor = record.id;
+                        pushed = true;
+                    }
+                    if (!pushed) {
+                        yield stream.send_event(new SseEvent("ping"));
+                    }
+                    yield nap_seconds(1);
+                }
+            } catch (Error e) {
+                if (!stream.is_closed) {
+                    warning(@"[ClankerSaddle] Event stream for task $(task_id) ended: $(e.message)");
+                }
+            }
+        }
+
+        private static async void nap_seconds(uint seconds) {
+            GLib.Timeout.add_seconds(seconds, () => {
+                nap_seconds.callback();
+                return false;
+            });
+            yield;
+        }
+    }
+
+    /** The wire shape shared by the JSON page and the SSE stream. */
+    internal class EventLogJson : Object {
+
+        public static Json.Object event_object(EventRecord record) {
+            var obj = new Json.Object();
+            obj.set_int_member("id", record.id);
+            obj.set_int_member("session_id", record.session_id);
+            obj.set_int_member("seq", record.seq);
+            obj.set_string_member("kind", record.kind);
+            obj.set_string_member("actor", record.actor);
+            obj.set_string_member("created_display",
+                record.created_at != null
+                    ? ((!)record.created_at).format("%Y-%m-%d %H:%M:%S") : "");
+            obj.set_object_member("payload", EventService.payload_of(record));
+            return obj;
+        }
+
+        public static string event_payload(EventRecord record) {
+            var generator = new Json.Generator();
+            generator.set_root(new Json.Node(Json.NodeType.OBJECT)
+                .init_object(event_object(record)));
+            return generator.to_data(null);
+        }
+    }
+}

+ 34 - 37
src/Api/SaddleApi.vala

@@ -674,25 +674,31 @@ namespace ClankerSaddle.Api {
     }
     }
 
 
     /**
     /**
-     * `POST /api/saddle/chunks` (plan 10 phase 2) — the raw-PTY push
-     * channel, authorised by the pushing saddle's bearer token:
+     * `POST /api/saddle/chunks` (plan 10 phase 2; plan 17 §7.2) — the
+     * daemon's event push channel, authorised by the pushing saddle's
+     * bearer token:
      *
      *
      * ```json
      * ```json
-     * {"session_id": 12, "chunks": [{"seq": 41, "text": "…"}]}
+     * {"task_id": 20, "session_id": 34,
+     *  "events": [ { "seq": 61, "kind": "tool_call", "actor": "model",
+     *                "ts": "…ISO8601 UTC…",
+     *                "payload": { "turn": 12, "tool": "run_command",
+     *                             "args": { "command": "…" } } } ]}
      * ```
      * ```
      *
      *
-     * The daemon's flusher batches chunks (at least twice per round and
-     * on a 10 s timer while output flows) and pushes them here; the
-     * batch is stored into `session_raw_log` (so the SSE terminal keeps
-     * working unchanged), idempotent on the existing (session_id, seq)
-     * uniqueness — an outbox redelivery after a lost response is
-     * consumed, not duplicated. Any push implies the daemon is alive
-     * (the staleness clock resets).
+     * `seq` is monotonic per session across all kinds; the batch stores
+     * idempotently on the events ledger's UNIQUE(session_id, seq) — a
+     * redelivered seq is consumed, not an error. Kinds: turn_text,
+     * tool_call, tool_result, progress, lifecycle, usage, state_change;
+     * actors model|harness; the daemon's `ts` is authoritative. Any
+     * push implies the daemon is alive (the staleness clock resets).
+     * Response `{"ok": true, "stored": <newly stored count>}`.
      */
      */
     public class SaddleChunksEndpoint : Object, Endpoint {
     public class SaddleChunksEndpoint : Object, Endpoint {
 
 
         private SaddleService saddles = inject<SaddleService>();
         private SaddleService saddles = inject<SaddleService>();
         private SaddlePollState poll_state = inject<SaddlePollState>();
         private SaddlePollState poll_state = inject<SaddlePollState>();
+        private EventService events = inject<EventService>();
         private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
         private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
 
 
         public virtual async HttpResult handle_request(HttpContext http_context,
         public virtual async HttpResult handle_request(HttpContext http_context,
@@ -718,16 +724,16 @@ namespace ClankerSaddle.Api {
                     @"No session $(session_id) exists on saddle $(((!)saddle).id)");
                     @"No session $(session_id) exists on saddle $(((!)saddle).id)");
             }
             }
 
 
-            if (!(body.has_member("chunks")
-                    && body.get_member("chunks").get_node_type() == Json.NodeType.ARRAY)) {
+            if (!(body.has_member("events")
+                    && body.get_member("events").get_node_type() == Json.NodeType.ARRAY)) {
                 return SaddleWire.json_error(400,
                 return SaddleWire.json_error(400,
-                    "A chunks array is required");
+                    "An events array is required");
             }
             }
 
 
             poll_state.note_push(session_id);
             poll_state.note_push(session_id);
             try {
             try {
-                int64 stored = yield saddles.store_pushed_chunks(session_id,
-                    body.get_array_member("chunks"));
+                int64 stored = yield events.store_pushed_events(session_id,
+                    ((!)session).task_id, body.get_array_member("events"));
                 var response = new Json.Object();
                 var response = new Json.Object();
                 response.set_int_member("stored", stored);
                 response.set_int_member("stored", stored);
                 return SaddleWire.json_ok(response);
                 return SaddleWire.json_ok(response);
@@ -753,29 +759,27 @@ namespace ClankerSaddle.Api {
     }
     }
 
 
     /**
     /**
-     * `POST /api/saddle/results` (plan 10 phase 2) — the round-results
-     * push channel, authorised by the pushing saddle's bearer token:
+     * `POST /api/saddle/results` (plan 10 phase 2; plan 17 §7.2 — the
+     * state transitions are unchanged, the entries array is gone)
+     * — the round-results push channel, authorised by the pushing
+     * saddle's bearer token:
      *
      *
      * ```json
      * ```json
      * {"session_id": 12, "state": "blocked",
      * {"session_id": 12, "state": "blocked",
-     *  "entries": [{"id": 9, "command": "…", "output": "…", "exit_code": 0,
-     *               "type": "command", "timestamp": "…"}],
      *  "block": {"question": "…", "type": "question", "options": […],
      *  "block": {"question": "…", "type": "question", "options": […],
      *            "body": "…"},
      *            "body": "…"},
      *  "summary": "…"}
      *  "summary": "…"}
      * ```
      * ```
      *
      *
-     * Entries keep today's OutputEntry shape and store like the deleted
-     * /output poll path; plan 13 B1 makes the daemon's global entry
-     * ids a DAEMON-GLOBAL (saddle, id) dedup persisted in the database
-     * — a redelivery under any session id (restart, churn, a mis-keyed
-     * send) is consumed silently, and only freshly-claimed entries
-     * project. The state transitions drive the same bookkeeping the
-     * /status poll switch ran: `blocked` raises the block payload as an
+     * The state transitions drive the same bookkeeping the /status
+     * poll switch ran: `blocked` raises the block payload as an
      * answerable `task_blocks` row (the pulse bump makes the question
      * answerable `task_blocks` row (the pulse bump makes the question
-     * appear on live pages) and parks the saddle; `suspended` parks for
-     * the subtask-event resume; `done`/`failed` complete or fail the
-     * task with the pushed summary; `working` flips the task to working.
+     * appear on live pages) and parks the saddle; `suspended` parks
+     * for the task-terminal-event resume; `done`/`failed` complete or
+     * fail the task with the pushed summary; `working` flips the task
+     * to working. Round entries travel as ledger events on the chunks
+     * channel now — an `entries` member is consumed silently (the
+     * daemon workstream stops sending it).
      */
      */
     public class SaddleResultsEndpoint : Object, Endpoint {
     public class SaddleResultsEndpoint : Object, Endpoint {
 
 
@@ -818,16 +822,9 @@ namespace ClankerSaddle.Api {
 
 
             poll_state.note_push(session_id);
             poll_state.note_push(session_id);
             try {
             try {
-                var response = new Json.Object();
-                if (body.has_member("entries")
-                        && body.get_member("entries").get_node_type() == Json.NodeType.ARRAY) {
-                    var stored = yield saddles.store_pushed_entries((!)saddle, session_id,
-                        body.get_array_member("entries"));
-                    yield saddles.apply_entry_events(((!)session).task_id, stored, tasks);
-                }
                 yield saddles.apply_result_state((!)session, state, block,
                 yield saddles.apply_result_state((!)session, state, block,
                     summary, tasks);
                     summary, tasks);
-                return SaddleWire.json_ok(response);
+                return SaddleWire.json_ok(new Json.Object());
             } catch (Error e) {
             } catch (Error e) {
                 return SaddleWire.json_error(400, e.message);
                 return SaddleWire.json_error(400, e.message);
             }
             }

+ 26 - 87
src/Data/Entities.vala

@@ -117,21 +117,38 @@ namespace ClankerSaddle.Data {
     }
     }
 
 
     /**
     /**
-     * One LLM-reported progress line on a task (`saddle progress-update`),
-     * stored in the `task_progress` table.
+     * One row of the plan-17 unified event ledger (`events`): daemon
+     * events carry their session and its per-session monotonic `seq`
+     * (the idempotency key), web-originated events carry neither. The
+     * payload is a JSON object whose shape depends on `kind` (see
+     * Services.EventService).
      */
      */
-    public class TaskProgress : Object {
+    public class EventRecord : Object {
 
 
         public int64 id { get; set; }
         public int64 id { get; set; }
-        public int64 task_id { get; set; }
-        public string summary { get; set; default = ""; }
+        public int64 session_id { get; set; default = 0; }
+        public int64 seq { get; set; default = 0; }
+        public string kind { get; set; default = ""; }
+        public string actor { get; set; default = ""; }
+        public string scope_type { get; set; default = ""; }
+        public int64 scope_id { get; set; }
+        public string payload { get; set; default = "{}"; }
         public DateTime? created_at { get; set; default = null; }
         public DateTime? created_at { get; set; default = null; }
 
 
-        public static void entity_mapping(EntityMapperBuilder<TaskProgress> cfg) {
-            cfg.table("task_progress")
+        public static void entity_mapping(EntityMapperBuilder<EventRecord> cfg) {
+            cfg.table("events")
                 .column<int64?>("id", o => o.id, (o, v) => o.id = v)
                 .column<int64?>("id", o => o.id, (o, v) => o.id = v)
-                .column<int64?>("task_id", o => o.task_id, (o, v) => o.task_id = v)
-                .column<string>("summary", o => o.summary, (o, v) => o.summary = v)
+                .column<int64?>("session_id",
+                    o => o.session_id > 0 ? (int64?) o.session_id : (int64?) null,
+                    (o, v) => o.session_id = v ?? 0)
+                .column<int64?>("seq",
+                    o => o.seq > 0 ? (int64?) o.seq : (int64?) null,
+                    (o, v) => o.seq = v ?? 0)
+                .column<string>("kind", o => o.kind, (o, v) => o.kind = v)
+                .column<string>("actor", o => o.actor, (o, v) => o.actor = v)
+                .column<string>("scope_type", o => o.scope_type, (o, v) => o.scope_type = v)
+                .column<int64?>("scope_id", o => o.scope_id, (o, v) => o.scope_id = v ?? 0)
+                .column<string>("payload", o => o.payload, (o, v) => o.payload = v)
                 .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
                 .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
         }
         }
     }
     }
@@ -260,62 +277,6 @@ namespace ClankerSaddle.Data {
         }
         }
     }
     }
 
 
-    /**
-     * One daemon-global entry id already ingested for a saddle (plan
-     * 13 B1): the (saddle, entry id) pair is the replay-integrity key.
-     * An outbox redelivery under ANY session id — a web restart, session
-     * churn, a mis-keyed send — finds its row already here and is
-     * consumed silently; the pair survives daemon restarts because it
-     * lives in the database, not in memory.
-     */
-    public class SaddleEntryDedup : Object {
-
-        public int64 id { get; set; }
-        public int64 saddle_id { get; set; }
-        public int64 entry_id { get; set; }
-
-        public static void entity_mapping(EntityMapperBuilder<SaddleEntryDedup> cfg) {
-            cfg.table("saddle_entry_dedup")
-                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
-                .column<int64?>("saddle_id", o => o.saddle_id, (o, v) => o.saddle_id = v)
-                .column<int64?>("entry_id", o => o.entry_id, (o, v) => o.entry_id = v);
-        }
-    }
-
-    /**
-     * A numbered entry captured during a {@link Session} — a bash
-     * command/output pair or a conversation-level event (comment, block,
-     * progress, review, plan, user-comment) — stored as the saddle daemon
-     * reports it.
-     */
-    public class SessionCommand : Object {
-
-        public int64 id { get; set; }
-        public int64 session_id { get; set; }
-        public string command_text { get; set; default = ""; }
-        public string? output_text { get; set; default = null; }
-        public int64 exit_code { get; set; default = 0; }
-        public string entry_type { get; set; default = "command"; }
-        public string? piped_input { get; set; default = null; }
-        public DateTime? started_at { get; set; default = null; }
-        public DateTime? completed_at { get; set; default = null; }
-        public int64 duration_ms { get; set; default = 0; }
-
-        public static void entity_mapping(EntityMapperBuilder<SessionCommand> cfg) {
-            cfg.table("session_commands")
-                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
-                .column<int64?>("session_id", o => o.session_id, (o, v) => o.session_id = v)
-                .column<string>("command_text", o => o.command_text, (o, v) => o.command_text = v)
-                .column<string>("output_text", o => o.output_text, (o, v) => o.output_text = v)
-                .column<int64?>("exit_code", o => o.exit_code, (o, v) => o.exit_code = v)
-                .column<string>("type", o => o.entry_type, (o, v) => o.entry_type = v)
-                .column<string>("piped_input", o => o.piped_input, (o, v) => o.piped_input = v)
-                .column<DateTime?>("started_at", o => o.started_at, (o, v) => o.started_at = v)
-                .column<DateTime?>("completed_at", o => o.completed_at, (o, v) => o.completed_at = v)
-                .column<int64?>("duration_ms", o => o.duration_ms, (o, v) => o.duration_ms = v);
-        }
-    }
-
     /**
     /**
      * A reusable instruction the dispatched agent must follow, stored in
      * A reusable instruction the dispatched agent must follow, stored in
      * the `rules` table and attached to projects and/or tasks.
      * the `rules` table and attached to projects and/or tasks.
@@ -397,28 +358,6 @@ namespace ClankerSaddle.Data {
         }
         }
     }
     }
 
 
-    /**
-     * One raw PTY output chunk of a {@link Session}, pulled from the
-     * daemon's `GET /log` ring buffer while the session is active
-     * (`session_raw_log`); `seq` is the daemon's monotonic chunk
-     * sequence number.
-     */
-    public class SessionRawLog : Object {
-
-        public int64 id { get; set; }
-        public int64 session_id { get; set; }
-        public int64 seq { get; set; }
-        public string chunk { get; set; default = ""; }
-
-        public static void entity_mapping(EntityMapperBuilder<SessionRawLog> cfg) {
-            cfg.table("session_raw_log")
-                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
-                .column<int64?>("session_id", o => o.session_id, (o, v) => o.session_id = v)
-                .column<int64?>("seq", o => o.seq, (o, v) => o.seq = v)
-                .column<string>("chunk", o => o.chunk, (o, v) => o.chunk = v);
-        }
-    }
-
     /**
     /**
      * A named tag used for dispatch routing (`capabilities`): saddles
      * A named tag used for dispatch routing (`capabilities`): saddles
      * declare the tags they carry, tasks the tags they require.
      * declare the tags they carry, tasks the tags they require.

+ 43 - 147
src/Data/Migrations/M0001_Initial.vala

@@ -5,26 +5,22 @@ namespace ClankerSaddle.Data.Migrations {
     /**
     /**
      * The full ClankerSaddle schema in one migration (plan 11: every
      * The full ClankerSaddle schema in one migration (plan 11: every
      * earlier migration flattened — the database is not deployed, so
      * earlier migration flattened — the database is not deployed, so
-     * tear-down-and-recreate replaced incremental evolution). Covers:
-     * projects and their links; the plan-12 goals (with links —
-     * plan 15 C1 deleted `goal_blocks` and C2 deleted `goal_comments`:
-     * the goal agent's blocks and comments ride the run task's own
-     * rows) and the
-     * flattened task shape (`goal_id`, `kind`, `mode`,
-     * `creator`, `enqueue_after_task_id` — the subtask machinery's
-     * parent_task_id/plan/auto-* columns and the task_dependencies
-     * table are gone); tasks with comments, typed blocks, progress
-     * and the draft-lifecycle columns (`status` defaults to
-     * 'draft'); saddle registrations with git identity
-     * and the per-saddle token; the session + command + raw-log audit
-     * trail; the plan-13 daemon-global entry dedup
-     * (`saddle_entry_dedup`); rules and their attachments; project
-     * notes; capability tags; and the plan-11 notification tables for
-     * task E
-     * (`push_subscriptions`, `task_notify_prefs` — included now so
-     * D+E needs no migration). Plan 15 C3 deleted `goal_events` (the
-     * narrative is derived; the terminal-transition watermark lives
-     * on the goals row).
+     * tear-down-and-recreate replaced incremental evolution; plan 17
+     * flattened again over the force-wake era). Covers: projects and
+     * their links; the plan-12 goals (with links — plan 15 C1/C2
+     * deleted the goal-side block/comment tables: the goal agent's
+     * blocks and comments ride the run task's own rows) and the
+     * flattened task shape (`goal_id`, `kind`, `mode`, `creator`,
+     * `enqueue_after_task_id`, plus the force-wake flags folded in
+     * from the deleted M0002); tasks with comments and typed blocks;
+     * saddle registrations with git identity and the per-saddle
+     * token; sessions; the plan-17 UNIFIED EVENT LEDGER (`events`)
+     * that replaced `session_commands`, `task_progress`,
+     * `session_raw_log` and `saddle_entry_dedup` — every timeline,
+     * narrative, resume-notice and log-view surface derives from it,
+     * and daemon chunk pushes are idempotent on its
+     * UNIQUE(session_id, seq); rules and their attachments; project
+     * notes; capability tags; the plan-11 notification tables.
      */
      */
     public class M0001_Initial : Migration {
     public class M0001_Initial : Migration {
 
 
@@ -61,20 +57,6 @@ namespace ClankerSaddle.Data.Migrations {
                     .not_null();
                     .not_null();
             });
             });
 
 
-            // Plan 12 goals: user-created units of agent-orchestrated
-            // work. The brief locks once started; `branch_name` is the
-            // user-named goal branch (uniqueness-validated against the
-            // other live goals sharing involved repos); `plan` holds
-            // the APPROVED plan markdown (rejected revisions live on
-            // as the run task's answered plan blocks);
-            // `last_terminal_watermark` (plan 15 C3, replacing the
-            // goal_events-id watermark) is the timestamp through which
-            // the goal agent has consumed its tasks' TERMINAL
-            // transitions (resume_due computes from transitions newer
-            // than it; the narrative itself is a derived read-time
-            // view). Plan 15 C4: the daemon-reported goal-branch push
-            // flags are LIVE per-saddle probe state (never a stored
-            // snapshot — the F9 bug), so no column exists.
             b.create_table("goals", (t) => {
             b.create_table("goals", (t) => {
                 t.column<int64?>("id")
                 t.column<int64?>("id")
                     .auto_increment()
                     .auto_increment()
@@ -104,6 +86,8 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<DateTime?>("started_at");
                 t.column<DateTime?>("started_at");
                 t.column<DateTime?>("completed_at");
                 t.column<DateTime?>("completed_at");
                 t.column<DateTime?>("paused_at");
                 t.column<DateTime?>("paused_at");
+                t.column<bool>("force_wake")
+                    .default_value<bool>(false);
             });
             });
 
 
             b.create_table("goal_links", (t) => {
             b.create_table("goal_links", (t) => {
@@ -130,12 +114,6 @@ namespace ClankerSaddle.Data.Migrations {
                     .not_null()
                     .not_null()
                     .references("projects", "id")
                     .references("projects", "id")
                     .indexed();
                     .indexed();
-                // Plan 12: goals own their task graph — a task links to
-                // its goal (null = standalone), runs as a plain worker
-                // or a goal agent (kind), carries a work mode, records
-                // its creator (enqueue is creator-only) and —
-                // human-created tasks only — an auto-enqueue
-                // predecessor.
                 t.column<int64?>("goal_id")
                 t.column<int64?>("goal_id")
                     .references("goals", "id")
                     .references("goals", "id")
                     .indexed();
                     .indexed();
@@ -150,8 +128,6 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<string>("title")
                 t.column<string>("title")
                     .not_null();
                     .not_null();
                 t.column<string>("description");
                 t.column<string>("description");
-                // Plan 11 draft lifecycle: new tasks start in draft and
-                // only the human Enqueue action queues them.
                 t.column<string>("status")
                 t.column<string>("status")
                     .default_value<string>("draft")
                     .default_value<string>("draft")
                     .indexed();
                     .indexed();
@@ -164,6 +140,8 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<DateTime?>("started_at");
                 t.column<DateTime?>("started_at");
                 t.column<DateTime?>("completed_at");
                 t.column<DateTime?>("completed_at");
                 t.column<DateTime?>("suspended_at");
                 t.column<DateTime?>("suspended_at");
+                t.column<bool>("force_wake")
+                    .default_value<bool>(false);
             });
             });
 
 
             b.create_table("task_comments", (t) => {
             b.create_table("task_comments", (t) => {
@@ -202,42 +180,7 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<DateTime?>("created_at");
                 t.column<DateTime?>("created_at");
             });
             });
 
 
-            b.create_table("task_progress", (t) => {
-                t.column<int64?>("id")
-                    .auto_increment()
-                    .primary_key();
-                t.column<int64?>("task_id")
-                    .not_null()
-                    .references("tasks", "id")
-                    .indexed();
-                t.column<string>("summary")
-                    .not_null();
-                t.column<DateTime?>("created_at");
-            });
-
-            // Plan 15 C1/C2: the goal-side block and comment tables
-            // are GONE — the goal agent's questions, plans and
-            // completion reports ride the RUN TASK's task_blocks rows
-            // (one row per question, answered in place; no dual-row
-            // pair can fall out of sync), and its comment thread IS
-            // the run task's task_comments (human comments inject
-            // through the existing delivery paths).
-
-            // Plan 15 C3: the goal_events table is GONE — the
-            // supervision narrative is a DERIVED read-time view (over
-            // the goal's lifecycle stamps, the run task's blocks and
-            // comments, and the goal's tasks' created/terminal
-            // transitions), and the resume watermark lives on the
-            // goals row as last_terminal_watermark. Nothing is
-            // written on ingest.
-
             b.create_table("saddles", (t) => {
             b.create_table("saddles", (t) => {
-                // Plan 16 item 23: the saddle row carries NO work
-                // cache — sessions are the source of truth for "who
-                // runs what" (busy/idle derived from open sessions).
-                // `status` is only the OFFLINE probe state: 'idle'
-                // (reachable/unknown) or 'offline' (the poller's
-                // unreachable stamp).
                 t.column<int64?>("id")
                 t.column<int64?>("id")
                     .auto_increment()
                     .auto_increment()
                     .primary_key();
                     .primary_key();
@@ -267,51 +210,37 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<DateTime?>("started_at");
                 t.column<DateTime?>("started_at");
                 t.column<DateTime?>("ended_at");
                 t.column<DateTime?>("ended_at");
                 t.column<string>("outcome");
                 t.column<string>("outcome");
-                // Plan 14 B4: what a RESUMED session's agent first
-                // received — the resume_notice the allocation built,
-                // stored on the session row at resume-allocation (null
-                // for fresh sessions; the terminal page shows it above
-                // the log).
                 t.column<string>("resume_notice");
                 t.column<string>("resume_notice");
             });
             });
 
 
-            b.create_table("session_commands", (t) => {
+            // Plan 17 §7.3: the unified append-only event ledger.
+            // Daemon events carry their session and its monotonic seq
+            // (idempotent upsert on UNIQUE(session_id, seq) — the
+            // entire dedup machinery in one constraint); web-originated
+            // events carry neither (NULL session_id rows never collide
+            // under SQLite's NULLs-distinct unique indexes). Everything
+            // downstream — timelines, goal narratives, resume-notice
+            // coalescing, the event log view — derives from this table.
+            b.create_table("events", (t) => {
                 t.column<int64?>("id")
                 t.column<int64?>("id")
                     .auto_increment()
                     .auto_increment()
                     .primary_key();
                     .primary_key();
                 t.column<int64?>("session_id")
                 t.column<int64?>("session_id")
-                    .not_null()
-                    .references("sessions", "id")
-                    .indexed();
-                t.column<string>("command_text")
+                    .references("sessions", "id");
+                t.column<int64?>("seq");
+                t.column<string>("kind")
                     .not_null();
                     .not_null();
-                t.column<string>("output_text");
-                t.column<int64?>("exit_code");
-                t.column<string>("type")
-                    .default_value<string>("command");
-                t.column<string>("piped_input");
-                t.column<DateTime?>("started_at");
-                t.column<DateTime?>("completed_at");
-                t.column<int64?>("duration_ms");
-            });
-
-            // Plan 13 B1: the daemon-global entry dedup — every pushed
-            // entry id this saddle has already ingested. The unique
-            // (saddle_id, entry_id) pair is the replay-integrity key: a
-            // redelivered entry under ANY session id (web restart,
-            // session churn, a mis-keyed send) finds its row here and is
-            // consumed silently.
-            b.create_table("saddle_entry_dedup", (t) => {
-                t.column<int64?>("id")
-                    .auto_increment()
-                    .primary_key();
-                t.column<int64?>("saddle_id")
-                    .not_null()
-                    .references("saddles", "id")
-                    .indexed();
-                t.column<int64?>("entry_id")
+                t.column<string>("actor")
+                    .not_null();
+                t.column<string>("scope_type")
+                    .not_null();
+                t.column<int64?>("scope_id")
+                    .not_null();
+                t.column<string>("payload")
+                    .not_null();
+                t.column<DateTime?>("created_at")
                     .not_null();
                     .not_null();
-                t.unique("uq_saddle_entry_dedup_pair", {"saddle_id", "entry_id"});
+                t.unique("uq_events_session_seq", {"session_id", "seq"});
             });
             });
 
 
             b.create_table("rules", (t) => {
             b.create_table("rules", (t) => {
@@ -324,16 +253,9 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<string>("body")
                 t.column<string>("body")
                     .not_null();
                     .not_null();
                 t.column<DateTime?>("created_at");
                 t.column<DateTime?>("created_at");
-                // Bumped on edit — the resume-time context_update
-                // delta keys on it (a rule edited while a workspace
-                // was parked must reach the agent as a turn).
                 t.column<DateTime?>("updated_at");
                 t.column<DateTime?>("updated_at");
             });
             });
 
 
-            // The attachment timestamps (created_at) feed the same
-            // delta: attaching an existing rule to a task or project
-            // changes the effective instruction set without touching
-            // the rule row.
             b.create_table("rule_projects", (t) => {
             b.create_table("rule_projects", (t) => {
                 t.column<int64?>("id")
                 t.column<int64?>("id")
                     .auto_increment()
                     .auto_increment()
@@ -381,21 +303,6 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<DateTime?>("created_at");
                 t.column<DateTime?>("created_at");
             });
             });
 
 
-            b.create_table("session_raw_log", (t) => {
-                t.column<int64?>("id")
-                    .auto_increment()
-                    .primary_key();
-                t.column<int64?>("session_id")
-                    .not_null()
-                    .references("sessions", "id")
-                    .indexed();
-                t.column<int64?>("seq")
-                    .not_null();
-                t.column<string>("chunk")
-                    .not_null();
-                t.unique("uq_session_raw_log_seq", {"session_id", "seq"});
-            });
-
             b.create_table("capabilities", (t) => {
             b.create_table("capabilities", (t) => {
                 t.column<int64?>("id")
                 t.column<int64?>("id")
                     .auto_increment()
                     .auto_increment()
@@ -436,11 +343,6 @@ namespace ClankerSaddle.Data.Migrations {
                 t.unique("uq_task_capabilities_pair", {"capability_id", "task_id"});
                 t.unique("uq_task_capabilities_pair", {"capability_id", "task_id"});
             });
             });
 
 
-            // Plan 11 task E: Web Push subscriptions, one row per
-            // browser endpoint a user registered (the VAPID keypair is
-            // generated at startup and persisted in the config dir, not
-            // here). `user_id` references the Spry users table by value
-            // without an FK — the auth schema owns that table.
             b.create_table("push_subscriptions", (t) => {
             b.create_table("push_subscriptions", (t) => {
                 t.column<int64?>("id")
                 t.column<int64?>("id")
                     .auto_increment()
                     .auto_increment()
@@ -458,9 +360,6 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<DateTime?>("created_at");
                 t.column<DateTime?>("created_at");
             });
             });
 
 
-            // Plan 11 task E: a user's per-task "Notify me" opt-in for
-            // the task's first start, failure and completion (block
-            // events notify all push-subscribed users globally).
             b.create_table("task_notify_prefs", (t) => {
             b.create_table("task_notify_prefs", (t) => {
                 t.column<int64?>("id")
                 t.column<int64?>("id")
                     .auto_increment()
                     .auto_increment()
@@ -477,21 +376,18 @@ namespace ClankerSaddle.Data.Migrations {
         }
         }
 
 
         public override void down(MigrationBuilder b) {
         public override void down(MigrationBuilder b) {
-            b.drop_table("saddle_entry_dedup");
             b.drop_table("task_notify_prefs");
             b.drop_table("task_notify_prefs");
             b.drop_table("push_subscriptions");
             b.drop_table("push_subscriptions");
             b.drop_table("task_capabilities");
             b.drop_table("task_capabilities");
             b.drop_table("saddle_capabilities");
             b.drop_table("saddle_capabilities");
             b.drop_table("capabilities");
             b.drop_table("capabilities");
-            b.drop_table("session_raw_log");
             b.drop_table("project_notes");
             b.drop_table("project_notes");
             b.drop_table("task_rules");
             b.drop_table("task_rules");
             b.drop_table("rule_projects");
             b.drop_table("rule_projects");
             b.drop_table("rules");
             b.drop_table("rules");
-            b.drop_table("session_commands");
+            b.drop_table("events");
             b.drop_table("sessions");
             b.drop_table("sessions");
             b.drop_table("saddles");
             b.drop_table("saddles");
-            b.drop_table("task_progress");
             b.drop_table("task_blocks");
             b.drop_table("task_blocks");
             b.drop_table("task_comments");
             b.drop_table("task_comments");
             b.drop_table("tasks");
             b.drop_table("tasks");

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

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

+ 28 - 7
src/Services/AllocationService.vala

@@ -81,6 +81,7 @@ namespace ClankerSaddle.Services {
         private StatePulse pulse = inject<StatePulse>();
         private StatePulse pulse = inject<StatePulse>();
         private PushService push = inject<PushService>();
         private PushService push = inject<PushService>();
         private SaddlePollState poll_state = inject<SaddlePollState>();
         private SaddlePollState poll_state = inject<SaddlePollState>();
+        private EventService events = inject<EventService>();
 
 
         /**
         /**
          * Allocates the best offerable task to `saddle`, or null when
          * Allocates the best offerable task to `saddle`, or null when
@@ -444,6 +445,14 @@ namespace ClankerSaddle.Services {
             }
             }
 
 
             poll_state.note_push(claim.session_id);
             poll_state.note_push(claim.session_id);
+            var dispatch_payload = new Json.Object();
+            dispatch_payload.set_int_member("session_id", claim.session_id);
+            dispatch_payload.set_string_member("saddle", saddle.name);
+            dispatch_payload.set_string_member("action", "dispatch");
+            dispatch_payload.set_string_member("text",
+                @"Dispatched to $(saddle.name) (session #$(claim.session_id))");
+            yield events.append(EVENT_LIFECYCLE, EVENT_ACTOR_SYSTEM,
+                EVENT_SCOPE_TASK, task.id, dispatch_payload);
             var payload = yield saddles.build_assignment_payload(saddle, task,
             var payload = yield saddles.build_assignment_payload(saddle, task,
                 claim.session_id, projects, resume_notice, branch_advanced,
                 claim.session_id, projects, resume_notice, branch_advanced,
                 context_update);
                 context_update);
@@ -511,13 +520,25 @@ namespace ClankerSaddle.Services {
             if (previous_session == 0 || watermark == null) {
             if (previous_session == 0 || watermark == null) {
                 return null;
                 return null;
             }
             }
-            foreach (var command in yield db.query<SessionCommand>()
-                    .where(expr("session_id == $0", elem<int64?>(previous_session)))
-                    .order_by(expr("id"))
-                    .materialise_async()) {
-                DateTime? at = command.completed_at ?? command.started_at;
-                if (at != null && ((!)at).compare((!)watermark) > 0) {
-                    watermark = at;
+            var previous = yield db.query<Session>()
+                .where(expr("id == $0", elem<int64?>(previous_session)))
+                .first_async();
+            if (previous != null) {
+                DateTime? last = yield events.last_session_activity(
+                    ((!)previous).task_id, previous_session);
+                if (last != null && ((!)last).compare((!)watermark) > 0) {
+                    // The previous protocol's watermark lagged its
+                    // round's end (entry stamps landed early), so
+                    // same-second human actions — an approval landing
+                    // moments after the round's last event — read as
+                    // newer. Ledger events stamp at second granularity
+                    // at best, so the floored watermark steps back one
+                    // second to keep that property.
+                    var stamp = (!)last;
+                    watermark = new DateTime.local(stamp.get_year(),
+                        stamp.get_month(), stamp.get_day_of_month(),
+                        stamp.get_hour(), stamp.get_minute(),
+                        stamp.get_second()).add_seconds(-1);
                 }
                 }
             }
             }
 
 

+ 363 - 0
src/Services/EventService.vala

@@ -0,0 +1,363 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql.Orm;
+using Inversion;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /** Event kinds (the `events.kind` values, plan 17 §3.1/§7.2). */
+    public const string EVENT_TURN_TEXT = "turn_text";
+    public const string EVENT_TOOL_CALL = "tool_call";
+    public const string EVENT_TOOL_RESULT = "tool_result";
+    public const string EVENT_COMMAND_OUTPUT = "command_output";
+    public const string EVENT_PROGRESS = "progress";
+    public const string EVENT_COMMENT = "comment";
+    public const string EVENT_STATE_CHANGE = "state_change";
+    public const string EVENT_LIFECYCLE = "lifecycle";
+    public const string EVENT_PLAN_SUBMITTED = "plan_submitted";
+    public const string EVENT_VERDICT = "verdict";
+    public const string EVENT_FORCE_WAKE = "force_wake";
+    public const string EVENT_USAGE = "usage";
+    public const string EVENT_ANSWER = "answer";
+    public const string EVENT_CANCEL = "cancel";
+
+    /** Event actors (the `events.actor` values). */
+    public const string EVENT_ACTOR_MODEL = "model";
+    public const string EVENT_ACTOR_HARNESS = "harness";
+    public const string EVENT_ACTOR_USER = "user";
+    public const string EVENT_ACTOR_SYSTEM = "system";
+
+    /** Event scopes (the `events.scope_type` values). */
+    public const string EVENT_SCOPE_PROJECT = "project";
+    public const string EVENT_SCOPE_GOAL = "goal";
+    public const string EVENT_SCOPE_TASK = "task";
+    public const string EVENT_SCOPE_SESSION = "session";
+
+    /** The event log page/SSE window (plan 17 §3.2). */
+    public const int EVENT_PAGE_SIZE = 30;
+
+    /**
+     * The plan-17 unified event ledger's writer and read model: daemon
+     * event ingestion from `POST /api/saddle/chunks` (idempotent on
+     * UNIQUE(session_id, seq)), web-originated appends from the domain
+     * mutations, and the paged/newer-than queries the event log page,
+     * the timeline, the goal narrative and the resume machinery read.
+     */
+    public class EventService : Object {
+
+        private OrmSession db = inject<OrmSession>();
+
+        // ------------------------------------------------------------------
+        // Writers
+        // ------------------------------------------------------------------
+
+        /**
+         * Appends one web-originated event: session_id 0 and seq NULL
+         * (the unique constraint never bites — SQLite treats NULLs as
+         * distinct), `created_at` stamped now. `payload` may be null
+         * (an empty object stores).
+         */
+        public async EventRecord append(string kind, string actor, string scope_type,
+                int64 scope_id, Json.Object? payload) throws Error {
+            var record = new EventRecord() {
+                kind = kind,
+                actor = actor,
+                scope_type = scope_type,
+                scope_id = scope_id,
+                payload = payload != null ? json_to_text((!)payload) : "{}",
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<EventRecord>(record);
+            return record;
+        }
+
+        /**
+         * Stores a pushed batch of daemon events (plan 17 §7.2: the
+         * `events[]` of `POST /api/saddle/chunks`) as ledger rows
+         * scoped to the session's task. Idempotent on the unique
+         * (session_id, seq) pair: a redelivered seq is consumed, not
+         * an error; an event without a positive seq is refused (the
+         * daemon contract makes seq mandatory). The daemon's `ts` is
+         * authoritative for `created_at` (fallback: now). Returns the
+         * count of rows newly stored.
+         */
+        public async int64 store_pushed_events(int64 session_id, int64 task_id,
+                Json.Array events) throws Error {
+            int64 stored = 0;
+            var staged = new Vector<EventRecord>();
+            foreach (var element in events.get_elements()) {
+                if (element.get_node_type() != Json.NodeType.OBJECT) {
+                    continue;
+                }
+                var incoming = element.get_object();
+                int64 seq = incoming.has_member("seq")
+                    && incoming.get_member("seq").get_value_type() == typeof(int64)
+                    ? incoming.get_int_member("seq") : 0;
+                if (seq <= 0) {
+                    throw new Error(Quark.from_string("clanker-saddle"), 90,
+                        "Every pushed event needs a positive per-session seq");
+                }
+                string kind = EntryJson.string_member(incoming, "kind");
+                if (kind.length == 0) {
+                    throw new Error(Quark.from_string("clanker-saddle"), 91,
+                        "Every pushed event needs a kind");
+                }
+                string actor = EntryJson.string_member(incoming, "actor");
+                var payload_node = incoming.has_member("payload")
+                    && incoming.get_member("payload").get_node_type() == Json.NodeType.OBJECT
+                    ? incoming.get_member("payload") : null;
+                staged.add(new EventRecord() {
+                    session_id = session_id,
+                    seq = seq,
+                    kind = kind,
+                    actor = actor.length > 0 ? actor : EVENT_ACTOR_HARNESS,
+                    scope_type = EVENT_SCOPE_TASK,
+                    scope_id = task_id,
+                    payload = payload_node != null
+                        ? json_to_text((!)payload_node.get_object()) : "{}",
+                    created_at = EntryJson.parse_iso(incoming, "ts")
+                        ?? new DateTime.now_local()
+                });
+            }
+            foreach (var record in staged) {
+                try {
+                    yield db.insert_async<EventRecord>(record);
+                    stored++;
+                } catch (Error insert_error) {
+                    var existing = yield db.query<EventRecord>()
+                        .where(expr("session_id == $0 && seq == $1",
+                            elem<int64?>(session_id), elem<int64?>(record.seq)))
+                        .first_async();
+                    if (existing == null) {
+                        throw insert_error;
+                    }
+                }
+            }
+            return stored;
+        }
+
+        // ------------------------------------------------------------------
+        // Readers
+        // ------------------------------------------------------------------
+
+        /**
+         * One newest-first page of a task's ledger (plan 17 §7.4):
+         * events with id below `before` (0 = from the top), `limit`
+         * rows, newest first.
+         */
+        public async ImmutableLot<EventRecord> page_for_task(int64 task_id,
+                int64 before, int64 limit) throws Error {
+            var query = db.query<EventRecord>()
+                .where(expr("scope_type == $0 && scope_id == $1",
+                    elem(EVENT_SCOPE_TASK), elem<int64?>(task_id)));
+            if (before > 0) {
+                query.where(expr("id < $0", elem<int64?>(before)));
+            }
+            return yield query
+                .order_by_desc(expr("id"))
+                .limit(limit)
+                .materialise_async();
+        }
+
+        /**
+         * The task's events strictly newer than `after_id`, oldest
+         * first — the SSE stream's live-append cursor walk.
+         */
+        public async ImmutableLot<EventRecord> newer_than(int64 task_id, int64 after_id)
+                throws Error {
+            return yield db.query<EventRecord>()
+                .where(expr("scope_type == $0 && scope_id == $1 && id > $2",
+                    elem(EVENT_SCOPE_TASK), elem<int64?>(task_id), elem<int64?>(after_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+        }
+
+        /**
+         * The task's events of the given kinds, newest first — the
+         * progress feed and the goal narrative's filtered projections.
+         */
+        public async ImmutableLot<EventRecord> list_for_task(int64 task_id,
+                string[] kinds) throws Error {
+            var query = db.query<EventRecord>()
+                .where(expr("scope_type == $0 && scope_id == $1",
+                    elem(EVENT_SCOPE_TASK), elem<int64?>(task_id)));
+            if (kinds.length > 0) {
+                query.where(kind_in(kinds));
+            }
+            return yield query
+                .order_by_desc(expr("id"))
+                .materialise_async();
+        }
+
+        /** The goal's own events (scope goal), newest first. */
+        public async ImmutableLot<EventRecord> list_for_goal(int64 goal_id) throws Error {
+            return yield db.query<EventRecord>()
+                .where(expr("scope_type == $0 && scope_id == $1",
+                    elem(EVENT_SCOPE_GOAL), elem<int64?>(goal_id)))
+                .order_by_desc(expr("id"))
+                .materialise_async();
+        }
+
+        /**
+         * The newest terminal state_change (to done/failed) among the
+         * tasks' events that is newer than `watermark` — the resume
+         * coalescer's oldest-pending-transition finder. Null when no
+         * unconsumed terminal transition exists.
+         */
+        public async DateTime? oldest_terminal_transition_since(
+                Invercargill.DataStructures.HashSet<string> task_ids,
+                DateTime? watermark) throws Error {
+            var filter = TaskService.id_in("scope_id", task_ids);
+            if (filter == null) {
+                return null;
+            }
+            DateTime? oldest = null;
+            foreach (var record in yield db.query<EventRecord>()
+                    .where(expr("kind == $0 && scope_type == $1",
+                        elem(EVENT_STATE_CHANGE), elem(EVENT_SCOPE_TASK)))
+                    .where((!)filter)
+                    .order_by(expr("id"))
+                    .materialise_async()) {
+                if (!is_terminal_transition(record)) {
+                    continue;
+                }
+                if (record.created_at == null) {
+                    continue;
+                }
+                if (watermark != null
+                        && ((!)record.created_at).compare((!)watermark) <= 0) {
+                    continue;
+                }
+                if (oldest == null || ((!)record.created_at).compare((!)oldest) < 0) {
+                    oldest = record.created_at;
+                }
+            }
+            return oldest;
+        }
+
+        /**
+         * The newest created_at among the tasks' terminal state_change
+         * events — the watermark advance (null when none exist).
+         */
+        public async DateTime? newest_terminal_transition(
+                Invercargill.DataStructures.HashSet<string> task_ids) throws Error {
+            var filter = TaskService.id_in("scope_id", task_ids);
+            if (filter == null) {
+                return null;
+            }
+            DateTime? newest = null;
+            foreach (var record in yield db.query<EventRecord>()
+                    .where(expr("kind == $0 && scope_type == $1",
+                        elem(EVENT_STATE_CHANGE), elem(EVENT_SCOPE_TASK)))
+                    .where((!)filter)
+                    .order_by(expr("id"))
+                    .materialise_async()) {
+                if (!is_terminal_transition(record) || record.created_at == null) {
+                    continue;
+                }
+                if (newest == null || ((!)record.created_at).compare((!)newest) > 0) {
+                    newest = record.created_at;
+                }
+            }
+            return newest;
+        }
+
+        /** True when the record is a state_change to done or failed. */
+        private static bool is_terminal_transition(EventRecord record) {
+            if (record.kind != EVENT_STATE_CHANGE) {
+                return false;
+            }
+            string to = payload_string(record, "to");
+            return to == STATUS_DONE || to == STATUS_FAILED;
+        }
+
+        /**
+         * The task's newest event of the "what is it doing" kinds —
+         * the goal page's current-activity line. Null when none.
+         */
+        public async EventRecord? newest_activity(int64 task_id) throws Error {
+            EventRecord? newest = null;
+            foreach (var record in yield list_for_task(task_id,
+                    { EVENT_TURN_TEXT, EVENT_TOOL_CALL, EVENT_TOOL_RESULT,
+                      EVENT_PROGRESS, EVENT_LIFECYCLE, EVENT_COMMENT })) {
+                newest = record;
+                break;
+            }
+            return newest;
+        }
+
+        /** The newest event stamp of one session (any kind), or null. */
+        public async DateTime? last_session_activity(int64 task_id, int64 session_id)
+                throws Error {
+            DateTime? newest = null;
+            foreach (var record in yield db.query<EventRecord>()
+                    .where(expr("scope_type == $0 && scope_id == $1 && session_id == $2",
+                        elem(EVENT_SCOPE_TASK), elem<int64?>(task_id),
+                        elem<int64?>(session_id)))
+                    .order_by(expr("id"))
+                    .materialise_async()) {
+                if (record.created_at != null) {
+                    newest = record.created_at;
+                }
+            }
+            return newest;
+        }
+
+        /** Deletes every event scoped to the task (the delete cascade). */
+        public async void delete_for_task(int64 task_id) throws Error {
+            foreach (var record in yield db.query<EventRecord>()
+                    .where(expr("scope_type == $0 && scope_id == $1",
+                        elem(EVENT_SCOPE_TASK), elem<int64?>(task_id)))
+                    .materialise_async()) {
+                yield db.delete_async<EventRecord>(record);
+            }
+        }
+
+        // ------------------------------------------------------------------
+        // Payload helpers
+        // ------------------------------------------------------------------
+
+        /** The record's payload as a JSON object (empty when malformed). */
+        public static Json.Object payload_of(EventRecord record) {
+            try {
+                var node = Json.from_string(record.payload ?? "{}");
+                if (node != null && node.get_node_type() == Json.NodeType.OBJECT) {
+                    return node.get_object();
+                }
+            } catch (Error e) {
+            }
+            return new Json.Object();
+        }
+
+        /** One string member of the record's payload ("" when absent). */
+        public static string payload_string(EventRecord record, string member) {
+            return EntryJson.string_member(payload_of(record), member);
+        }
+
+        /** One string member of the record's payload (null when absent). */
+        public static string? payload_optional_string(EventRecord record, string member) {
+            return EntryJson.optional_string_member(payload_of(record), member);
+        }
+
+        /** The record's payload serialised back to compact JSON text. */
+        public static string json_to_text(Json.Object payload) {
+            var generator = new Json.Generator();
+            generator.set_root(new Json.Node(Json.NodeType.OBJECT).init_object(payload));
+            return generator.to_data(null);
+        }
+
+        /** An OR-chain over the kind column for a batch of kinds. */
+        private static Expression kind_in(string[] kinds) throws Error {
+            Expression? chain = null;
+            foreach (var kind in kinds) {
+                var comparison = expr("kind == $0", elem(kind));
+                chain = chain == null
+                    ? comparison : new BinaryExpression((!)chain, comparison,
+                        BinaryOperator.OR);
+            }
+            return (!)chain;
+        }
+    }
+}

+ 224 - 136
src/Services/GoalService.vala

@@ -83,6 +83,18 @@ namespace ClankerSaddle.Services {
         private PushService push = inject<PushService>();
         private PushService push = inject<PushService>();
         private TaskService tasks = inject<TaskService>();
         private TaskService tasks = inject<TaskService>();
         private SaddlePollState polls = inject<SaddlePollState>();
         private SaddlePollState polls = inject<SaddlePollState>();
+        private EventService events = inject<EventService>();
+
+        /** Appends a goal-scoped event with a human-readable line. */
+        private async void append_goal_event(int64 goal_id, string kind, string text,
+                string? to = null) throws Error {
+            var payload = new Json.Object();
+            payload.set_string_member("text", text);
+            if (to != null) {
+                payload.set_string_member("to", (!)to);
+            }
+            yield events.append(kind, EVENT_ACTOR_USER, EVENT_SCOPE_GOAL, goal_id, payload);
+        }
 
 
         /**
         /**
          * SADDLE_GOAL_RESUME_S (default 10): the coalescing window —
          * SADDLE_GOAL_RESUME_S (default 10): the coalescing window —
@@ -157,6 +169,8 @@ namespace ClankerSaddle.Services {
                 created_at = new DateTime.now_local()
                 created_at = new DateTime.now_local()
             };
             };
             yield db.insert_async<Goal>(goal);
             yield db.insert_async<Goal>(goal);
+            yield append_goal_event(goal.id, EVENT_STATE_CHANGE,
+                @"Goal created (branch $(trimmed_branch))", GOAL_DRAFT);
             yield pulse.bump(0, "goal created");
             yield pulse.bump(0, "goal created");
             return goal;
             return goal;
         }
         }
@@ -286,6 +300,8 @@ namespace ClankerSaddle.Services {
                 null, ((!)goal).base_branch, STATUS_QUEUED,
                 null, ((!)goal).base_branch, STATUS_QUEUED,
                 TASK_KIND_GOAL_RUN, MODE_ORCHESTRATE, CREATOR_GOAL,
                 TASK_KIND_GOAL_RUN, MODE_ORCHESTRATE, CREATOR_GOAL,
                 ((!)goal).id, 0);
                 ((!)goal).id, 0);
+            yield append_goal_event(goal_id, EVENT_STATE_CHANGE,
+                "Goal started — the goal agent is planning", GOAL_PLANNING);
             yield pulse.bump(run.id, "goal started");
             yield pulse.bump(run.id, "goal started");
             return goal;
             return goal;
         }
         }
@@ -341,6 +357,9 @@ namespace ClankerSaddle.Services {
             ((!)goal).status = GOAL_PAUSED;
             ((!)goal).status = GOAL_PAUSED;
             ((!)goal).paused_at = new DateTime.now_local();
             ((!)goal).paused_at = new DateTime.now_local();
             yield db.update_async<Goal>((!)goal);
             yield db.update_async<Goal>((!)goal);
+            yield append_goal_event(goal_id, EVENT_STATE_CHANGE,
+                "Goal paused — supervision frozen (terminal transitions queue)",
+                GOAL_PAUSED);
             yield pulse.bump(0, "goal paused");
             yield pulse.bump(0, "goal paused");
             return goal;
             return goal;
         }
         }
@@ -359,6 +378,8 @@ namespace ClankerSaddle.Services {
             ((!)goal).status = GOAL_ACTIVE;
             ((!)goal).status = GOAL_ACTIVE;
             ((!)goal).paused_at = null;
             ((!)goal).paused_at = null;
             yield db.update_async<Goal>((!)goal);
             yield db.update_async<Goal>((!)goal);
+            yield append_goal_event(goal_id, EVENT_STATE_CHANGE,
+                "Goal resumed — supervision restarts", GOAL_ACTIVE);
             yield pulse.bump(0, "goal resumed");
             yield pulse.bump(0, "goal resumed");
             return goal;
             return goal;
         }
         }
@@ -384,6 +405,8 @@ namespace ClankerSaddle.Services {
             }
             }
             ((!)goal).force_wake = true;
             ((!)goal).force_wake = true;
             yield db.update_async<Goal>((!)goal);
             yield db.update_async<Goal>((!)goal);
+            yield append_goal_event(goal_id, EVENT_FORCE_WAKE,
+                "The user explicitly force-woke this goal's supervision");
             yield pulse.bump(0, "goal force-woke");
             yield pulse.bump(0, "goal force-woke");
             return goal;
             return goal;
         }
         }
@@ -409,6 +432,8 @@ namespace ClankerSaddle.Services {
             ((!)goal).status = GOAL_CANCELLED;
             ((!)goal).status = GOAL_CANCELLED;
             ((!)goal).completed_at = new DateTime.now_local();
             ((!)goal).completed_at = new DateTime.now_local();
             yield db.update_async<Goal>((!)goal);
             yield db.update_async<Goal>((!)goal);
+            yield append_goal_event(goal_id, EVENT_CANCEL,
+                "Goal cancelled — its tasks were cancelled with it");
 
 
             yield cascade_goal_tasks(goal_id);
             yield cascade_goal_tasks(goal_id);
             yield pulse.bump(0, "goal cancelled");
             yield pulse.bump(0, "goal cancelled");
@@ -429,6 +454,8 @@ namespace ClankerSaddle.Services {
             ((!)goal).status = GOAL_ACTIVE;
             ((!)goal).status = GOAL_ACTIVE;
             ((!)goal).completed_at = null;
             ((!)goal).completed_at = null;
             yield db.update_async<Goal>((!)goal);
             yield db.update_async<Goal>((!)goal);
+            yield append_goal_event(goal_id, EVENT_STATE_CHANGE,
+                "Supervision retried — the goal agent runs again", GOAL_ACTIVE);
             return goal;
             return goal;
         }
         }
 
 
@@ -488,6 +515,8 @@ namespace ClankerSaddle.Services {
             }
             }
             ((!)goal).status = GOAL_PLAN_REVIEW;
             ((!)goal).status = GOAL_PLAN_REVIEW;
             yield db.update_async<Goal>(((!)goal));
             yield db.update_async<Goal>(((!)goal));
+            yield append_goal_event(goal_id, EVENT_PLAN_SUBMITTED,
+                @"Plan revision #$(((!)raised).id) submitted for review");
             return raised;
             return raised;
         }
         }
 
 
@@ -541,6 +570,8 @@ namespace ClankerSaddle.Services {
             }
             }
             ((!)goal).status = GOAL_COMPLETION_REVIEW;
             ((!)goal).status = GOAL_COMPLETION_REVIEW;
             yield db.update_async<Goal>(((!)goal));
             yield db.update_async<Goal>(((!)goal));
+            yield append_goal_event(goal_id, EVENT_STATE_CHANGE,
+                "Completion report submitted for review", GOAL_COMPLETION_REVIEW);
             return raised;
             return raised;
         }
         }
 
 
@@ -567,6 +598,10 @@ namespace ClankerSaddle.Services {
             ((!)goal).completed_at = new DateTime.now_local();
             ((!)goal).completed_at = new DateTime.now_local();
             ((!)goal).completion_report = report ?? ((!)goal).completion_report;
             ((!)goal).completion_report = report ?? ((!)goal).completion_report;
             yield db.update_async<Goal>(((!)goal));
             yield db.update_async<Goal>(((!)goal));
+            yield append_goal_event(goal_id, EVENT_STATE_CHANGE,
+                (((!)goal).completion_report ?? "").length > 0
+                    ? (!)((!)goal).completion_report : "The goal agent failed the goal",
+                GOAL_FAILED);
 
 
             var run = yield goal_run_task(goal_id);
             var run = yield goal_run_task(goal_id);
             if (run != null && ((!)run).status != STATUS_DONE
             if (run != null && ((!)run).status != STATUS_DONE
@@ -815,18 +850,15 @@ namespace ClankerSaddle.Services {
         }
         }
 
 
         /**
         /**
-         * The supervision narrative (plan 15 C3): a DERIVED read-time
-         * view — nothing is written on ingest any more. The rows come
-         * from the goal's lifecycle stamps (created/started/paused/
-         * terminal), the goal-run task's blocks (plan submissions and
-         * verdicts, questions/RFCs, the completion review) and its
-         * comment thread (agent/user rows as footnote comments,
-         * harness-authored rows as system events), and the goal's
-         * tasks' created and terminal transitions. Timestamp-less
-         * bookkeeping (task-enqueued, task-deleted, resumed) never
-         * returns; the goal-failure CASCADE derives from the
-         * cancelled tasks surviving under a failed goal. Newest
-         * first.
+         * The supervision narrative (plan 17): a filtered projection
+         * of the unified event ledger — the goal's own events
+         * (lifecycle, plan submissions, force-wakes, cancels), the
+         * run task's verdicts/answers and its comment thread (agent
+         * and human remarks as footnote comments, harness-authored
+         * rows as system events), and the goal's worker tasks'
+         * created/terminal transitions. The failure CASCADE derives
+         * from the worker cancel events surviving under a failed
+         * goal. Newest first.
          */
          */
         public async ImmutableLot<NarrativeRow> narrative(int64 goal_id) throws Error {
         public async ImmutableLot<NarrativeRow> narrative(int64 goal_id) throws Error {
             var goal = yield get_goal(goal_id);
             var goal = yield get_goal(goal_id);
@@ -835,135 +867,141 @@ namespace ClankerSaddle.Services {
             }
             }
             var rows = new Vector<NarrativeRow>();
             var rows = new Vector<NarrativeRow>();
 
 
-            // Lifecycle stamps.
-            if (((!)goal).created_at != null) {
-                rows.add(new NarrativeRow() {
-                    kind = "created", at = ((!)goal).created_at,
-                    text = @"Goal created (branch $(((!)goal).branch_name))" });
-            }
-            if (((!)goal).started_at != null) {
-                rows.add(new NarrativeRow() {
-                    kind = "started", at = ((!)goal).started_at,
-                    text = "Goal started — the goal agent is planning" });
-            }
-            if (((!)goal).paused_at != null && ((!)goal).status == GOAL_PAUSED) {
-                rows.add(new NarrativeRow() {
-                    kind = "paused", at = ((!)goal).paused_at,
-                    text = "Goal paused — supervision frozen (terminal transitions queue)" });
-            }
-            if (((!)goal).completed_at != null) {
-                if (((!)goal).status == GOAL_CANCELLED) {
-                    rows.add(new NarrativeRow() {
-                        kind = "cancelled", at = ((!)goal).completed_at,
-                        text = "Goal cancelled — its tasks were cancelled with it" });
-                } else if (((!)goal).status == GOAL_FAILED) {
-                    rows.add(new NarrativeRow() {
-                        kind = "goal-failed", at = ((!)goal).completed_at,
-                        text = (((!)goal).completion_report ?? "").length > 0
-                            ? (!)(((!)goal).completion_report)
-                            : "The goal agent failed the goal" });
+            foreach (var record in yield events.list_for_goal(goal_id)) {
+                if (record.created_at == null) {
+                    continue;
                 }
                 }
+                var payload = EventService.payload_of(record);
+                string text = EntryJson.string_member(payload, "text");
+                string kind = "";
+                switch (record.kind) {
+                    case EVENT_STATE_CHANGE:
+                        kind = narrative_kind_of_goal_status(
+                            EntryJson.string_member(payload, "to"));
+                        break;
+                    case EVENT_PLAN_SUBMITTED:
+                        kind = "plan-submitted";
+                        break;
+                    case EVENT_CANCEL:
+                        kind = "cancelled";
+                        break;
+                    case EVENT_FORCE_WAKE:
+                        kind = "force-wake";
+                        break;
+                    case EVENT_COMMENT:
+                        kind = EntryJson.string_member(payload, "author")
+                            == COMMENT_AUTHOR_SYSTEM ? "system" : "comment";
+                        text = EntryJson.string_member(payload, "body");
+                        break;
+                    default:
+                        continue;
+                }
+                if (kind == "system"
+                        && comment_is_mechanical(text)) {
+                    continue;
+                }
+                rows.add(new NarrativeRow() {
+                    kind = kind, at = record.created_at, text = text
+                });
             }
             }
 
 
-            // The run task's blocks: the plan/completion reviews with
-            // their verdicts, the questions/RFCs.
             var run = yield goal_run_task(goal_id);
             var run = yield goal_run_task(goal_id);
             if (run != null) {
             if (run != null) {
-                foreach (var block in yield tasks.list_blocks(((!)run).id)) {
-                    if (block.created_at != null) {
-                        if (block.block_type == BLOCK_PLAN) {
-                            rows.add(new NarrativeRow() {
-                                kind = "plan-submitted", at = block.created_at,
-                                text = @"Plan revision #$(block.id) submitted for review" });
-                        } else if (is_completion_row(block)) {
-                            rows.add(new NarrativeRow() {
-                                kind = "completion-submitted", at = block.created_at,
-                                text = "Completion report submitted for review" });
-                        } else {
-                            rows.add(new NarrativeRow() {
-                                kind = "block-raised", at = block.created_at,
-                                text = @"$(block.block_type) raised: $(block.question)" });
-                        }
-                    }
-                    if (block.answered_at != null) {
-                        if (block.block_type == BLOCK_PLAN) {
-                            bool approved = block.answer == NOTICE_PLAN_APPROVED;
-                            rows.add(new NarrativeRow() {
-                                kind = approved ? "plan-approved" : "plan-rejected",
-                                at = block.answered_at,
-                                text = approved
-                                    ? @"Plan revision #$(block.id) approved — the goal is active"
-                                    : @"Plan revision #$(block.id) rejected with feedback — the agent revises" });
-                        } else if (is_completion_row(block)) {
-                            bool approved = block.answer == NOTICE_REVIEW_APPROVED;
-                            rows.add(new NarrativeRow() {
-                                kind = approved ? "goal-approved" : "completion-rejected",
-                                at = block.answered_at,
-                                text = approved
-                                    ? "Completion report approved — the goal is complete"
-                                    : "Completion report rejected with feedback — the agent continues" });
-                        } else {
-                            rows.add(new NarrativeRow() {
-                                kind = "block-answered", at = block.answered_at,
-                                text = @"$(block.block_type) answered" });
-                        }
-                    }
-                }
-                // The ONE comment thread: agent/user rows as footnote
-                // comments, harness-authored rows as system events.
-                // Plan 16 S5 (U2): mechanical rows (restores, park
-                // notices) never reach the user-facing narrative.
-                foreach (var comment in yield tasks.list_comments(((!)run).id)) {
-                    if (comment.created_at == null) {
+                foreach (var record in yield events.list_for_task(((!)run).id,
+                        { EVENT_VERDICT, EVENT_ANSWER, EVENT_COMMENT })) {
+                    if (record.created_at == null) {
                         continue;
                         continue;
                     }
                     }
-                    if (comment.author == COMMENT_AUTHOR_SYSTEM
-                            && comment_is_mechanical(comment.body)) {
-                        continue;
+                    var payload = EventService.payload_of(record);
+                    string text = EntryJson.string_member(payload, "text");
+                    string kind = "";
+                    switch (record.kind) {
+                        case EVENT_VERDICT:
+                            kind = narrative_kind_of_verdict(payload);
+                            break;
+                        case EVENT_ANSWER:
+                            kind = "block-answered";
+                            break;
+                        default:
+                            string author = EntryJson.string_member(payload, "author");
+                            text = EntryJson.string_member(payload, "body");
+                            if (author == COMMENT_AUTHOR_SYSTEM) {
+                                if (comment_is_mechanical(text)) {
+                                    continue;
+                                }
+                                kind = "system";
+                            } else {
+                                kind = "comment";
+                            }
+                            break;
                     }
                     }
                     rows.add(new NarrativeRow() {
                     rows.add(new NarrativeRow() {
-                        kind = comment.author == COMMENT_AUTHOR_SYSTEM
-                            ? "system" : "comment",
-                        at = comment.created_at,
-                        text = comment.body });
+                        kind = kind, at = record.created_at, text = text
+                    });
                 }
                 }
             }
             }
 
 
-            // The goal's tasks: created + terminal transitions. The
-            // failure cascade derives from the cancelled tasks
-            // surviving under a failed goal (deleted drafts left no
-            // rows; the count is the surviving record).
+            // The goal's worker tasks: created + terminal transitions
+            // (plus their comment threads — human actions visible in
+            // the goal-scope view). The failure cascade derives from
+            // the cancel events surviving under a failed goal.
             int64 cascaded = 0;
             int64 cascaded = 0;
+            var worker_ids = new Invercargill.DataStructures.HashSet<string>();
             foreach (var task in yield db.query<TaskEntity>()
             foreach (var task in yield db.query<TaskEntity>()
                     .where(expr("goal_id == $0 && kind == $1",
                     .where(expr("goal_id == $0 && kind == $1",
                         elem<int64?>(goal_id), elem(TASK_KIND_TASK)))
                         elem<int64?>(goal_id), elem(TASK_KIND_TASK)))
                     .order_by(expr("id"))
                     .order_by(expr("id"))
                     .materialise_async()) {
                     .materialise_async()) {
-                if (task.created_at != null) {
-                    rows.add(new NarrativeRow() {
-                        kind = "task-created", at = task.created_at, task_id = task.id,
-                        text = @"Task #$(task.id) $(task.title) created by the goal agent ($(task.mode))" });
-                }
-                if (task.completed_at != null) {
-                    if (task.status == STATUS_DONE) {
-                        rows.add(new NarrativeRow() {
-                            kind = "task-done", at = task.completed_at, task_id = task.id,
-                            text = @"Task #$(task.id) $(task.title) — completed$(summary_lead(task))" });
-                    } else if (task.status == STATUS_FAILED) {
-                        rows.add(new NarrativeRow() {
-                            kind = "task-failed", at = task.completed_at, task_id = task.id,
-                            text = @"Task #$(task.id) $(task.title) — failed$(summary_lead(task))" });
-                        if (((!)goal).status == GOAL_FAILED
-                                && (task.summary ?? "") == "Cancelled by user") {
-                            cascaded++;
+                worker_ids.add(task.id.to_string());
+                foreach (var record in yield events.list_for_task(task.id,
+                        { EVENT_STATE_CHANGE, EVENT_CANCEL, EVENT_COMMENT })) {
+                    if (record.created_at == null) {
+                        continue;
+                    }
+                    var payload = EventService.payload_of(record);
+                    string text = EntryJson.string_member(payload, "text");
+                    string kind = "";
+                    if (record.kind == EVENT_CANCEL) {
+                        // The terminal state_change row carries the
+                        // summary lead; the cancel event only feeds
+                        // the cascade count.
+                        cascaded++;
+                        continue;
+                    } else if (record.kind == EVENT_COMMENT) {
+                        string author = EntryJson.string_member(payload, "author");
+                        text = EntryJson.string_member(payload, "body");
+                        if (author == COMMENT_AUTHOR_SYSTEM
+                                && comment_is_mechanical(text)) {
+                            continue;
+                        }
+                        kind = author == COMMENT_AUTHOR_SYSTEM
+                            ? "system" : "comment";
+                    } else {
+                        string to = EntryJson.string_member(payload, "to");
+                        if (to == STATUS_DRAFT) {
+                            kind = "task-created";
+                            text = @"Task #$(task.id) $(task.title) created by the goal agent ($(task.mode))";
+                        } else if (to == STATUS_DONE) {
+                            kind = "task-done";
+                            text = @"Task #$(task.id) $(task.title) — completed$(summary_lead(task))";
+                        } else if (to == STATUS_FAILED) {
+                            kind = "task-failed";
+                            text = @"Task #$(task.id) $(task.title) — failed$(summary_lead(task))";
+                        } else {
+                            continue;
                         }
                         }
                     }
                     }
+                    rows.add(new NarrativeRow() {
+                        kind = kind, at = record.created_at,
+                        task_id = task.id, text = text
+                    });
                 }
                 }
             }
             }
             if (((!)goal).status == GOAL_FAILED && cascaded > 0) {
             if (((!)goal).status == GOAL_FAILED && cascaded > 0) {
                 rows.add(new NarrativeRow() {
                 rows.add(new NarrativeRow() {
                     kind = "cascade", at = ((!)goal).completed_at,
                     kind = "cascade", at = ((!)goal).completed_at,
-                    text = @"Goal failure cancelled $(cascaded) outstanding task(s)" });
+                    text = @"Goal failure cancelled $(cascaded) outstanding task(s)"
+                });
             }
             }
 
 
             // Newest first, stable for equal timestamps (insertion
             // Newest first, stable for equal timestamps (insertion
@@ -985,6 +1023,41 @@ namespace ClankerSaddle.Services {
             return newest_first.to_immutable_buffer();
             return newest_first.to_immutable_buffer();
         }
         }
 
 
+        /** A goal-status state_change onto the narrative kind vocabulary. */
+        private static string narrative_kind_of_goal_status(string to) {
+            switch (to) {
+                case GOAL_DRAFT:
+                    return "created";
+                case GOAL_PLANNING:
+                    return "started";
+                case GOAL_PAUSED:
+                    return "paused";
+                case GOAL_COMPLETION_REVIEW:
+                    return "completion-submitted";
+                case GOAL_COMPLETE:
+                    return "goal-approved";
+                case GOAL_FAILED:
+                    return "goal-failed";
+                case GOAL_ACTIVE:
+                    return "active";
+                default:
+                    return to.length > 0 ? to : "state";
+            }
+        }
+
+        /** A verdict event's payload onto the narrative kind vocabulary. */
+        private static string narrative_kind_of_verdict(Json.Object payload) {
+            string block_type = EntryJson.string_member(payload, "block_type");
+            bool approved = EntryJson.string_member(payload, "verdict") == "approved";
+            if (block_type == BLOCK_PLAN) {
+                return approved ? "plan-approved" : "plan-rejected";
+            }
+            if (block_type == BLOCK_COMPLETION || block_type == BLOCK_REVIEW) {
+                return approved ? "goal-approved" : "completion-rejected";
+            }
+            return approved ? "review-approved" : "review-rejected";
+        }
+
         /** True when row `a` sorts after (newer than) row `b`. */
         /** True when row `a` sorts after (newer than) row `b`. */
         private static bool narrative_after(NarrativeRow a, NarrativeRow b) {
         private static bool narrative_after(NarrativeRow a, NarrativeRow b) {
             if (a.at == null) {
             if (a.at == null) {
@@ -1031,29 +1104,30 @@ namespace ClankerSaddle.Services {
             if (goal == null || ((!)goal).status != GOAL_ACTIVE) {
             if (goal == null || ((!)goal).status != GOAL_ACTIVE) {
                 return false;
                 return false;
             }
             }
-            // Goal 4 task 1 (Force Wake): the human pressed Force
-            // wake — offerable immediately, skipping BOTH the
-            // coalescing window and the terminal-transition
-            // requirement (a parked agent with no pending wake —
-            // the goal-2 stall class — still resumes).
             if (((!)goal).force_wake) {
             if (((!)goal).force_wake) {
                 return true;
                 return true;
             }
             }
-            DateTime? watermark = ((!)goal).last_terminal_watermark;
-            DateTime? oldest = null;
-            // ONLY owned-task terminal transitions resume the parked
-            // agent — the agent's own task-created narrative (and
-            // human comments) must never wake it.
+            var worker_ids = new Invercargill.DataStructures.HashSet<string>();
             foreach (var task in yield db.query<TaskEntity>()
             foreach (var task in yield db.query<TaskEntity>()
                     .where(expr("goal_id == $0 && kind == $1",
                     .where(expr("goal_id == $0 && kind == $1",
                         elem<int64?>(run_task.goal_id), elem(TASK_KIND_TASK)))
                         elem<int64?>(run_task.goal_id), elem(TASK_KIND_TASK)))
-                    .order_by(expr("id"))
                     .materialise_async()) {
                     .materialise_async()) {
-                if (task.completed_at == null) {
-                    continue;
-                }
-                if (watermark != null
-                        && ((!)task.completed_at).compare((!)watermark) <= 0) {
+                worker_ids.add(task.id.to_string());
+            }
+            DateTime? oldest = yield events.oldest_terminal_transition_since(
+                worker_ids, ((!)goal).last_terminal_watermark);
+            // Belt-and-braces: task rows whose terminal stamp predates
+            // the events ledger (fixtures, external writers) still
+            // drive the resume window — the completed_at column stays
+            // the fallback of record.
+            foreach (var task in yield db.query<TaskEntity>()
+                    .where(expr("goal_id == $0 && kind == $1",
+                        elem<int64?>(run_task.goal_id), elem(TASK_KIND_TASK)))
+                    .materialise_async()) {
+                if (task.completed_at == null
+                        || (((!)goal).last_terminal_watermark != null
+                            && ((!)task.completed_at)
+                                .compare(((!)((!)goal).last_terminal_watermark)) <= 0)) {
                     continue;
                     continue;
                 }
                 }
                 if (oldest == null || ((!)task.completed_at).compare((!)oldest) < 0) {
                 if (oldest == null || ((!)task.completed_at).compare((!)oldest) < 0) {
@@ -1138,14 +1212,28 @@ namespace ClankerSaddle.Services {
                 .append(" prune worktrees, create or enqueue follow-ups as needed, then")
                 .append(" prune worktrees, create or enqueue follow-ups as needed, then")
                 .append(" `saddle suspend` again when you are parked.\n");
                 .append(" `saddle suspend` again when you are parked.\n");
 
 
-            DateTime consumed = new DateTime.now_local();
+            var goal_task_ids = new Invercargill.DataStructures.HashSet<string>();
             foreach (var task in yield db.query<TaskEntity>()
             foreach (var task in yield db.query<TaskEntity>()
                     .where(expr("goal_id == $0", elem<int64?>(goal_id)))
                     .where(expr("goal_id == $0", elem<int64?>(goal_id)))
                     .order_by(expr("id"))
                     .order_by(expr("id"))
                     .materialise_async()) {
                     .materialise_async()) {
-                if (task.completed_at != null
-                        && ((!)task.completed_at).compare(consumed) > 0) {
-                    consumed = (!)task.completed_at;
+                goal_task_ids.add(task.id.to_string());
+            }
+            DateTime consumed = new DateTime.now_local();
+            DateTime? newest_terminal = yield events.newest_terminal_transition(
+                goal_task_ids);
+            if (newest_terminal != null && ((!)newest_terminal).compare(consumed) > 0) {
+                consumed = (!)newest_terminal;
+            }
+            var terminal_filter = TaskService.id_in("id", goal_task_ids);
+            if (terminal_filter != null) {
+                foreach (var task in yield db.query<TaskEntity>()
+                        .where((!)terminal_filter)
+                        .materialise_async()) {
+                    if (task.completed_at != null
+                            && ((!)task.completed_at).compare(consumed) > 0) {
+                        consumed = (!)task.completed_at;
+                    }
                 }
                 }
             }
             }
             ((!)goal).last_terminal_watermark = consumed;
             ((!)goal).last_terminal_watermark = consumed;

+ 12 - 296
src/Services/SaddleService.vala

@@ -137,6 +137,7 @@ namespace ClankerSaddle.Services {
         private CapabilityService capability_service = inject<CapabilityService>();
         private CapabilityService capability_service = inject<CapabilityService>();
         private TaskService task_service = inject<TaskService>();
         private TaskService task_service = inject<TaskService>();
         private GoalService goal_service = inject<GoalService>();
         private GoalService goal_service = inject<GoalService>();
+        private EventService event_service = inject<EventService>();
         private Astralis.WebConfig config = inject<Astralis.WebConfig>();
         private Astralis.WebConfig config = inject<Astralis.WebConfig>();
 
 
         /**
         /**
@@ -1202,147 +1203,6 @@ namespace ClankerSaddle.Services {
             }
             }
         }
         }
 
 
-        /**
-         * Projects the agent's conversation-level events onto the task:
-         * `saddle comment` lines become agent comments, `saddle plan`
-         * lines update the plan, `saddle progress-update` lines append
-         * progress, `saddle review` raises the review banner, the
-         * `saddle done` entry completes the task with the summary the
-         * agent gave, and the `saddle failure` entry fails it with the
-         * explanation the agent piped in. `saddle yeehaw` entries are
-         * handled explicitly but project nothing — the stored
-         * `session_commands` row IS the celebration the card, badge
-         * and timeline read back. Returns true once a done or failed
-         * event finished the task.
-         *
-         * Plan 12: on a GOAL-RUN session the plan/done/failure verbs
-         * are goal-context — a plan submission enters plan review, a
-         * done submits the completion report (the goal-run task parks
-         * instead of completing), a failure fails the goal. Plan 15
-         * C2: the run task's comment thread IS the goal's thread —
-         * nothing is mirrored anywhere.
-         *
-         * Plan 13 B2: the projections key on the DEDUPED entry stream
-         * ({@link store_pushed_entries} claims every id once per
-         * saddle, ever) and stop at terminal state — a task already
-         * done/failed, a goal already finished, projects nothing
-         * further. The live failure's 115-event/187-comment fan-out is
-         * structurally impossible: replays never reach this function,
-         * and the honest burst of DISTINCT failure ids dies at the
-         * first one.
-         */
-        public async bool apply_entry_events(int64 task_id, Vector<Json.Object> entries,
-                TaskService tasks) throws Error {
-            var goal_id = yield goal_id_of_run(task_id);
-            bool terminal = yield task_is_terminal(task_id);
-            if (!terminal && goal_id > 0) {
-                terminal = yield goal_is_terminal(goal_id);
-            }
-            if (terminal) {
-                return false;
-            }
-            foreach (var entry in entries) {
-                string command = EntryJson.string_member(entry, "command");
-                string output = EntryJson.string_member(entry, "output");
-                switch (EntryJson.string_member(entry, "type")) {
-                    case "comment":
-                        // Plan 13 B4: body-first — the entry's output IS
-                        // the comment body; the command echo (the
-                        // `saddle comment` invocation, heredoc shapes
-                        // included) must never become the projected
-                        // text. The parsed command stays as a legacy
-                        // fallback for pushes without output.
-                        string body = output;
-                        if (body.length == 0) {
-                            body = strip_quotes(strip_verb(command, "saddle comment"));
-                        }
-                        if (body.length > 0) {
-                            // Plan 15 C2/C3: the run task's comment IS
-                            // the goal comment — one row, no mirror,
-                            // and the narrative derives it at read time.
-                            yield tasks.add_comment(task_id, "agent", body);
-                        }
-                        break;
-                    case "system":
-                        // Plan 15 C2: harness-authored bookkeeping (the
-                        // session-restore footnote, the goal park
-                        // notice) — the distinct system-event class on
-                        // the ONE thread, never an agent row. Plan 16
-                        // S5 (U2): the MECHANICAL rows (restores, park
-                        // notices) never author comment rows at all —
-                        // they stay in the raw terminal log only.
-                        if (output.length > 0
-                                && !comment_is_mechanical(output)) {
-                            yield tasks.add_system_comment(task_id, output);
-                        }
-                        break;
-                    case "plan":
-                        // Plan 12: plans are goal-context only — a
-                        // goal-run's `saddle plan` enters plan review;
-                        // plain task runs carry no plan member.
-                        if (goal_id > 0) {
-                            string markdown = output.length > 0 ? output
-                                : strip_quotes(strip_verb(command, "saddle plan"));
-                            if (markdown.length > 0) {
-                                yield goal_service.submit_plan(goal_id, markdown);
-                            }
-                        }
-                        break;
-                    case "progress":
-                        string summary = strip_verb(command, "saddle progress-update");
-                        if (summary.length == 0) {
-                            summary = output;
-                        }
-                        if (summary.length > 0) {
-                            yield tasks.add_progress(task_id, strip_quotes(summary));
-                        }
-                        break;
-                    case "review":
-                        yield tasks.request_review(task_id,
-                            output.length > 0 ? output
-                            : strip_quotes(strip_verb(command, "saddle review")));
-                        break;
-                    case "done":
-                        if (goal_id > 0) {
-                            string report = output.length > 0 ? output
-                                : strip_quotes(strip_verb(command, "saddle done"));
-                            yield goal_service.submit_completion(goal_id,
-                                report.length > 0 ? report : "The goal agent submitted its completion report");
-                            return true;
-                        }
-                        {
-                            // the DONE entry's output IS the report
-                            // (the command is just "saddle done") —
-                            // falling back to it keeps the summary when
-                            // this entry path beats the transition push
-                            string report = output.length > 0 ? output
-                                : strip_quotes(strip_verb(command, "saddle done"));
-                            yield tasks.complete_task(task_id,
-                                report.length > 0 ? report : null, OUTCOME_DONE);
-                        }
-                        return true;
-                    case "failed":
-                        string explanation = output.length > 0 ? output
-                            : strip_quotes(strip_verb(command, "saddle failure"));
-                        if (goal_id > 0) {
-                            yield goal_service.fail_goal(goal_id,
-                                explanation.length > 0 ? explanation : null);
-                            return true;
-                        }
-                        yield tasks.fail_task(task_id, explanation);
-                        return true;
-                    case "yeehaw":
-                        // The celebration needs no task-side projection:
-                        // store_pushed_entries already persisted the row
-                        // the yeehaw card, badge and timeline read from.
-                        break;
-                    default:
-                        break;
-                }
-            }
-            return false;
-        }
-
         /**
         /**
          * The goal id when `task_id` is a goal agent's run task (kind
          * The goal id when `task_id` is a goal agent's run task (kind
          * 'goal_run'), else 0 — the discriminator for the goal-context
          * 'goal_run'), else 0 — the discriminator for the goal-context
@@ -1422,141 +1282,6 @@ namespace ClankerSaddle.Services {
                 ? "Request for comments" : "";
                 ? "Request for comments" : "";
         }
         }
 
 
-        /**
-         * Stores a pushed batch of raw PTY chunks (`POST
-         * /api/saddle/chunks`, plan 10 phase 2) as the open session's
-         * `session_raw_log` rows — the same storage logic the deleted
-         * `GET /log` poller ran, driven by the daemon's flusher instead
-         * of the web's cursor. Idempotent on the unique
-         * (session_id, seq) pair: a chunk whose row already exists (an
-         * outbox redelivery after a lost response) is consumed, not an
-         * error; anything else stops the pass so the daemon's retry
-         * re-sends the tail. A truly empty text stores no row (it
-         * renders as nothing); whitespace-only chunks DO store. Returns
-         * the count of rows newly stored.
-         */
-        public async int64 store_pushed_chunks(int64 session_id, Json.Array chunks) throws Error {
-            int64 stored = 0;
-            foreach (var element in chunks.get_elements()) {
-                if (element.get_node_type() != Json.NodeType.OBJECT) {
-                    continue;
-                }
-                var chunk = element.get_object();
-                int64 seq = chunk.has_member("seq")
-                    && chunk.get_member("seq").get_value_type() == typeof(int64)
-                    ? chunk.get_int_member("seq") : 0;
-                if (seq <= 0) {
-                    continue;
-                }
-                string text = EntryJson.string_member(chunk, "text");
-                if (text.length == 0) {
-                    continue;
-                }
-                try {
-                    yield db.insert_async<SessionRawLog>(new SessionRawLog() {
-                        session_id = session_id,
-                        seq = seq,
-                        chunk = text
-                    });
-                    stored++;
-                } catch (Error insert_error) {
-                    // The unique (session_id, seq) pair is the idempotency
-                    // guarantee: a row already there means the push is a
-                    // redelivery — consume it. Any other failure is real.
-                    var existing = yield db.query<SessionRawLog>()
-                        .where(expr("session_id == $0 && seq == $1",
-                            elem<int64?>(session_id), elem<int64?>(seq)))
-                        .first_async();
-                    if (existing == null) {
-                        throw insert_error;
-                    }
-                }
-            }
-            return stored;
-        }
-
-        /**
-         * Stores a pushed batch of round entries (`POST
-         * /api/saddle/results`, plan 10 phase 2) as `session_commands`
-         * rows — the mapping the deleted `GET /output` poller ran. Plan
-         * 13 B1: the dedup key is the DAEMON-GLOBAL (saddle, entry id)
-         * pair ({@link SaddleEntryDedup}), persisted in the database so
-         * it survives web restarts and ignores session churn entirely —
-         * a redelivered entry is consumed silently under whatever
-         * session id the payload carries, and a push may only CREATE
-         * rows for entries whose ids have not already landed on this
-         * saddle. Entries without an id always store. Returns the
-         * entries stored, oldest first, for {@link apply_entry_events}
-         * — the deduped entry stream every projection keys on (B2).
-         */
-        public async Vector<Json.Object> store_pushed_entries(Saddle saddle, int64 session_id,
-                Json.Array entries) throws Error {
-            var stored = new Vector<Json.Object>();
-            foreach (var element in entries.get_elements()) {
-                if (element.get_node_type() != Json.NodeType.OBJECT) {
-                    continue;
-                }
-                var entry = element.get_object();
-                int64 entry_id = entry.has_member("id")
-                    && entry.get_member("id").get_value_type() == typeof(int64)
-                    ? entry.get_int_member("id") : 0;
-                if (entry_id > 0 && !(yield claim_entry_id(saddle.id, entry_id))) {
-                    // A redelivery under any session binding — consumed,
-                    // never duplicated, never projected.
-                    continue;
-                }
-                var command = new SessionCommand() {
-                    session_id = session_id,
-                    command_text = EntryJson.string_member(entry, "command"),
-                    output_text = entry.has_member("output")
-                        && entry.get_member("output").get_string() != null
-                        ? entry.get_string_member("output") : null,
-                    exit_code = entry.has_member("exit_code")
-                        && entry.get_member("exit_code").get_value_type() == typeof(int64)
-                        ? entry.get_int_member("exit_code") : 0,
-                    entry_type = EntryJson.string_member(entry, "type").length > 0
-                        ? EntryJson.string_member(entry, "type") : "command",
-                    piped_input = EntryJson.optional_string_member(entry, "input"),
-                    started_at = EntryJson.parse_iso(entry, "started_at") ?? EntryJson.parse_iso(entry, "timestamp"),
-                    completed_at = EntryJson.parse_iso(entry, "completed_at"),
-                    duration_ms = entry.has_member("duration_ms")
-                        && entry.get_member("duration_ms").get_value_type() == typeof(int64)
-                        ? entry.get_int_member("duration_ms") : 0
-                };
-                yield db.insert_async<SessionCommand>(command);
-                stored.add(entry);
-            }
-            return stored;
-        }
-
-        /**
-         * Claims `(saddle_id, entry_id)` for ingestion: true when the
-         * id is fresh on this saddle, false when it already landed (an
-         * outbox redelivery — consumed silently regardless of the
-         * payload's session binding). The dedup row is inserted BEFORE
-         * the entry stores, so a mid-pass failure can never re-project
-         * an entry the daemon retries (exactly-once projections beat
-         * at-least-once storage).
-         */
-        private async bool claim_entry_id(int64 saddle_id, int64 entry_id) throws Error {
-            try {
-                yield db.insert_async<SaddleEntryDedup>(new SaddleEntryDedup() {
-                    saddle_id = saddle_id,
-                    entry_id = entry_id
-                });
-                return true;
-            } catch (Error insert_error) {
-                var existing = yield db.query<SaddleEntryDedup>()
-                    .where(expr("saddle_id == $0 && entry_id == $1",
-                        elem<int64?>(saddle_id), elem<int64?>(entry_id)))
-                    .first_async();
-                if (existing == null) {
-                    throw insert_error;
-                }
-                return false;
-            }
-        }
-
         /**
         /**
          * Applies a pushed state transition (`POST /api/saddle/results`,
          * Applies a pushed state transition (`POST /api/saddle/results`,
          * plan 10 phase 2) — the same task/saddle bookkeeping the
          * plan 10 phase 2) — the same task/saddle bookkeeping the
@@ -1693,13 +1418,22 @@ namespace ClankerSaddle.Services {
                     // under its review banner.
                     // under its review banner.
                     var advance = connection.create_command(
                     var advance = connection.create_command(
                         "UPDATE tasks SET status = 'working' WHERE id = :id"
                         "UPDATE tasks SET status = 'working' WHERE id = :id"
-                        + " AND status IN ('assigned', 'queued')");
+                        + " AND status IN ('assigned', 'queued')"
+                        + " AND status != 'working'");
                     advance.with_parameter<Invercargill.Element>("id",
                     advance.with_parameter<Invercargill.Element>("id",
                         elem<int64?>(task_id));
                         elem<int64?>(task_id));
-                    yield advance.execute_non_query_async();
+                    bool flipped = (yield advance.execute_non_query_async()) > 0;
                     var current = yield db.query<TaskEntity>()
                     var current = yield db.query<TaskEntity>()
                         .where(expr("id == $0", elem<int64?>(task_id)))
                         .where(expr("id == $0", elem<int64?>(task_id)))
                         .first_async();
                         .first_async();
+                    if (flipped) {
+                        var moved = new Json.Object();
+                        moved.set_string_member("from", STATUS_ASSIGNED);
+                        moved.set_string_member("to", STATUS_WORKING);
+                        moved.set_string_member("text", "Status → working");
+                        yield event_service.append(EVENT_STATE_CHANGE,
+                            EVENT_ACTOR_SYSTEM, EVENT_SCOPE_TASK, task_id, moved);
+                    }
                     // started_at (and the first-start push) only stamp
                     // started_at (and the first-start push) only stamp
                     // for work that never allocated; the guarded
                     // for work that never allocated; the guarded
                     // advance above means the re-read task is working
                     // advance above means the re-read task is working
@@ -1903,15 +1637,6 @@ namespace ClankerSaddle.Services {
             return builder.get_root();
             return builder.get_root();
         }
         }
 
 
-        /** `command` minus a leading `verb` and its separating whitespace. */
-        private static string strip_verb(string command, string verb) {
-            var trimmed = command.strip();
-            if (trimmed == verb) {
-                return "";
-            }
-            return trimmed.has_prefix(verb + " ") ? trimmed.substring(verb.length + 1).strip() : trimmed;
-        }
-
         /**
         /**
          * The answer as the saddle daemon expects it over /answer (or in a
          * The answer as the saddle daemon expects it over /answer (or in a
          * resume payload): verbatim. The daemon applies its own
          * resume payload): verbatim. The daemon applies its own
@@ -1928,14 +1653,5 @@ namespace ClankerSaddle.Services {
             }
             }
             return answer;
             return answer;
         }
         }
-
-        /** Strips one layer of matching double quotes, when present. */
-        private static string strip_quotes(string text) {
-            if (text.length >= 2 && text.has_prefix("\"") && text.has_suffix("\"")) {
-                return text.substring(1, text.length - 2);
-            }
-            return text;
-        }
-
     }
     }
 }
 }

+ 145 - 191
src/Services/TaskService.vala

@@ -52,17 +52,6 @@ namespace ClankerSaddle.Services {
             || body.contains("parked until");
             || body.contains("parked until");
     }
     }
 
 
-    /**
-     * The agent-celebration entry type (`saddle yeehaw <message>`): the
-     * daemon pushes it as a round-results entry carrying the message in
-     * the output field, so it stores like any other `session_commands`
-     * row — nothing new is persisted for the badge or the card.
-     */
-    public const string ENTRY_YEEHAW = "yeehaw";
-
-    /** The task page's yeehaw card cap (newest entries kept). */
-    public const int YEEHAW_CARD_MAX = 3;
-
     /**
     /**
      * The stable verdict wordings (plan 11): they are stored as the
      * The stable verdict wordings (plan 11): they are stored as the
      * block answers AND ride the resuming allocation's
      * block answers AND ride the resuming allocation's
@@ -113,6 +102,19 @@ namespace ClankerSaddle.Services {
         }
         }
     }
     }
 
 
+    /**
+     * One LLM-reported progress line on a task — the ledger's progress
+     * events projected back into the feed shape the pages read (id is
+     * the event id, summary the payload's text).
+     */
+    public class TaskProgress : Object {
+
+        public int64 id { get; set; }
+        public int64 task_id { get; set; }
+        public string summary { get; set; default = ""; }
+        public DateTime? created_at { get; set; default = null; }
+    }
+
     /**
     /**
      * The full task aggregate loaded by {@link TaskService.get_task}: the
      * The full task aggregate loaded by {@link TaskService.get_task}: the
      * task itself plus its comments, blocks, progress history and
      * task itself plus its comments, blocks, progress history and
@@ -126,25 +128,6 @@ namespace ClankerSaddle.Services {
         public ImmutableLot<Session> sessions { get; set; }
         public ImmutableLot<Session> sessions { get; set; }
     }
     }
 
 
-    /**
-     * One agent celebration (`saddle yeehaw`) read back for display: the
-     * message from the entry's output field and when it landed.
-     */
-    public class YeehawEntry : Object {
-        public string message { get; set; default = ""; }
-        public DateTime? at { get; set; }
-    }
-
-    /**
-     * The task's celebration feed for the yeehaw card: the newest
-     * {@link YEEHAW_CARD_MAX} entries plus the uncapped total (the
-     * "+N more" hint derives from the two).
-     */
-    public class YeehawFeed : Object {
-        public Vector<YeehawEntry> latest { get; set; default = new Vector<YeehawEntry>(); }
-        public int64 total { get; set; }
-    }
-
     /**
     /**
      * Task storage and lifecycle: creation, enqueue-after-aware
      * Task storage and lifecycle: creation, enqueue-after-aware
      * readiness, agent interaction primitives (comments, blocks,
      * readiness, agent interaction primitives (comments, blocks,
@@ -159,6 +142,7 @@ namespace ClankerSaddle.Services {
         private Connection connection = inject<Connection>();
         private Connection connection = inject<Connection>();
         private StatePulse pulse = inject<StatePulse>();
         private StatePulse pulse = inject<StatePulse>();
         private PushService push = inject<PushService>();
         private PushService push = inject<PushService>();
+        private EventService events = inject<EventService>();
 
 
         /**
         /**
          * Inserts a task (status 'draft' until its creator enqueues
          * Inserts a task (status 'draft' until its creator enqueues
@@ -230,9 +214,26 @@ namespace ClankerSaddle.Services {
                 }
                 }
             }
             }
             yield pulse.bump(task.id, "task created");
             yield pulse.bump(task.id, "task created");
+            yield append_state_change(task.id, "", status, "Task created",
+                creator == CREATOR_HUMAN ? EVENT_ACTOR_USER : EVENT_ACTOR_MODEL);
             return task;
             return task;
         }
         }
 
 
+        /**
+         * Appends a task-scoped state_change event carrying `from`/`to`
+         * plus a human-readable line the timeline and narrative read
+         * back verbatim.
+         */
+        private async void append_state_change(int64 task_id, string from, string to,
+                string text, string actor) throws Error {
+            var payload = new Json.Object();
+            payload.set_string_member("from", from);
+            payload.set_string_member("to", to);
+            payload.set_string_member("text", text);
+            yield events.append(EVENT_STATE_CHANGE, actor, EVENT_SCOPE_TASK, task_id,
+                payload);
+        }
+
         /**
         /**
          * Loads the full aggregate for `id`: the task, its comments,
          * Loads the full aggregate for `id`: the task, its comments,
          * blocks, progress and sessions. Returns null when the task
          * blocks, progress and sessions. Returns null when the task
@@ -483,6 +484,7 @@ namespace ClankerSaddle.Services {
                 return null;
                 return null;
             }
             }
             if (((!)task).status != status) {
             if (((!)task).status != status) {
+                string from = ((!)task).status;
                 if (status != STATUS_DONE && status != STATUS_FAILED) {
                 if (status != STATUS_DONE && status != STATUS_FAILED) {
                     // Terminal states are sticky (plan 13 B2's
                     // Terminal states are sticky (plan 13 B2's
                     // invariant): a NON-terminal transition must never
                     // invariant): a NON-terminal transition must never
@@ -518,6 +520,12 @@ namespace ClankerSaddle.Services {
                     yield db.update_async<TaskEntity>((!)task);
                     yield db.update_async<TaskEntity>((!)task);
                 }
                 }
                 yield pulse.bump(id, @"task $(status)");
                 yield pulse.bump(id, @"task $(status)");
+                var moved = new Json.Object();
+                moved.set_string_member("from", from);
+                moved.set_string_member("to", status);
+                moved.set_string_member("text", @"Status → $(status)");
+                yield events.append(EVENT_STATE_CHANGE, EVENT_ACTOR_SYSTEM,
+                    EVENT_SCOPE_TASK, id, moved);
             }
             }
             return task;
             return task;
         }
         }
@@ -560,6 +568,8 @@ namespace ClankerSaddle.Services {
             ((!)task).status = STATUS_QUEUED;
             ((!)task).status = STATUS_QUEUED;
             yield db.update_async<TaskEntity>((!)task);
             yield db.update_async<TaskEntity>((!)task);
             yield pulse.bump(id, "task enqueued");
             yield pulse.bump(id, "task enqueued");
+            yield append_state_change(id, STATUS_DRAFT, STATUS_QUEUED,
+                "Task enqueued", actor == CREATOR_HUMAN ? EVENT_ACTOR_USER : EVENT_ACTOR_MODEL);
         }
         }
 
 
         /**
         /**
@@ -641,11 +651,6 @@ namespace ClankerSaddle.Services {
                     .materialise_async()) {
                     .materialise_async()) {
                 yield db.delete_async<TaskCapability>(link);
                 yield db.delete_async<TaskCapability>(link);
             }
             }
-            foreach (var row in yield db.query<TaskProgress>()
-                    .where(expr("task_id == $0", elem<int64?>(task.id)))
-                    .materialise_async()) {
-                yield db.delete_async<TaskProgress>(row);
-            }
             foreach (var row in yield db.query<TaskComment>()
             foreach (var row in yield db.query<TaskComment>()
                     .where(expr("task_id == $0", elem<int64?>(task.id)))
                     .where(expr("task_id == $0", elem<int64?>(task.id)))
                     .materialise_async()) {
                     .materialise_async()) {
@@ -656,6 +661,7 @@ namespace ClankerSaddle.Services {
                     .materialise_async()) {
                     .materialise_async()) {
                 yield db.delete_async<TaskBlock>(row);
                 yield db.delete_async<TaskBlock>(row);
             }
             }
+            yield events.delete_for_task(task.id);
             yield clear_enqueue_after_references(task.id);
             yield clear_enqueue_after_references(task.id);
             yield db.delete_async<TaskEntity>(task);
             yield db.delete_async<TaskEntity>(task);
             yield pulse.bump(task.id, "task deleted");
             yield pulse.bump(task.id, "task deleted");
@@ -713,119 +719,46 @@ namespace ClankerSaddle.Services {
         }
         }
 
 
         /**
         /**
-         * Appends a progress line (`saddle progress-update`) to the task's
-         * history and returns the new row.
+         * Appends a progress line to the task's ledger (the daemon's
+         * progress events land in the same ledger through the chunks
+         * push; the goal-era results-entry path that wrote
+         * `task_progress` rows is gone).
          */
          */
         public async TaskProgress add_progress(int64 task_id, string summary) throws Error {
         public async TaskProgress add_progress(int64 task_id, string summary) throws Error {
-            var row = new TaskProgress() {
+            var payload = new Json.Object();
+            payload.set_string_member("text", summary);
+            var record = yield events.append(EVENT_PROGRESS, EVENT_ACTOR_MODEL,
+                EVENT_SCOPE_TASK, task_id, payload);
+            yield pulse.bump(task_id, "progress");
+            return new TaskProgress() {
+                id = record.id,
                 task_id = task_id,
                 task_id = task_id,
                 summary = summary,
                 summary = summary,
-                created_at = new DateTime.now_local()
+                created_at = record.created_at
             };
             };
-            yield db.insert_async<TaskProgress>(row);
-            yield pulse.bump(task_id, "progress");
-            return row;
         }
         }
 
 
         /** The task's progress history, newest first. */
         /** The task's progress history, newest first. */
         public async ImmutableLot<TaskProgress> list_progress(int64 task_id) throws Error {
         public async ImmutableLot<TaskProgress> list_progress(int64 task_id) throws Error {
-            return yield db.query<TaskProgress>()
-                .where(expr("task_id == $0", elem<int64?>(task_id)))
-                .order_by_desc(expr("id"))
-                .materialise_async();
-        }
-
-        /**
-         * Every task id whose sessions carry a `yeehaw` entry (stringly
-         * keyed) — the batch presence check the task list and the
-         * dashboard's task references badge from, one DISTINCT query
-         * instead of one EXISTS per row.
-         */
-        public async Invercargill.DataStructures.HashSet<string> yeehaw_task_ids() throws Error {
-            var ids = new Invercargill.DataStructures.HashSet<string>();
-            var command = connection.create_command(
-                "SELECT DISTINCT s.task_id AS task_id FROM sessions s"
-                + " JOIN session_commands sc ON sc.session_id = s.id"
-                + " WHERE sc.type = :kind");
-            command.with_parameter<Invercargill.Element>("kind", elem(ENTRY_YEEHAW));
-            foreach (var row in yield command.execute_query_async()) {
-                var element = row.get("task_id");
-                int64? value = 0;
-                if (element != null && element.try_get_as<int64?>(out value) && value != null) {
-                    ids.add(((!)value).to_string());
-                }
+            var rows = new Vector<TaskProgress>();
+            foreach (var record in yield events.list_for_task(task_id,
+                    { EVENT_PROGRESS })) {
+                rows.add(new TaskProgress() {
+                    id = record.id,
+                    task_id = task_id,
+                    summary = EventService.payload_string(record, "text"),
+                    created_at = record.created_at
+                });
             }
             }
-            return ids;
-        }
-
-        /**
-         * The task's yeehaw card data: the newest
-         * {@link YEEHAW_CARD_MAX} celebrations (message + when, newest
-         * first) plus the uncapped total. Reads the existing
-         * `session_commands` rows through the sessions join — the card
-         * stores nothing of its own.
-         */
-        public async YeehawFeed yeehaw_feed(int64 task_id) throws Error {
-            var total_command = connection.create_command(
-                "SELECT COUNT(*) AS total FROM session_commands sc"
-                + " JOIN sessions s ON s.id = sc.session_id"
-                + " WHERE s.task_id = :task AND sc.type = :kind");
-            total_command.with_parameter<Invercargill.Element>("task", elem<int64?>(task_id));
-            total_command.with_parameter<Invercargill.Element>("kind", elem(ENTRY_YEEHAW));
-            var total = yield total_command.execute_scalar_async();
-            int64? total_value = 0;
-            if (total != null) {
-                total.try_get_as<int64?>(out total_value);
-            }
-
-            var feed = new YeehawFeed() { total = total_value };
-            var rows_command = connection.create_command(
-                "SELECT sc.output_text AS message, sc.command_text AS command,"
-                + " sc.started_at AS started, sc.completed_at AS completed"
-                + " FROM session_commands sc JOIN sessions s ON s.id = sc.session_id"
-                + " WHERE s.task_id = :task AND sc.type = :kind"
-                + " ORDER BY sc.id DESC LIMIT :cap");
-            rows_command.with_parameter<Invercargill.Element>("task", elem<int64?>(task_id));
-            rows_command.with_parameter<Invercargill.Element>("kind", elem(ENTRY_YEEHAW));
-            rows_command.with_parameter<Invercargill.Element>("cap", elem<int64?>(YEEHAW_CARD_MAX));
-            foreach (var row in yield rows_command.execute_query_async()) {
-                string message = string_of(row.get("message"));
-                if (message.length == 0) {
-                    // The message rides the output field; the command
-                    // line is the fallback for empty-output pushes.
-                    message = string_of(row.get("command"));
-                }
-                DateTime? at = date_time_of(row.get("started"));
-                if (at == null) {
-                    at = date_time_of(row.get("completed"));
-                }
-                feed.latest.add(new YeehawEntry() { message = message, at = at });
-            }
-            return feed;
-        }
-
-        /** A raw query column back as a string ("" when null/unreadable). */
-        private static string string_of(Invercargill.Element? element) {
-            if (element == null) {
-                return "";
-            }
-            string value = "";
-            return ((!)element).try_get_as<string>(out value) ? value : "";
-        }
-
-        /** A raw query column back as a DateTime (null when null/unreadable). */
-        private static DateTime? date_time_of(Invercargill.Element? element) {
-            if (element == null) {
-                return null;
-            }
-            DateTime value;
-            return ((!)element).try_get_as<DateTime>(out value) ? value : null;
+            return rows.to_immutable_buffer();
         }
         }
 
 
         /**
         /**
          * Appends a comment; `author` is 'agent', 'user:<name>' or
          * Appends a comment; `author` is 'agent', 'user:<name>' or
          * 'system' (the harness-authored bookkeeping rows — plan 15
          * 'system' (the harness-authored bookkeeping rows — plan 15
-         * C2's distinct system-event class, never agent content).
+         * C2's distinct system-event class, never agent content). The
+         * comment event references the row (the task_comments table
+         * stays; the ledger carries the reference plus the body).
          */
          */
         public async TaskComment add_comment(int64 task_id, string author, string body,
         public async TaskComment add_comment(int64 task_id, string author, string body,
                 string? quote = null) throws Error {
                 string? quote = null) throws Error {
@@ -837,6 +770,17 @@ namespace ClankerSaddle.Services {
                 created_at = new DateTime.now_local()
                 created_at = new DateTime.now_local()
             };
             };
             yield db.insert_async<TaskComment>(comment);
             yield db.insert_async<TaskComment>(comment);
+            var payload = new Json.Object();
+            payload.set_int_member("comment_id", comment.id);
+            payload.set_string_member("body", body);
+            payload.set_string_member("author", author);
+            if (comment.quote != null) {
+                payload.set_string_member("quote", (!)comment.quote);
+            }
+            yield events.append(EVENT_COMMENT,
+                author == "agent" ? EVENT_ACTOR_MODEL
+                : author.has_prefix("user:") ? EVENT_ACTOR_USER : EVENT_ACTOR_SYSTEM,
+                EVENT_SCOPE_TASK, task_id, payload);
             yield pulse.bump(task_id, "comment");
             yield pulse.bump(task_id, "comment");
             return comment;
             return comment;
         }
         }
@@ -852,28 +796,6 @@ namespace ClankerSaddle.Services {
             return yield add_comment(task_id, COMMENT_AUTHOR_SYSTEM, body);
             return yield add_comment(task_id, COMMENT_AUTHOR_SYSTEM, body);
         }
         }
 
 
-        /**
-         * Appends a terminal entry to the task's open session so
-         * conversation-level events (blocks, plans, progress, reviews,
-         * human comments) appear in the streamed log. A no-op when the
-         * task has no open session.
-         */
-        public async void log_session_entry(int64 task_id, string entry_type,
-                string command, string? output) throws Error {
-            var session = yield open_session_for_task(task_id);
-            if (session == null) {
-                return;
-            }
-            var entry = new SessionCommand() {
-                session_id = ((!)session).id,
-                command_text = command,
-                output_text = output,
-                entry_type = entry_type,
-                started_at = new DateTime.now_local()
-            };
-            yield db.insert_async<SessionCommand>(entry);
-        }
-
         /**
         /**
          * Raises a blocking prompt on a task — `question` (optionally with
          * Raises a blocking prompt on a task — `question` (optionally with
          * multiple-choice `options` and a markdown `body`), `rfc` or
          * multiple-choice `options` and a markdown `body`), `rfc` or
@@ -905,6 +827,7 @@ namespace ClankerSaddle.Services {
             var task = yield db.query<TaskEntity>()
             var task = yield db.query<TaskEntity>()
                 .where(expr("id == $0", elem<int64?>(task_id)))
                 .where(expr("id == $0", elem<int64?>(task_id)))
                 .first_async();
                 .first_async();
+            string previous_status = task != null ? ((!)task).status : "";
             if (task != null && (((!)task).status == STATUS_ASSIGNED || ((!)task).status == STATUS_WORKING
             if (task != null && (((!)task).status == STATUS_ASSIGNED || ((!)task).status == STATUS_WORKING
                     || ((!)task).status == STATUS_BLOCKED || ((!)task).status == STATUS_REVIEW_REQUESTED)) {
                     || ((!)task).status == STATUS_BLOCKED || ((!)task).status == STATUS_REVIEW_REQUESTED)) {
                 // The park flip is a guarded update: a completion
                 // The park flip is a guarded update: a completion
@@ -929,6 +852,15 @@ namespace ClankerSaddle.Services {
                 yield park_open_sessions(task_id);
                 yield park_open_sessions(task_id);
             }
             }
             yield pulse.bump(task_id, "block raised");
             yield pulse.bump(task_id, "block raised");
+            var raised_payload = new Json.Object();
+            raised_payload.set_string_member("from", previous_status);
+            raised_payload.set_string_member("to", block.block_type == BLOCK_REVIEW
+                ? STATUS_REVIEW_REQUESTED : STATUS_BLOCKED);
+            raised_payload.set_string_member("block_type", block.block_type);
+            raised_payload.set_string_member("question", block.question);
+            raised_payload.set_string_member("text", @"$(block.block_type) raised: $(block.question)");
+            yield events.append(EVENT_STATE_CHANGE, EVENT_ACTOR_SYSTEM, EVENT_SCOPE_TASK,
+                task_id, raised_payload);
             // Web Push (plan 11 task E): every raised block notifies all
             // Web Push (plan 11 task E): every raised block notifies all
             // push-subscribed users globally — the goal agent's mirrored
             // push-subscribed users globally — the goal agent's mirrored
             // blocks stay quiet (the goal-level block owns the
             // blocks stay quiet (the goal-level block owns the
@@ -973,6 +905,32 @@ namespace ClankerSaddle.Services {
             ((!)block).answered_by = answered_by;
             ((!)block).answered_by = answered_by;
             yield db.update_async<TaskBlock>((!)block);
             yield db.update_async<TaskBlock>((!)block);
 
 
+            bool review_family = ((!)block).block_type == BLOCK_REVIEW
+                || ((!)block).block_type == BLOCK_PLAN
+                || ((!)block).block_type == BLOCK_COMPLETION;
+            var verdict_payload = new Json.Object();
+            verdict_payload.set_int_member("block_id", block_id);
+            verdict_payload.set_string_member("block_type", ((!)block).block_type);
+            if (((!)block).answered_by != null) {
+                verdict_payload.set_string_member("by", ((!)block).answered_by);
+            }
+            if (review_family) {
+                bool approved = answer == NOTICE_PLAN_APPROVED
+                    || answer == NOTICE_REVIEW_APPROVED;
+                verdict_payload.set_string_member("verdict",
+                    approved ? "approved" : "feedback");
+                verdict_payload.set_string_member("text", @"$(approved ? "approved" : "feedback") — $(answer)");
+                yield events.append(EVENT_VERDICT,
+                    ((!)block).answered_by != null ? EVENT_ACTOR_USER : EVENT_ACTOR_SYSTEM,
+                    EVENT_SCOPE_TASK, ((!)block).task_id, verdict_payload);
+            } else {
+                verdict_payload.set_string_member("answer", answer);
+                verdict_payload.set_string_member("text", @"$(((!)block).block_type) answered: $(answer)");
+                yield events.append(EVENT_ANSWER,
+                    ((!)block).answered_by != null ? EVENT_ACTOR_USER : EVENT_ACTOR_SYSTEM,
+                    EVENT_SCOPE_TASK, ((!)block).task_id, verdict_payload);
+            }
+
             var task = yield db.query<TaskEntity>()
             var task = yield db.query<TaskEntity>()
                 .where(expr("id == $0", elem<int64?>(((!)block).task_id)))
                 .where(expr("id == $0", elem<int64?>(((!)block).task_id)))
                 .first_async();
                 .first_async();
@@ -1194,6 +1152,7 @@ namespace ClankerSaddle.Services {
          */
          */
         private async void mark_terminal(TaskEntity task, string status, string? summary,
         private async void mark_terminal(TaskEntity task, string status, string? summary,
                 string? empty_summary = null) throws Error {
                 string? empty_summary = null) throws Error {
+            string from = task.status;
             task.status = status;
             task.status = status;
             task.completed_at = new DateTime.now_local();
             task.completed_at = new DateTime.now_local();
             if (summary != null && summary.length > 0) {
             if (summary != null && summary.length > 0) {
@@ -1202,6 +1161,16 @@ namespace ClankerSaddle.Services {
                 task.summary = empty_summary;
                 task.summary = empty_summary;
             }
             }
             yield db.update_async<TaskEntity>(task);
             yield db.update_async<TaskEntity>(task);
+            var payload = new Json.Object();
+            payload.set_string_member("from", from);
+            payload.set_string_member("to", status);
+            payload.set_string_member("text", status == STATUS_DONE
+                ? "Task completed" : "Task failed");
+            if ((task.summary ?? "").length > 0) {
+                payload.set_string_member("summary", (!)task.summary);
+            }
+            yield events.append(EVENT_STATE_CHANGE, EVENT_ACTOR_SYSTEM,
+                EVENT_SCOPE_TASK, task.id, payload);
         }
         }
 
 
         /**
         /**
@@ -1225,6 +1194,8 @@ namespace ClankerSaddle.Services {
             yield db.update_async<TaskEntity>((!)task);
             yield db.update_async<TaskEntity>((!)task);
             yield park_open_sessions(task_id);
             yield park_open_sessions(task_id);
             yield pulse.bump(task_id, "task suspended");
             yield pulse.bump(task_id, "task suspended");
+            yield append_state_change(task_id, STATUS_WORKING, STATUS_SUSPENDED,
+                "Parked — awaiting the resume", EVENT_ACTOR_SYSTEM);
         }
         }
 
 
         /**
         /**
@@ -1287,6 +1258,12 @@ namespace ClankerSaddle.Services {
             }
             }
 
 
             yield mark_terminal((!)task, STATUS_FAILED, null, "Cancelled by user");
             yield mark_terminal((!)task, STATUS_FAILED, null, "Cancelled by user");
+            {
+                var payload = new Json.Object();
+                payload.set_string_member("text", "Task cancelled by user");
+                yield events.append(EVENT_CANCEL, EVENT_ACTOR_USER, EVENT_SCOPE_TASK,
+                    task_id, payload);
+            }
 
 
             yield release_task(task_id, OUTCOME_STOPPED, false);
             yield release_task(task_id, OUTCOME_STOPPED, false);
             yield pulse.bump(task_id, "task cancelled");
             yield pulse.bump(task_id, "task cancelled");
@@ -1374,17 +1351,10 @@ namespace ClankerSaddle.Services {
                 }
                 }
             }
             }
             if (park == null) {
             if (park == null) {
-                foreach (var session in yield db.query<Session>()
-                        .where(expr("task_id == $0", elem<int64?>(task_id)))
-                        .materialise_async()) {
-                    foreach (var entry in yield db.query<SessionCommand>()
-                            .where(expr("session_id == $0", elem<int64?>(session.id)))
-                            .materialise_async()) {
-                        if (entry.started_at != null
-                                && (park == null
-                                    || ((!)entry.started_at).compare((!)park) > 0)) {
-                            park = (!)entry.started_at;
-                        }
+                foreach (var record in yield events.list_for_task(task_id, new string[0])) {
+                    if (record.created_at != null) {
+                        park = (!)record.created_at;
+                        break;
                     }
                     }
                 }
                 }
             }
             }
@@ -1407,11 +1377,10 @@ namespace ClankerSaddle.Services {
                         return true;
                         return true;
                     }
                     }
                 }
                 }
-                foreach (var progress in yield db.query<TaskProgress>()
-                        .where(expr("task_id == $0", elem<int64?>(sibling.id)))
-                        .materialise_async()) {
-                    if (progress.created_at != null
-                            && progress.created_at.compare((!)park) > 0) {
+                foreach (var record in yield events.list_for_task(sibling.id,
+                        { EVENT_PROGRESS })) {
+                    if (record.created_at != null
+                            && ((!)record.created_at).compare((!)park) > 0) {
                         return true;
                         return true;
                     }
                     }
                 }
                 }
@@ -1494,6 +1463,8 @@ namespace ClankerSaddle.Services {
 
 
             yield release_task(task_id, OUTCOME_STOPPED, false);
             yield release_task(task_id, OUTCOME_STOPPED, false);
             yield add_system_comment(task_id, "Task retried by user");
             yield add_system_comment(task_id, "Task retried by user");
+            yield append_state_change(task_id, STATUS_FAILED, STATUS_QUEUED,
+                "Task retried by user", EVENT_ACTOR_USER);
             yield pulse.bump(task_id, "task retried");
             yield pulse.bump(task_id, "task retried");
         }
         }
 
 
@@ -1589,26 +1560,6 @@ namespace ClankerSaddle.Services {
             return newest != null && ((!)newest).answer != null;
             return newest != null && ((!)newest).answer != null;
         }
         }
 
 
-        /** All commands recorded against a session, oldest first. */
-        public async ImmutableLot<SessionCommand> list_commands(int64 session_id) throws Error {
-            return yield db.query<SessionCommand>()
-                .where(expr("session_id == $0", elem<int64?>(session_id)))
-                .order_by(expr("id"))
-                .materialise_async();
-        }
-
-        /**
-         * True when the session carries raw PTY log rows — the terminal
-         * view then renders the concatenated raw log instead of the
-         * typed entry stream.
-         */
-        public async bool has_raw_log(int64 session_id) throws Error {
-            var probe = yield db.query<SessionRawLog>()
-                .where(expr("session_id == $0", elem<int64?>(session_id)))
-                .first_async();
-            return probe != null;
-        }
-
         /**
         /**
          * The session still open for `task_id` (no `ended_at`), or null.
          * The session still open for `task_id` (no `ended_at`), or null.
          * The null check runs in Vala: the expression layer cannot compare
          * The null check runs in Vala: the expression layer cannot compare
@@ -1695,11 +1646,14 @@ namespace ClankerSaddle.Services {
                         || ((!)task).status == STATUS_WORKING || ((!)task).status == STATUS_BLOCKED
                         || ((!)task).status == STATUS_WORKING || ((!)task).status == STATUS_BLOCKED
                         || ((!)task).status == STATUS_REVIEW_REQUESTED
                         || ((!)task).status == STATUS_REVIEW_REQUESTED
                         || ((!)task).status == STATUS_SUSPENDED)) {
                         || ((!)task).status == STATUS_SUSPENDED)) {
+                    string from = ((!)task).status;
                     ((!)task).status = STATUS_QUEUED;
                     ((!)task).status = STATUS_QUEUED;
                     if (((!)task).suspended_at != null) {
                     if (((!)task).suspended_at != null) {
                         ((!)task).suspended_at = null;
                         ((!)task).suspended_at = null;
                     }
                     }
                     yield db.update_async<TaskEntity>((!)task);
                     yield db.update_async<TaskEntity>((!)task);
+                    yield append_state_change(task_id, from, STATUS_QUEUED,
+                        "Task requeued", EVENT_ACTOR_SYSTEM);
                 }
                 }
             }
             }
             var sessions = yield db.query<Session>()
             var sessions = yield db.query<Session>()

+ 155 - 261
src/Services/TimelineService.vala

@@ -11,7 +11,8 @@ namespace ClankerSaddle.Services {
     public class TimelineEvent : Object {
     public class TimelineEvent : Object {
         /** created | started | suspended | completed | failed |
         /** created | started | suspended | completed | failed |
          *  block-raised | block-answered | comment | progress | dispatch |
          *  block-raised | block-answered | comment | progress | dispatch |
-         *  yeehaw | goal-<kind> (plan 12) */
+         *  verdict | plan-submitted | force-wake | cancel | answer |
+         *  goal-<kind> (plan 12) */
         public string kind { get; set; default = ""; }
         public string kind { get; set; default = ""; }
         /** The event's epoch second — the newest-first sort key. */
         /** The event's epoch second — the newest-first sort key. */
         public int64 at_epoch { get; set; }
         public int64 at_epoch { get; set; }
@@ -26,18 +27,14 @@ namespace ClankerSaddle.Services {
     }
     }
 
 
     /**
     /**
-     * The home dashboard's cross-task activity timeline (plan 10
-     * phase 3): the last {@link WINDOW} events across every task,
-     * newest first — status changes (started, suspended), blocks
-     * raised and answered, comments (author + snippet), progress
-     * updates, completions/failures and session dispatches — plus the
-     * plan-12 goal events (plan submissions/reviews, task
-     * fan-out, lifecycle — plan 15 C3: derived per goal by
-     * {@link GoalService.narrative}) — materialised per request from
-     * the existing tables (tasks, task_blocks, task_comments,
-     * task_progress, sessions, goals). Nothing is archived or
-     * aggregated: at this fleet size a bounded newest-cut per source,
-     * merged and re-sorted, is the whole job.
+     * The home dashboard's cross-task activity timeline: the last
+     * {@link WINDOW} ledger events, newest first (plan 17: a pure
+     * projection of the unified `events` table — the per-source table
+     * scans it replaced are gone). The agent's protocol noise
+     * (turn_text/tool_call/tool_result/command_output/usage) stays out;
+     * everything human-relevant — status changes, block raises and
+     * answers, verdicts, comments, progress, dispatches, goal
+     * lifecycle — flows through.
      */
      */
     public class TimelineService : Object {
     public class TimelineService : Object {
 
 
@@ -48,19 +45,15 @@ namespace ClankerSaddle.Services {
         public const int SNIPPET_MAX = 160;
         public const int SNIPPET_MAX = 160;
 
 
         private OrmSession db = inject<OrmSession>();
         private OrmSession db = inject<OrmSession>();
-        private GoalService goals = inject<GoalService>();
 
 
         /**
         /**
-         * The recent event window, newest first. Each source
-         * contributes its newest WINDOW rows by id; the merged view is
-         * sorted by event time (epoch desc, then materialisation order
-         * for same-second ties) and trimmed to WINDOW.
+         * The recent event window, newest first: the newest WINDOW
+         * non-noise ledger rows, mapped onto the timeline's shape with
+         * goal-run task rows staying internal (the goal-keyed events
+         * carry the story).
          */
          */
         public async Vector<TimelineEvent> recent_window() throws Error {
         public async Vector<TimelineEvent> recent_window() throws Error {
             var titles = new Dictionary<string, string>();
             var titles = new Dictionary<string, string>();
-            // Goal-agent runs are internal tasks: their own lifecycle,
-            // mirrored blocks and telemetry never surface as task
-            // events (the goal-keyed events below carry the story).
             var run_tasks = new Invercargill.DataStructures.HashSet<string>();
             var run_tasks = new Invercargill.DataStructures.HashSet<string>();
             foreach (var task in yield db.query<TaskEntity>().materialise_async()) {
             foreach (var task in yield db.query<TaskEntity>().materialise_async()) {
                 titles.set(task.id.to_string(), task.title);
                 titles.set(task.id.to_string(), task.title);
@@ -68,275 +61,176 @@ namespace ClankerSaddle.Services {
                     run_tasks.add(task.id.to_string());
                     run_tasks.add(task.id.to_string());
                 }
                 }
             }
             }
-            var saddle_names = new Dictionary<string, string>();
-            foreach (var saddle in yield db.query<Saddle>().materialise_async()) {
-                saddle_names.set(saddle.id.to_string(), saddle.name);
-            }
             var goal_titles = new Dictionary<string, string>();
             var goal_titles = new Dictionary<string, string>();
             foreach (var goal in yield db.query<Goal>().materialise_async()) {
             foreach (var goal in yield db.query<Goal>().materialise_async()) {
                 goal_titles.set(goal.id.to_string(), goal.title);
                 goal_titles.set(goal.id.to_string(), goal.title);
             }
             }
 
 
-            var events = new Vector<TimelineEvent>();
-            int64 sequence = 0;
-
-            foreach (var task in yield db.query<TaskEntity>()
+            var rows = new Vector<EventRecord>();
+            foreach (var record in yield db.query<EventRecord>()
                     .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) {
                     .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) {
-                if (run_tasks.has(task.id.to_string())) {
-                    continue;
-                }
-                string title = titles.get_or_default(task.id.to_string()) ?? task.title;
-                if (task.created_at != null) {
-                    events.add(new TimelineEvent() {
-                        kind = "created", at_epoch = ((!)task.created_at).to_unix(),
-                        task_id = task.id, task_title = title, text = "Task created",
-                        sequence = sequence++
-                    });
-                }
-                if (task.started_at != null) {
-                    events.add(new TimelineEvent() {
-                        kind = "started", at_epoch = ((!)task.started_at).to_unix(),
-                        task_id = task.id, task_title = title, text = "Status → working",
-                        sequence = sequence++
-                    });
-                }
-                if (task.suspended_at != null) {
-                    events.add(new TimelineEvent() {
-                        kind = "suspended", at_epoch = ((!)task.suspended_at).to_unix(),
-                        task_id = task.id, task_title = title,
-                        text = "Parked — goal agent awaiting its tasks", sequence = sequence++
-                    });
-                }
-                if (task.completed_at != null) {
-                    bool done = task.status == STATUS_DONE;
-                    string text = done ? "Task completed" : "Task failed";
-                    // Heredoc markdown summaries open with their
-                    // headline: the event snippet carries the first
-                    // line only (fallback: the bare verdict text).
-                    string? lead = first_line(task.summary);
-                    if (lead != null) {
-                        text += ": " + snippet((!)lead);
-                    }
-                    events.add(new TimelineEvent() {
-                        kind = done ? "completed" : "failed",
-                        at_epoch = ((!)task.completed_at).to_unix(),
-                        task_id = task.id, task_title = title, text = text,
-                        sequence = sequence++
-                    });
-                }
+                rows.add(record);
             }
             }
-
-            foreach (var block in yield db.query<TaskBlock>()
-                    .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) {
-                if (run_tasks.has(block.task_id.to_string())) {
-                    continue;
-                }
-                if (block.created_at != null) {
-                    events.add(new TimelineEvent() {
-                        kind = "block-raised", at_epoch = ((!)block.created_at).to_unix(),
-                        task_id = block.task_id,
-                        task_title = titles.get_or_default(block.task_id.to_string()) ?? "",
-                        text = @"$(block.block_type) raised: $(snippet(block.question))",
-                        sequence = sequence++
-                    });
-                }
-                if (block.answered_at != null) {
-                    events.add(new TimelineEvent() {
-                        kind = "block-answered", at_epoch = ((!)block.answered_at).to_unix(),
-                        task_id = block.task_id,
-                        task_title = titles.get_or_default(block.task_id.to_string()) ?? "",
-                        text = @"$(block.block_type) answered: $(snippet(block.answer ?? ""))",
-                        sequence = sequence++
-                    });
+            var ordered = rows.to_array();
+            for (var i = 1; i < ordered.length; i++) {
+                var key = ordered[i];
+                var j = i - 1;
+                while (j >= 0
+                        && (epoch_of(ordered[j]) < epoch_of(key)
+                            || (epoch_of(ordered[j]) == epoch_of(key)
+                                && ordered[j].id < key.id))) {
+                    ordered[j + 1] = ordered[j];
+                    j--;
                 }
                 }
+                ordered[j + 1] = key;
             }
             }
-
-            foreach (var comment in yield db.query<TaskComment>()
-                    .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) {
-                if (comment.created_at == null || run_tasks.has(comment.task_id.to_string())) {
+            var events = new Vector<TimelineEvent>();
+            int64 sequence = 0;
+            foreach (var record in ordered) {
+                if (record.kind == EVENT_TURN_TEXT || record.kind == EVENT_TOOL_CALL
+                        || record.kind == EVENT_TOOL_RESULT
+                        || record.kind == EVENT_COMMAND_OUTPUT
+                        || record.kind == EVENT_USAGE) {
                     continue;
                     continue;
                 }
                 }
-                // Plan 16 S5 (U2): mechanical rows (restores, park
-                // notices) never reach the dashboard timeline — they
-                // live in the raw terminal log only.
-                if (comment.author == COMMENT_AUTHOR_SYSTEM
-                        && comment_is_mechanical(comment.body)) {
+                bool goal_scoped = record.scope_type == EVENT_SCOPE_GOAL;
+                bool task_scoped = record.scope_type == EVENT_SCOPE_TASK;
+                if (task_scoped && run_tasks.has(record.scope_id.to_string())) {
                     continue;
                     continue;
                 }
                 }
-                events.add(new TimelineEvent() {
-                    kind = "comment", at_epoch = ((!)comment.created_at).to_unix(),
-                    task_id = comment.task_id,
-                    task_title = titles.get_or_default(comment.task_id.to_string()) ?? "",
-                    text = @"$(comment.author): $(snippet(comment.body))",
-                    sequence = sequence++
-                });
-            }
-
-            foreach (var progress in yield db.query<TaskProgress>()
-                    .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) {
-                if (progress.created_at == null || run_tasks.has(progress.task_id.to_string())) {
+                var view = project(record);
+                if (view == null) {
                     continue;
                     continue;
                 }
                 }
-                events.add(new TimelineEvent() {
-                    kind = "progress", at_epoch = ((!)progress.created_at).to_unix(),
-                    task_id = progress.task_id,
-                    task_title = titles.get_or_default(progress.task_id.to_string()) ?? "",
-                    text = snippet(progress.summary), sequence = sequence++
-                });
-            }
-
-            foreach (var session in yield db.query<Session>()
-                    .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) {
-                if (session.started_at == null || run_tasks.has(session.task_id.to_string())) {
-                    continue;
+                ((!)view).sequence = sequence++;
+                if (goal_scoped) {
+                    ((!)view).goal_id = record.scope_id;
+                    ((!)view).goal_title = goal_titles.get_or_default(
+                        record.scope_id.to_string()) ?? "";
+                    ((!)view).kind = @"goal-$(((!)view).kind)";
+                } else if (task_scoped) {
+                    ((!)view).task_id = record.scope_id;
+                    ((!)view).task_title = titles.get_or_default(
+                        record.scope_id.to_string()) ?? "";
                 }
                 }
-                string saddle = saddle_names.get_or_default(session.saddle_id.to_string())
-                    ?? @"saddle #$(session.saddle_id)";
-                events.add(new TimelineEvent() {
-                    kind = "dispatch", at_epoch = ((!)session.started_at).to_unix(),
-                    task_id = session.task_id,
-                    task_title = titles.get_or_default(session.task_id.to_string()) ?? "",
-                    text = @"Dispatched to $(saddle)", sequence = sequence++
-                });
+                events.add((!)view);
             }
             }
+            return events;
+        }
 
 
-            // Agent celebrations (`saddle yeehaw`): the stored
-            // session_commands rows surface as their own event kind —
-            // "🤖 yeehaw'd: <first line>" — keyed on the owning session's
-            // task. Only the WINDOW newest entries load, and only
-            // THEIR sessions (a batched id lookup — not every session
-            // row). Entries without a timestamp cannot sort and are
-            // skipped like every other timestamp-less source row.
-            var yeehaw_entries = new Vector<SessionCommand>();
-            foreach (var entry in yield db.query<SessionCommand>()
-                    .where(expr("type == $0", elem(ENTRY_YEEHAW)))
-                    .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) {
-                if (entry.started_at != null) {
-                    yeehaw_entries.add(entry);
-                }
-            }
-            var session_tasks = new Dictionary<string, string>();
-            if (yeehaw_entries.length > 0) {
-                var wanted_session_ids = new Invercargill.DataStructures.HashSet<string>();
-                foreach (var entry in yeehaw_entries) {
-                    wanted_session_ids.add(entry.session_id.to_string());
-                }
-                var session_filter = id_filter("id", wanted_session_ids);
-                if (session_filter != null) {
-                    foreach (var session in yield db.query<Session>()
-                            .where((!)session_filter)
-                            .materialise_async()) {
-                        session_tasks.set(session.id.to_string(), session.task_id.to_string());
+        /**
+         * One ledger row onto the timeline shape: `kind` keeps the
+         * historical vocabulary the dashboard badges (state_change's
+         * `to` maps to created/started/completed/&c.), `text` prefers
+         * the payload's human-readable line and falls back to a
+         * per-kind synthesis. Null when the row says nothing worth a
+         * timeline entry.
+         */
+        private static TimelineEvent? project(EventRecord record) {
+            var payload = EventService.payload_of(record);
+            string text = EntryJson.string_member(payload, "text");
+            switch (record.kind) {
+                case EVENT_STATE_CHANGE:
+                    string to = EntryJson.string_member(payload, "to");
+                    string kind = timeline_kind_of_status(to);
+                    if (text.length == 0) {
+                        text = @"Status → $(to)";
                     }
                     }
-                }
-            }
-            foreach (var entry in yeehaw_entries) {
-                string? task_key = session_tasks.get_or_default(entry.session_id.to_string());
-                if (task_key == null || run_tasks.has((!)task_key)) {
-                    continue;
-                }
-                string? lead = first_line(entry.output_text ?? entry.command_text);
-                string text = lead != null
-                    ? @"🤖 yeehaw'd: $(snippet((!)lead))" : "🤖 yeehaw'd";
-                events.add(new TimelineEvent() {
-                    kind = ENTRY_YEEHAW, at_epoch = ((!)entry.started_at).to_unix(),
-                    task_id = int64.parse((!)task_key),
-                    task_title = titles.get_or_default((!)task_key) ?? "",
-                    text = text, sequence = sequence++
-                });
-            }
-
-            // Plan 12: goal events — plan submissions/reviews, the
-            // goal agent's task operations, task terminal events and
-            // lifecycle moves — keyed on the goal (the timeline links
-            // the goal page, not the task).
-            // Plan 15 C3: the goal narrative derives at read time —
-            // per goal, newest WINDOW rows, keyed on the goal (the
-            // timeline links the goal page, not the task). Narrative
-            // comment rows land as goal-comment footnotes, harness
-            // system rows as goal-system footnotes.
-            foreach (var goal in yield db.query<Goal>().materialise_async()) {
-                int64 taken_per_goal = 0;
-                foreach (var row in yield goals.narrative(goal.id)) {
-                    if (row.at == null || taken_per_goal >= WINDOW) {
-                        continue;
+                    // Terminal transitions append the summary's first
+                    // line (the old timeline snippet behaviour).
+                    string summary = EntryJson.string_member(payload, "summary");
+                    if (summary.length > 0 && (to == STATUS_DONE || to == STATUS_FAILED)) {
+                        text = @"$(text): $(first_line(summary))";
                     }
                     }
-                    taken_per_goal++;
-                    // A narrative row may reference the goal agent's
-                    // own run task — the row stays goal-keyed, but the
-                    // internal task never becomes a clickable task
-                    // reference.
-                    int64 event_task_id = run_tasks.has(row.task_id.to_string())
-                        ? 0 : row.task_id;
-                    string kind = row.kind == "comment" ? "goal-comment"
-                        : row.kind == "system" ? "goal-system" : @"goal-$(row.kind)";
-                    events.add(new TimelineEvent() {
-                        kind = kind,
-                        at_epoch = ((!)row.at).to_unix(),
-                        task_id = event_task_id,
-                        task_title = event_task_id > 0
-                            ? titles.get_or_default(event_task_id.to_string()) ?? "" : "",
-                        goal_id = goal.id,
-                        goal_title = goal_titles.get_or_default(goal.id.to_string()) ?? "",
-                        text = snippet(row.text), sequence = sequence++
-                    });
-                }
+                    return new TimelineEvent() {
+                        kind = kind, at_epoch = record.created_at != null
+                            ? ((!)record.created_at).to_unix() : 0,
+                        text = snippet(text)
+                    };
+                case EVENT_ANSWER:
+                    return new TimelineEvent() {
+                        kind = "block-answered", at_epoch = epoch_of(record),
+                        text = snippet(text.length > 0 ? text : "block answered")
+                    };
+                case EVENT_COMMENT:
+                    string author = EntryJson.string_member(payload, "author");
+                    string body = EntryJson.string_member(payload, "body");
+                    if (author == COMMENT_AUTHOR_SYSTEM && comment_is_mechanical(body)) {
+                        return null;
+                    }
+                    return new TimelineEvent() {
+                        kind = "comment", at_epoch = epoch_of(record),
+                        text = snippet(@"$(author): $(body)")
+                    };
+                case EVENT_PROGRESS:
+                    return new TimelineEvent() {
+                        kind = "progress", at_epoch = epoch_of(record),
+                        text = snippet(EntryJson.string_member(payload, "text"))
+                    };
+                case EVENT_LIFECYCLE:
+                    return new TimelineEvent() {
+                        kind = "dispatch", at_epoch = epoch_of(record),
+                        text = snippet(text.length > 0 ? text : "dispatched")
+                    };
+                case EVENT_VERDICT:
+                    return new TimelineEvent() {
+                        kind = "verdict", at_epoch = epoch_of(record),
+                        text = snippet(text.length > 0 ? text : "verdict")
+                    };
+                case EVENT_PLAN_SUBMITTED:
+                    return new TimelineEvent() {
+                        kind = "plan-submitted", at_epoch = epoch_of(record),
+                        text = snippet(text.length > 0 ? text : "plan submitted for review")
+                    };
+                case EVENT_FORCE_WAKE:
+                    return new TimelineEvent() {
+                        kind = "force-wake", at_epoch = epoch_of(record),
+                        text = snippet(text.length > 0 ? text : "force-woke")
+                    };
+                case EVENT_CANCEL:
+                    return new TimelineEvent() {
+                        kind = "cancel", at_epoch = epoch_of(record),
+                        text = snippet(text.length > 0 ? text : "cancelled")
+                    };
+                default:
+                    return null;
             }
             }
+        }
 
 
-            var sorted = events.sort((a, b) => {
-                if (a.at_epoch != b.at_epoch) {
-                    return a.at_epoch > b.at_epoch ? -1 : 1;
-                }
-                return a.sequence > b.sequence ? -1 : 1;
-            });
-            var trimmed = new Vector<TimelineEvent>();
-            int64 taken = 0;
-            foreach (var item in sorted) {
-                if (taken >= WINDOW) {
-                    break;
-                }
-                trimmed.add(item);
-                taken++;
+        /** A terminal `to` status onto the timeline's historical kind. */
+        private static string timeline_kind_of_status(string to) {
+            switch (to) {
+                case STATUS_DRAFT:
+                    return "created";
+                case STATUS_WORKING:
+                case STATUS_ASSIGNED:
+                    return "started";
+                case STATUS_SUSPENDED:
+                    return "suspended";
+                case STATUS_DONE:
+                    return "completed";
+                case STATUS_FAILED:
+                    return "failed";
+                case STATUS_BLOCKED:
+                case STATUS_REVIEW_REQUESTED:
+                    return "block-raised";
+                default:
+                    return to.length > 0 ? to : "state";
             }
             }
-            return trimmed;
         }
         }
 
 
-        private static string snippet(string body) {
-            string flat = body.replace("\n", " ");
-            return flat.length > SNIPPET_MAX ? flat.substring(0, SNIPPET_MAX) + "…" : flat;
+        private static int64 epoch_of(EventRecord record) {
+            return record.created_at != null ? ((!)record.created_at).to_unix() : 0;
         }
         }
 
 
-        /**
-         * An OR-chain of `column == id` comparisons for a batch of ids
-         * (the grammar has no IN operator), or null for an empty
-         * batch — the WHERE-in the batched lookups build.
-         */
-        private static Expression? id_filter(string column,
-                Invercargill.DataStructures.HashSet<string> ids) throws Error {
-            Expression? chain = null;
-            foreach (var id in ids) {
-                var comparison = expr(column + " == $0", elem(id));
-                chain = chain == null
-                    ? comparison : new BinaryExpression((!)chain, comparison,
-                        BinaryOperator.OR);
-            }
-            return chain;
+        private static string first_line(string body) {
+            int cut = body.index_of_char('\n');
+            return cut >= 0 ? body.substring(0, cut) : body;
         }
         }
 
 
-        /**
-         * The first non-empty line of a summary (a markdown report's
-         * headline), or null when the summary holds nothing usable.
-         */
-        private static string? first_line(string? body) {
-            if (body == null) {
-                return null;
-            }
-            string trimmed = ((!)body).strip();
-            if (trimmed.length == 0) {
-                return null;
-            }
-            int cut = trimmed.index_of("\n");
-            return cut >= 0 ? trimmed.substring(0, cut).strip() : trimmed;
+        private static string snippet(string body) {
+            string flat = body.replace("\n", " ");
+            return flat.length > SNIPPET_MAX ? flat.substring(0, SNIPPET_MAX) + "…" : flat;
         }
         }
     }
     }
 }
 }

+ 2 - 1
src/actions/GoalActions.vala

@@ -48,6 +48,7 @@ namespace ClankerSaddle {
         protected GoalService goal_service = inject<GoalService>();
         protected GoalService goal_service = inject<GoalService>();
         protected TaskService task_service = inject<TaskService>();
         protected TaskService task_service = inject<TaskService>();
         protected ProjectService project_service = inject<ProjectService>();
         protected ProjectService project_service = inject<ProjectService>();
+        protected EventService event_service = inject<EventService>();
         protected SaddleService saddle_service = inject<SaddleService>();
         protected SaddleService saddle_service = inject<SaddleService>();
         protected SaddlePollState poll_state = inject<SaddlePollState>();
         protected SaddlePollState poll_state = inject<SaddlePollState>();
         protected RuleService rule_service = inject<RuleService>();
         protected RuleService rule_service = inject<RuleService>();
@@ -61,7 +62,7 @@ namespace ClankerSaddle {
          */
          */
         protected async Statum.Slot goal_detail_slot(int64 goal_id) throws GLib.Error {
         protected async Statum.Slot goal_detail_slot(int64 goal_id) throws GLib.Error {
             var ctx = new GoalViewContext(goal_service, task_service, project_service,
             var ctx = new GoalViewContext(goal_service, task_service, project_service,
-                db, action_registry, saddle_service, poll_state, rule_service);
+                event_service, db, action_registry, saddle_service, poll_state, rule_service);
             return yield GoalState.build(goal_id, ctx, state_service);
             return yield GoalState.build(goal_id, ctx, state_service);
         }
         }
     }
     }

+ 6 - 2
src/actions/TaskActions.vala

@@ -53,6 +53,7 @@ namespace ClankerSaddle {
         protected CapabilityService capability_service = inject<CapabilityService>();
         protected CapabilityService capability_service = inject<CapabilityService>();
         protected SaddlePollState poll_state = inject<SaddlePollState>();
         protected SaddlePollState poll_state = inject<SaddlePollState>();
         protected PushService push_service = inject<PushService>();
         protected PushService push_service = inject<PushService>();
+        protected EventService event_service = inject<EventService>();
         protected InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         protected InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         protected UserService users = inject<UserService>();
         protected UserService users = inject<UserService>();
 
 
@@ -237,8 +238,6 @@ namespace ClankerSaddle {
             var me = SpryAuth.current(request);
             var me = SpryAuth.current(request);
             try {
             try {
                 yield tasks.add_comment(task_id, @"user:$(me.username)", body, quote);
                 yield tasks.add_comment(task_id, @"user:$(me.username)", body, quote);
-                yield tasks.log_session_entry(task_id, "user-comment",
-                    @"comment by user:$(me.username)", quote.length > 0 ? "> " + quote.replace("\n", "\n> ") + "\n\n" + body : body);
                 // Terminals post with a return_to so the live view survives
                 // Terminals post with a return_to so the live view survives
                 // the comment; every other caller lands on the task page.
                 // the comment; every other caller lands on the task page.
                 string return_to = Forms.field(request, "return_to");
                 string return_to = Forms.field(request, "return_to");
@@ -553,6 +552,11 @@ namespace ClankerSaddle {
                 }
                 }
                 task.force_wake = true;
                 task.force_wake = true;
                 yield db.update_async<TaskEntity>(task);
                 yield db.update_async<TaskEntity>(task);
+                var wake_payload = new Json.Object();
+                wake_payload.set_string_member("text",
+                    "The user explicitly force-woke this parked task — resume where you left off.");
+                yield event_service.append(EVENT_FORCE_WAKE, EVENT_ACTOR_USER,
+                    EVENT_SCOPE_TASK, task_id, wake_payload);
                 return directives().notify("success",
                 return directives().notify("success",
                         "Task force-woke — the parked run resumes on its saddle")
                         "Task force-woke — the parked run resumes on its saddle")
                     .set(yield task_detail_slot(task_id));
                     .set(yield task_detail_slot(task_id));

+ 57 - 0
src/entrypoints/TaskLogEntrypoint.vala

@@ -0,0 +1,57 @@
+using Invercargill;
+using Invercargill.Expressions;
+using InvercargillSql.Orm;
+using Inversion;
+using Statum;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** Public data of the event log page's `task_log` slot. */
+    public class TaskLogPublic : Object {
+        public bool exists { get; set; }
+        public int64 task_id { get; set; }
+        public string task_title { get; set; default = ""; }
+        public string task_status { get; set; default = ""; }
+        /** The task page's own address (the log header's back link). */
+        public string task_url { get; set; default = ""; }
+    }
+
+    /**
+     * Entrypoint for /tasks/{task_id}/log (plan 17 §3.2): the task's
+     * event log — the unified ledger's human view, replacing the
+     * terminal page. The page shell hydrates only the task header;
+     * the feed itself is client-side over the plan-17 endpoints: the
+     * SSE stream (/tasks/&lt;id&gt;/events/stream) delivers the initial
+     * most-recent-30 window plus live appends, and the load-more
+     * control pages backwards through GET /tasks/&lt;id&gt;/events in
+     * batches of 30. Unauthenticated like the terminal page before
+     * it (the task id is the capability).
+     */
+    public class TaskLogEntrypoint : StatumEntrypoint {
+
+        private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            int64 task_id = int64.parse(request.route("task_id") ?? "0");
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(task_id)))
+                .first_async();
+
+            var pub = new TaskLogPublic() {
+                exists = task != null,
+                task_id = task_id,
+                task_url = @"/tasks/$(task_id)"
+            };
+            if (task != null) {
+                pub.task_title = ((!)task).title;
+                pub.task_status = ((!)task).status;
+            }
+            return directives().set(state_service.new_slot(Statum.Scope.PAGE, new Statum.State() {
+                type_name = "task_log",
+                public_data = GObjectMapping.to_properties(pub)
+            }));
+        }
+    }
+}

+ 7 - 8
src/main.vala

@@ -42,22 +42,18 @@ int main(string[] args) {
             print(@"[ClankerSaddle] sqlite pragmas failed: $(pragma_error.message)\n");
             print(@"[ClankerSaddle] sqlite pragmas failed: $(pragma_error.message)\n");
         }
         }
         db.add_migration<ClankerSaddle.Data.Migrations.M0001_Initial>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0001_Initial>();
-        db.add_migration<ClankerSaddle.Data.Migrations.M0002_ForceWake>();
         db.add_entity<Project>(Project.entity_mapping);
         db.add_entity<Project>(Project.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);
         db.add_entity<TaskComment>(TaskComment.entity_mapping);
         db.add_entity<TaskComment>(TaskComment.entity_mapping);
         db.add_entity<TaskBlock>(TaskBlock.entity_mapping);
         db.add_entity<TaskBlock>(TaskBlock.entity_mapping);
-        db.add_entity<TaskProgress>(TaskProgress.entity_mapping);
+        db.add_entity<EventRecord>(EventRecord.entity_mapping);
         db.add_entity<Saddle>(Saddle.entity_mapping);
         db.add_entity<Saddle>(Saddle.entity_mapping);
         db.add_entity<Session>(Session.entity_mapping);
         db.add_entity<Session>(Session.entity_mapping);
-        db.add_entity<SessionCommand>(SessionCommand.entity_mapping);
-        db.add_entity<SaddleEntryDedup>(SaddleEntryDedup.entity_mapping);
         db.add_entity<Rule>(Rule.entity_mapping);
         db.add_entity<Rule>(Rule.entity_mapping);
         db.add_entity<RuleProject>(RuleProject.entity_mapping);
         db.add_entity<RuleProject>(RuleProject.entity_mapping);
         db.add_entity<TaskRule>(TaskRule.entity_mapping);
         db.add_entity<TaskRule>(TaskRule.entity_mapping);
         db.add_entity<ProjectNote>(ProjectNote.entity_mapping);
         db.add_entity<ProjectNote>(ProjectNote.entity_mapping);
-        db.add_entity<SessionRawLog>(SessionRawLog.entity_mapping);
         db.add_entity<Capability>(Capability.entity_mapping);
         db.add_entity<Capability>(Capability.entity_mapping);
         db.add_entity<SaddleCapability>(SaddleCapability.entity_mapping);
         db.add_entity<SaddleCapability>(SaddleCapability.entity_mapping);
         db.add_entity<TaskCapability>(TaskCapability.entity_mapping);
         db.add_entity<TaskCapability>(TaskCapability.entity_mapping);
@@ -69,6 +65,7 @@ int main(string[] args) {
 
 
         application.add_scoped<ProjectService>();
         application.add_scoped<ProjectService>();
         application.add_scoped<TaskService>();
         application.add_scoped<TaskService>();
+        application.add_scoped<EventService>();
         application.add_scoped<GoalService>();
         application.add_scoped<GoalService>();
         application.add_scoped<SaddleService>();
         application.add_scoped<SaddleService>();
         application.add_scoped<LifecycleService>();
         application.add_scoped<LifecycleService>();
@@ -99,7 +96,7 @@ int main(string[] args) {
         statum.add_page<ProjectDetailPage, ProjectDetailEntrypoint>();
         statum.add_page<ProjectDetailPage, ProjectDetailEntrypoint>();
         statum.add_page<TasksPage, TasksEntrypoint>();
         statum.add_page<TasksPage, TasksEntrypoint>();
         statum.add_page<TaskDetailPage, TaskDetailEntrypoint>();
         statum.add_page<TaskDetailPage, TaskDetailEntrypoint>();
-        statum.add_page<TaskTerminalPage, TaskTerminalEntrypoint>();
+        statum.add_page<TaskLogPage, TaskLogEntrypoint>();
         statum.add_page<GoalsPage, GoalsEntrypoint>();
         statum.add_page<GoalsPage, GoalsEntrypoint>();
         statum.add_page<GoalDetailPage, GoalDetailEntrypoint>();
         statum.add_page<GoalDetailPage, GoalDetailEntrypoint>();
         statum.add_page<SaddlesPage, SaddlesEntrypoint>();
         statum.add_page<SaddlesPage, SaddlesEntrypoint>();
@@ -202,8 +199,10 @@ int main(string[] args) {
             new EndpointRoute("/api/saddle/task/{id}", Method.GET));
             new EndpointRoute("/api/saddle/task/{id}", Method.GET));
         application.add_endpoint<SaddleCapabilitiesEndpoint>(
         application.add_endpoint<SaddleCapabilitiesEndpoint>(
             new EndpointRoute("/api/saddle/capabilities", Method.GET));
             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.TaskEventsEndpoint>(
+            new EndpointRoute("/tasks/{task_id}/events", Method.GET));
+        application.add_endpoint<ClankerSaddle.Api.EventStreamSseEndpoint>(
+            new EndpointRoute("/tasks/{task_id}/events/stream", Method.GET));
         application.add_endpoint<ClankerSaddle.Api.ServiceWorkerEndpoint>(
         application.add_endpoint<ClankerSaddle.Api.ServiceWorkerEndpoint>(
             new EndpointRoute("/sw.js", Method.GET));
             new EndpointRoute("/sw.js", Method.GET));
 
 

+ 375 - 0
src/pages/task_log.html

@@ -0,0 +1,375 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/tasks/{task_id}/log</pstm-uri>
+    <pstm-template>main</pstm-template>
+    <script>
+    // The task's event log (plan 17 §3.2): the unified ledger's human
+    // view. The feed is client-side over the plan-17 endpoints — the
+    // SSE stream seeds the most-recent-30 window and streams live
+    // appends, load-more pages backwards through the JSON endpoint in
+    // batches of 30 (the button pins at the top and appears when the
+    // window is scrolled to it while older events remain). Turn cards
+    // group a run's turn_text + tool_call + tool_result events by
+    // payload.turn (per session); results collapse behind a summary
+    // carrying head+tail truncation display and byte counts.
+    // Filters (commands/results/comments/transitions/usage) and the
+    // full-text search run client-side over the loaded window. All
+    // rendering goes through textContent/createElement — event
+    // payloads never touch innerHTML.
+    if (window.statum) {
+        (function () {
+            'use strict';
+
+            var state = {
+                events: [],
+                oldest: 0,
+                exhausted: false,
+                loading: false,
+                filters: { commands: true, results: true, comments: true,
+                           transitions: true, usage: true },
+                search: ''
+            };
+            var feed = null;
+            var moreButton = null;
+            var stream = null;
+
+            function kindClass(kind) {
+                return 'event-kind-' + String(kind || '').replace(/[^a-z0-9-]/g, '');
+            }
+
+            function filterOf(kind) {
+                if (kind === 'tool_call') return 'commands';
+                if (kind === 'tool_result' || kind === 'command_output') return 'results';
+                if (kind === 'comment' || kind === 'answer' || kind === 'verdict') return 'comments';
+                if (kind === 'usage') return 'usage';
+                return 'transitions';
+            }
+
+            function visible(evt) {
+                if (!state.filters[filterOf(evt.kind)]) return false;
+                if (state.search.length > 0) {
+                    var haystack = evt.kind + ' ' + evt.actor + ' '
+                        + JSON.stringify(evt.payload || {});
+                    if (haystack.toLowerCase().indexOf(state.search.toLowerCase()) === -1) {
+                        return false;
+                    }
+                }
+                return true;
+            }
+
+            function ingest(evt) {
+                for (var i = 0; i < state.events.length; i++) {
+                    if (state.events[i].id === evt.id) return;
+                    if (state.events[i].id < evt.id) break;
+                }
+                if (i < state.events.length && state.events[i].id === evt.id) return;
+                state.events.splice(i, 0, evt);
+                if (evt.id < state.oldest || state.oldest === 0) {
+                    state.oldest = evt.id;
+                }
+            }
+
+            function payloadBytes(evt, member) {
+                var value = evt.payload && evt.payload[member];
+                return typeof value === 'string' ? value.length : 0;
+            }
+
+            function fmtBytes(n) {
+                if (n < 1024) return n + ' B';
+                if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KiB';
+                return (n / (1024 * 1024)).toFixed(1) + ' MiB';
+            }
+
+            function preLine(text) {
+                var pre = document.createElement('pre');
+                pre.className = 'event-pre';
+                pre.textContent = text;
+                return pre;
+            }
+
+            function resultSummary(evt) {
+                var p = evt.payload || {};
+                var wrap = document.createElement('summary');
+                var label = document.createTextNode('result');
+                wrap.appendChild(label);
+                var bits = [];
+                if (p.exit_code !== undefined && p.exit_code !== null) {
+                    bits.push('exit ' + p.exit_code);
+                }
+                var bytes = payloadBytes(evt, 'stdout') + payloadBytes(evt, 'stderr');
+                if (bytes > 0) bits.push(fmtBytes(bytes));
+                if (p.truncated) bits.push('truncated');
+                if (p.turn !== undefined && p.turn !== null) bits.push('turn ' + p.turn);
+                if (bits.length > 0) {
+                    wrap.appendChild(document.createTextNode(' (' + bits.join(', ') + ')'));
+                }
+                return wrap;
+            }
+
+            function resultBody(evt) {
+                var p = evt.payload || {};
+                var div = document.createElement('div');
+                div.className = 'event-result-body';
+                var out = p.stdout || '';
+                var err = p.stderr || '';
+                if (out.length > 0) {
+                    div.appendChild(preLine(out));
+                }
+                if (err.length > 0) {
+                    var errPre = preLine(err);
+                    errPre.className = 'event-pre event-stderr';
+                    div.appendChild(errPre);
+                }
+                if (out.length === 0 && err.length === 0) {
+                    var empty = document.createElement('p');
+                    empty.className = 'muted';
+                    empty.textContent = '(no output)';
+                    div.appendChild(empty);
+                }
+                return div;
+            }
+
+            function eventNode(evt) {
+                var row = document.createElement('div');
+                row.className = 'event-row';
+                var head = document.createElement('p');
+                head.className = 'event-head';
+                var badge = document.createElement('span');
+                badge.className = 'badge ' + kindClass(evt.kind);
+                badge.textContent = String(evt.kind || '').replace(/_/g, ' ');
+                head.appendChild(badge);
+                var actor = document.createElement('span');
+                actor.className = 'muted';
+                actor.textContent = ' ' + (evt.actor || '') + ' · ' + (evt.created_display || '');
+                head.appendChild(actor);
+                row.appendChild(head);
+                var body = document.createElement('div');
+                body.className = 'event-body';
+                var p = evt.payload || {};
+                if (evt.kind === 'turn_text' && typeof p.text === 'string') {
+                    body.appendChild(preLine(p.text));
+                } else if (evt.kind === 'tool_call') {
+                    var args = p.args || {};
+                    var line = p.tool || 'tool';
+                    if (typeof args.command === 'string') line += ': ' + args.command;
+                    body.appendChild(preLine(line));
+                } else if (evt.kind === 'tool_result') {
+                    var details = document.createElement('details');
+                    details.className = 'event-result';
+                    details.appendChild(resultSummary(evt));
+                    details.appendChild(resultBody(evt));
+                    body.appendChild(details);
+                } else if (evt.kind === 'command_output') {
+                    body.appendChild(preLine(p.text || p.chunk || ''));
+                } else if (evt.kind === 'progress') {
+                    var para = document.createElement('p');
+                    para.textContent = p.text || '';
+                    body.appendChild(para);
+                } else if (evt.kind === 'comment') {
+                    var quote = document.createElement('blockquote');
+                    if (p.quote) {
+                        var q = document.createElement('p');
+                        q.className = 'muted';
+                        q.textContent = '> ' + p.quote;
+                        quote.appendChild(q);
+                    }
+                    var cbody = document.createElement('p');
+                    cbody.textContent = (p.author ? p.author + ': ' : '') + (p.body || '');
+                    quote.appendChild(cbody);
+                    body.appendChild(quote);
+                } else if (evt.kind === 'usage') {
+                    var ul = document.createElement('ul');
+                    Object.keys(p).forEach(function (key) {
+                        if (key === 'turn') return;
+                        var li = document.createElement('li');
+                        li.textContent = key + ': ' + p[key];
+                        ul.appendChild(li);
+                    });
+                    body.appendChild(ul);
+                } else {
+                    var generic = document.createElement('p');
+                    generic.textContent = p.text || JSON.stringify(p);
+                    body.appendChild(generic);
+                }
+                row.appendChild(body);
+                return row;
+            }
+
+            function turnCard(events) {
+                var card = document.createElement('div');
+                card.className = 'turn-card';
+                var head = document.createElement('p');
+                head.className = 'turn-head muted';
+                var bits = [];
+                if (events[0].payload && events[0].payload.turn !== undefined
+                        && events[0].payload.turn !== null) {
+                    bits.push('turn ' + events[0].payload.turn);
+                }
+                if (events[0].session_id) bits.push('session ' + events[0].session_id);
+                head.textContent = bits.length > 0 ? bits.join(' · ') : 'agent turn';
+                card.appendChild(head);
+                events.forEach(function (evt) {
+                    card.appendChild(eventNode(evt));
+                });
+                return card;
+            }
+
+            function render() {
+                if (!feed) return;
+                while (feed.firstChild) feed.removeChild(feed.firstChild);
+                var visibleRows = state.events.filter(visible);
+                if (visibleRows.length === 0) {
+                    var empty = document.createElement('p');
+                    empty.className = 'muted';
+                    empty.textContent = state.events.length === 0
+                        ? 'Waiting for the first events…' : 'No events match the filters.';
+                    feed.appendChild(empty);
+                }
+                // Grouping: consecutive turn-family events sharing a
+                // session and payload.turn render as one card; every
+                // other event renders inline.
+                var i = 0;
+                while (i < visibleRows.length) {
+                    var evt = visibleRows[i];
+                    var kind = evt.kind;
+                    if (kind === 'turn_text' || kind === 'tool_call'
+                            || kind === 'tool_result') {
+                        var turn = evt.payload ? evt.payload.turn : undefined;
+                        var group = [evt];
+                        var j = i + 1;
+                        while (j < visibleRows.length) {
+                            var next = visibleRows[j];
+                            var nextTurn = next.payload ? next.payload.turn : undefined;
+                            if ((next.kind === 'turn_text' || next.kind === 'tool_call'
+                                    || next.kind === 'tool_result')
+                                    && next.session_id === evt.session_id
+                                    && (turn === nextTurn
+                                        || (turn === undefined && nextTurn === undefined))) {
+                                group.push(next);
+                                j++;
+                            } else {
+                                break;
+                            }
+                        }
+                        feed.appendChild(turnCard(group));
+                        i = j;
+                    } else {
+                        feed.appendChild(eventNode(evt));
+                        i++;
+                    }
+                }
+                if (moreButton) {
+                    moreButton.hidden = state.exhausted;
+                    moreButton.disabled = state.loading;
+                    moreButton.textContent = state.loading
+                        ? 'Loading…' : 'Load older events';
+                }
+            }
+
+            function connect() {
+                var log = statum.read('task_log');
+                if (!log) return;
+                var base = '/tasks/' + log.task_id;
+                stream = new EventSource(base + '/events/stream');
+                stream.addEventListener('append', function (message) {
+                    try {
+                        ingest(JSON.parse(message.data));
+                        render();
+                    } catch (error) {
+                        console.warn('[log] bad event:', error);
+                    }
+                });
+                stream.onerror = function () {
+                    // EventSource retries on its own (retry: 3s hint).
+                };
+                if (moreButton) {
+                    moreButton.addEventListener('click', function () {
+                        loadMore(base);
+                    });
+                }
+            }
+
+            function loadMore(base) {
+                if (state.loading || state.exhausted) return;
+                state.loading = true;
+                render();
+                var url = base + '/events?before=' + (state.oldest || 0)
+                    + '&limit=30';
+                fetch(url, { credentials: 'same-origin' })
+                    .then(function (response) { return response.json(); })
+                    .then(function (page) {
+                        (page.events || []).forEach(ingest);
+                        if (!page.more) state.exhausted = true;
+                        state.loading = false;
+                        render();
+                    })
+                    .catch(function (error) {
+                        console.warn('[log] paging failed:', error);
+                        state.loading = false;
+                        render();
+                    });
+            }
+
+            document.addEventListener('click', function (event) {
+                if (!event.target.closest) return;
+                var toggle = event.target.closest('[data-log-filter]');
+                if (toggle) {
+                    var key = toggle.getAttribute('data-log-filter');
+                    state.filters[key] = !state.filters[key];
+                    toggle.classList.toggle('active', state.filters[key]);
+                    render();
+                }
+            });
+
+            document.addEventListener('input', function (event) {
+                if (!event.target.closest) return;
+                var search = event.target.closest('[data-log-search]');
+                if (search) {
+                    state.search = search.value.trim();
+                    render();
+                }
+            });
+
+            statum.afterEvaluate(function () {
+                var page = statum.read('task_log');
+                if (!page || !page.exists) return;
+                feed = document.querySelector('[data-log-feed]');
+                moreButton = document.querySelector('[data-log-more]');
+                var header = document.querySelector('[data-log-title]');
+                if (header) header.textContent = '#' + page.task_id + ' — ' + page.task_title;
+                if (!stream && feed) connect();
+                render();
+            });
+        })();
+    }
+    </script>
+</head>
+<body>
+    <div class="page log-page">
+        <p><a href="/tasks">&larr; Tasks</a></p>
+        <h2 data-log-title>Event log</h2>
+        <div stm-if="!task_log.exists" class="muted">No task exists with this id.</div>
+        <div stm-if="task_log.exists">
+            <p><a stm-attribute.href="task_log.task_url">Task page</a>
+                <span class="muted">· the unified event ledger, live (most recent 30;
+                    loads older on demand)</span></p>
+            <section class="card">
+                <div class="log-toolbar">
+                    <span class="muted">Show:</span>
+                    <button type="button" class="log-filter active" data-log-filter="commands">commands</button>
+                    <button type="button" class="log-filter active" data-log-filter="results">results</button>
+                    <button type="button" class="log-filter active" data-log-filter="comments">comments</button>
+                    <button type="button" class="log-filter active" data-log-filter="transitions">transitions</button>
+                    <button type="button" class="log-filter active" data-log-filter="usage">usage</button>
+                    <input type="search" data-log-search placeholder="Search events…">
+                </div>
+                <button type="button" class="log-more" data-log-more hidden>Load older events</button>
+                <div class="log-feed" data-log-feed>
+                    <p class="muted">Waiting for the first events…</p>
+                </div>
+            </section>
+        </div>
+    </div>
+</body>
+</html>

+ 31 - 0
vapi/invercargill-sql-inversion.vapi

@@ -0,0 +1,31 @@
+/* invercargill-sql-inversion.vapi generated by valac 0.56.19, do not modify. */
+
+
+namespace InvercargillSqlInversion {
+	[CCode (cheader_filename = "invercargill-sql-inversion.h")]
+	public class DatabaseConfigurator : GLib.Object {
+		public DatabaseConfigurator ();
+		public void add_entity<T> (owned InvercargillSqlInversion.EntityMapperFactoryFunc<T> configuration_func) throws GLib.Error;
+		public void add_migration<T> (Inversion.TypedFactoryDelegate<T>? factory_func = null);
+		public void add_projection<T> (owned InvercargillSqlInversion.ProjectionFactoryFunc<T> configuration_func) throws GLib.Error;
+		public void migrate_on_startup ();
+		public void register_connection (string connection_string, Inversion.Lifecycle? lifecycle = null) throws GLib.Error;
+	}
+	[CCode (cheader_filename = "invercargill-sql-inversion.h")]
+	public class InversionTypeProvider : GLib.Object, InvercargillSql.Orm.TypeProvider {
+		public InversionTypeProvider ();
+	}
+	[CCode (cheader_filename = "invercargill-sql-inversion.h")]
+	public class MapperTypeInfo : GLib.Object {
+		public MapperTypeInfo (GLib.Type type);
+		public GLib.Type mapper_type { get; set; }
+	}
+	[CCode (cheader_filename = "invercargill-sql-inversion.h")]
+	public class MigrationStartupService : GLib.Object {
+		public MigrationStartupService ();
+	}
+	[CCode (cheader_filename = "invercargill-sql-inversion.h")]
+	public delegate void EntityMapperFactoryFunc<T> (InvercargillSql.Orm.EntityMapperBuilder<T> cfg);
+	[CCode (cheader_filename = "invercargill-sql-inversion.h")]
+	public delegate void ProjectionFactoryFunc<T> (InvercargillSql.Orm.Projections.ProjectionBuilder<T> cfg) throws GLib.Error;
+}

+ 2 - 0
vapi/spry-0.2.deps

@@ -0,0 +1,2 @@
+statum-0.1
+invercargill-sql

+ 254 - 0
vapi/spry-0.2.vapi

@@ -0,0 +1,254 @@
+/* spry-0.2.vapi generated by valac 0.56.19, do not modify. */
+
+
+namespace Spry {
+	namespace Actions {
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class AlterUserAction : Spry.Actions.UserManagementAction {
+			public AlterUserAction ();
+			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
+			protected virtual string date_of_birth_required_message { get; }
+			protected virtual string duplicate_email_message { get; }
+			protected virtual string duplicate_user_message { get; }
+			protected virtual string duplicate_username_message { get; }
+			protected virtual string invalid_date_of_birth_message { get; }
+			protected virtual string invalid_email_message { get; }
+			protected virtual string name_required_message { get; }
+			protected virtual string password_too_short_message { get; }
+			protected virtual string self_alter_message { get; }
+			protected virtual string update_failed_message { get; }
+			protected virtual string user_required_message { get; }
+			protected virtual string user_updated_message { get; }
+			protected virtual string username_too_short_message { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class ChangePasswordAction : Statum.StatumAction {
+			public ChangePasswordAction ();
+			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
+			protected virtual string change_failed_message { get; }
+			protected virtual string current_password_invalid_message { get; }
+			protected virtual string current_password_required_message { get; }
+			protected virtual string login_uri { get; }
+			protected virtual string password_changed_message { get; }
+			protected virtual string password_mismatch_message { get; }
+			protected virtual string password_too_short_message { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class DeleteUserAction : Spry.Actions.UserManagementAction {
+			public DeleteUserAction ();
+			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
+			protected virtual string delete_failed_message { get; }
+			protected virtual string self_delete_message { get; }
+			protected virtual string user_deleted_message { get; }
+			protected virtual string user_required_message { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class GrantPermissionAction : Spry.Actions.UserManagementAction {
+			public GrantPermissionAction ();
+			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
+			protected virtual string grant_failed_message { get; }
+			protected virtual string permission_granted_message { get; }
+			protected virtual string permission_required_message { get; }
+			protected virtual string user_required_message { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class LoginAction : Statum.StatumAction {
+			public LoginAction ();
+			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
+			protected virtual string invalid_credentials_message { get; }
+			protected virtual string landing_uri { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class LogoutAction : Statum.StatumAction {
+			public LogoutAction ();
+			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
+			protected virtual string logged_out_uri { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class ProfileValidation : GLib.Object {
+			public class Fields : GLib.Object {
+				public GLib.DateTime date_of_birth { get; private set; }
+				public string email { get; private set; }
+				public string forename { get; private set; }
+				public string surname { get; private set; }
+				public string username { get; private set; }
+			}
+			public enum Rejection {
+				USERNAME_TOO_SHORT,
+				INVALID_EMAIL,
+				NAME_REQUIRED,
+				DATE_OF_BIRTH_REQUIRED,
+				INVALID_DATE_OF_BIRTH
+			}
+			public ProfileValidation ();
+			public static string? duplicate_message (GLib.Error e, string username_message, string email_message, string other_message);
+			public static Spry.Actions.ProfileValidation.Fields? validate (Statum.StatumRequest request, out Spry.Actions.ProfileValidation.Rejection rejection);
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class RegisterAction : Statum.StatumAction {
+			public RegisterAction ();
+			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
+			protected virtual string account_created_message { get; }
+			protected virtual string date_of_birth_required_message { get; }
+			protected virtual string duplicate_email_message { get; }
+			protected virtual string duplicate_user_message { get; }
+			protected virtual string duplicate_username_message { get; }
+			protected virtual string invalid_date_of_birth_message { get; }
+			protected virtual string invalid_email_message { get; }
+			protected virtual string login_uri { get; }
+			protected virtual string name_required_message { get; }
+			protected virtual string password_mismatch_message { get; }
+			protected virtual string password_too_short_message { get; }
+			protected virtual string registration_failed_message { get; }
+			protected virtual string username_too_short_message { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class RevokePermissionAction : Spry.Actions.UserManagementAction {
+			public RevokePermissionAction ();
+			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
+			protected virtual string permission_required_message { get; }
+			protected virtual string permission_revoked_message { get; }
+			protected virtual string revoke_failed_message { get; }
+			protected virtual string user_required_message { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class SetUserEnabledAction : Spry.Actions.UserManagementAction {
+			public SetUserEnabledAction ();
+			public override async Statum.DirectiveBuilder handle () throws GLib.Error;
+			protected virtual string update_failed_message { get; }
+			protected virtual string user_disabled_message { get; }
+			protected virtual string user_enabled_message { get; }
+			protected virtual string user_required_message { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class UserActionPrivate : GLib.Object {
+			public UserActionPrivate ();
+			public int64 user_id { get; set; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public abstract class UserManagementAction : Statum.StatumAction {
+			protected Spry.Authentication.UserService users;
+			protected UserManagementAction ();
+			protected async Statum.DirectiveBuilder? require_permission_guard ();
+			protected string target_permission ();
+			protected int64 target_user_id ();
+			protected override string private_namespace { get; }
+			protected virtual string required_permission { get; }
+			protected virtual string unauthorised_uri { get; }
+		}
+	}
+	namespace Authentication {
+		namespace Migrations {
+			[CCode (cheader_filename = "spry-0.2.h")]
+			public class M0001_Initial : InvercargillSql.Migrations.Migration {
+				public M0001_Initial ();
+				public override void down (InvercargillSql.Migrations.MigrationBuilder b);
+				public override void up (InvercargillSql.Migrations.MigrationBuilder b);
+				public override string migration_namespace { get; }
+				public override string name { get; }
+				public override uint64 serial { get; }
+			}
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class UserEntity : GLib.Object {
+			public UserEntity ();
+			public static void entity_mapping (InvercargillSql.Orm.EntityMapperBuilder<Spry.Authentication.UserEntity> cfg);
+			public GLib.DateTime created { get; set; }
+			public GLib.DateTime date_of_birth { get; set; }
+			public string email { get; set; }
+			public bool enabled { get; set; }
+			public string forename { get; set; }
+			public int64 id { get; set; }
+			public GLib.DateTime modified { get; set; }
+			public string password_hash { get; set; }
+			public string surname { get; set; }
+			public string username { get; set; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class UserPermissionEntity : GLib.Object {
+			public UserPermissionEntity ();
+			public static void entity_mapping (InvercargillSql.Orm.EntityMapperBuilder<Spry.Authentication.UserPermissionEntity> cfg);
+			public int64 id { get; set; }
+			public string permission { get; set; }
+			public int64 user_id { get; set; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class UserProjection : GLib.Object {
+			public UserProjection ();
+			public static void projection_mapping (InvercargillSql.Orm.Projections.ProjectionBuilder<Spry.Authentication.UserProjection> cfg) throws GLib.Error;
+			public GLib.DateTime created { get; set; }
+			public GLib.DateTime date_of_birth { get; set; }
+			public string email { get; set; }
+			public bool enabled { get; set; }
+			public string forename { get; set; }
+			public int64 id { get; set; }
+			public GLib.DateTime modified { get; set; }
+			public string password_hash { get; set; }
+			public Invercargill.ImmutableLot<string> permissions { owned get; }
+			public string surname { get; set; }
+			public string username { get; }
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public class UserService : GLib.Object {
+			public UserService ();
+			public async Spry.Authentication.UserEntity alter_user (int64 user_id, string username, string email, string forename, string surname, GLib.DateTime date_of_birth, bool enabled) throws GLib.Error;
+			public async Spry.Authentication.UserProjection? authenticate_user (string username, string password) throws GLib.Error;
+			public async void clear_user_permissions (int64 user_id) throws GLib.Error;
+			public async void delete_user (int64 user_id) throws GLib.Error;
+			public async Spry.Authentication.UserProjection? get_user (int64 user_id) throws GLib.Error;
+			public async Spry.Authentication.UserProjection? get_user_by_username (string username) throws GLib.Error;
+			public async Invercargill.ImmutableLot<string> get_user_permissions (int64 user_id) throws GLib.Error;
+			public async Invercargill.ImmutableLot<Spry.Authentication.UserProjection> list_users (int64 offset = 0, int64 limit = 100) throws GLib.Error;
+			public async Spry.Authentication.UserEntity register_user (string username, string email, string forename, string surname, GLib.DateTime date_of_birth, string password, bool enabled = true) throws GLib.Error;
+			public async void remove_user_permission (int64 user_id, string permission) throws GLib.Error;
+			public async void set_password (int64 user_id, string password) throws GLib.Error;
+			public async void set_user_enabled (int64 user_id, bool enabled) throws GLib.Error;
+			public async void set_user_permission (int64 user_id, string permission) throws GLib.Error;
+		}
+		[CCode (cheader_filename = "spry-0.2.h")]
+		public errordomain UserServiceError {
+			USER_NOT_FOUND
+		}
+	}
+	[CCode (cheader_filename = "spry-0.2.h")]
+	public class AuthPrivate : GLib.Object {
+		public AuthPrivate ();
+		public int64 user_id { get; set; }
+	}
+	[CCode (cheader_filename = "spry-0.2.h")]
+	public class AuthPublic : GLib.Object {
+		public AuthPublic ();
+		public string display_name { get; set; }
+		public Statum.Model.ActionDto? logout { get; set; }
+		public string[] permissions { get; set; }
+		public string username { get; set; }
+	}
+	[CCode (cheader_filename = "spry-0.2.h")]
+	public class CurrentUser : GLib.Object {
+		public CurrentUser ();
+		public CurrentUser.authenticated (int64 user_id, string username, string display_name, string[] permissions);
+		public bool has_permission (string permission);
+		public string display_name { get; private set; }
+		public bool is_anonymous { get; private set; }
+		public string[] permissions { get; private set; }
+		public int64 user_id { get; private set; }
+		public string username { get; private set; }
+	}
+	[CCode (cheader_filename = "spry-0.2.h")]
+	public class SpryAuth : GLib.Object {
+		public const string ADMIN_PERMISSION;
+		public const string SLOT_TYPE;
+		public SpryAuth ();
+		public static Spry.CurrentUser current (Statum.StatumRequest request);
+		public static async void ensure_admin (Spry.Authentication.UserService users, string username, string email, string password) throws GLib.Error;
+		public static Statum.DirectiveBuilder login (Statum.DirectiveBuilder directives, Spry.Authentication.UserProjection user, Statum.ActionRegistry action_registry) throws GLib.Error;
+		public static bool permission_matches (string[] patterns, string permission);
+		public static void register_actions (Statum.StatumConfigurator cfg);
+		public static async Statum.DirectiveBuilder? require_login (Statum.DirectiveBuilder directives, Statum.StatumRequest request, Spry.Authentication.UserService users, string redirect = "/login");
+		public static async Statum.DirectiveBuilder? require_permission (Statum.DirectiveBuilder directives, Statum.StatumRequest request, Spry.Authentication.UserService users, string permission, string redirect = "/login");
+	}
+	[CCode (cheader_filename = "spry-0.2.h")]
+	public class SpryModule : GLib.Object, Inversion.Module {
+		public SpryModule ();
+	}
+}

+ 3 - 0
vapi/statum-0.1.deps

@@ -0,0 +1,3 @@
+glib-2.0
+invercargill-1
+invercargill-json

+ 539 - 0
vapi/statum-0.1.vapi

@@ -0,0 +1,539 @@
+/* statum-0.1.vapi generated by valac 0.56.19, do not modify. */
+
+[CCode (gir_namespace = "statum", gir_version = "0.1")]
+namespace Statum {
+	namespace Cryptography {
+		[CCode (cheader_filename = "statum-0.1.h")]
+		public class EncryptionProvider : GLib.Object {
+			public const string DEFAULT_NAMESPACE;
+			public EncryptionProvider ();
+			public string author_properties (string @namespace, Invercargill.Properties data) throws GLib.Error;
+			public string author_string (string @namespace, string data);
+			public Invercargill.Properties read_properties (string @namespace, string blob) throws GLib.Error;
+			public string read_string (string @namespace, string blob) throws Statum.Cryptography.EncryptionError;
+			public EncryptionProvider.with_keys (uint8[] signing_secret_key, uint8[] signing_public_key, uint8[] sealing_secret_key, uint8[] sealing_public_key);
+		}
+		[CCode (cheader_filename = "statum-0.1.h")]
+		public class SigningProvider : GLib.Object {
+			public SigningProvider ();
+			public Statum.Model.FrameDto author_frame (string content);
+			public uint8[] get_public_key ();
+			public string sign (string content);
+			public bool verify (string content, string signature_b64);
+			public void verify_frame (Statum.Model.FrameDto frame) throws Statum.Cryptography.SigningError;
+			public bool verify_with_key (string content, string signature_b64, uint8[] verification_public_key);
+			public SigningProvider.with_keys (uint8[] secret_key, uint8[] public_key);
+			public string signer_id { get; set; }
+		}
+		[CCode (cheader_filename = "statum-0.1.h")]
+		public errordomain EncryptionError {
+			DECRYPTION_FAILED,
+			SIGNATURE_INVALID,
+			NAMESPACE_MISMATCH
+		}
+		[CCode (cheader_filename = "statum-0.1.h")]
+		public errordomain SigningError {
+			INVALID_SIGNATURE
+		}
+	}
+	namespace Model {
+		[CCode (cheader_filename = "statum-0.1.h")]
+		public class ActionDto : GLib.Object {
+			public ActionDto ();
+			public static Invercargill.Mapping.PropertyMapper<Statum.Model.ActionDto> get_mapper ();
+			public InvercargillJson.JsonElement to_element () throws GLib.Error;
+			public string method { get; set; }
+			public string? @private { get; set; }
+			public string uri { get; set; }
+		}
+		[CCode (cheader_filename = "statum-0.1.h")]
+		public class ClearDirectiveDto : Statum.Model.DirectiveDto {
+			public ClearDirectiveDto ();
+			protected override Invercargill.Properties build_field_properties () throws GLib.Error;
+			public static Invercargill.Mapping.PropertyMapper<Statum.Model.ClearDirectiveDto> get_mapper ();
+			public ClearDirectiveDto.with_key (string key);
+			public override string discriminator { get; }
+			public string key { get; set; }
+		}
+		[CCode (cheader_filename = "statum-0.1.h")]
+		public abstract class DirectiveDto : GLib.Object {
+			protected DirectiveDto ();
+			protected abstract Invercargill.Properties build_field_properties () throws GLib.Error;
+			public static Invercargill.DataStructures.Series<Statum.Model.DirectiveDto> deserialize_array (InvercargillJson.JsonElement element) throws GLib.Error;
+			public static Statum.Model.DirectiveDto from_json (InvercargillJson.JsonObject json) throws GLib.Error;
+			public static InvercargillJson.JsonElement serialize_array (Invercargill.Lot<Statum.Model.DirectiveDto> directives) throws GLib.Error;
+			public InvercargillJson.JsonElement to_json () throws GLib.Error;
+			public InvercargillJson.JsonObject to_json_object () throws GLib.Error;
+			public abstract string discriminator { get; }
+		}
+		[CCode (cheader_filename = "statum-0.1.h")]
+		public class ErrorDirectiveDto : Statum.Model.DirectiveDto {
+			public ErrorDirectiveDto ();
+			protected override Invercargill.Properties build_field_properties () throws GLib.Error;
+			public static Invercargill.Mapping.PropertyMapper<Statum.Model.ErrorDirectiveDto> get_mapper ();
+			public string? code { get; set; }
+			public Invercargill.Properties? data { get; set; }
+			public override string discriminator { get; }
+			public string? message { get; set; }
+		}
+		[CCode (cheader_filename = "statum-0.1.h")]
+		public class FormResetDirectiveDto : Statum.Model.DirectiveDto {
+			public FormResetDirectiveDto ();
+			protected override Invercargill.Properties build_field_properties () throws GLib.Error;
+			public static Invercargill.Mapping.PropertyMapper<Statum.Model.FormResetDirectiveDto> get_mapper ();
+			public FormResetDirectiveDto.with_form (string form);
+			public override string discriminator { get; }
+			public string form { get; set; }
+		}
+		[CCode (cheader_filename = "statum-0.1.h")]
+		public class FormSubmitDirectiveDto : Statum.Model.DirectiveDto {
+			public FormSubmitDirectiveDto ();
+			protected override Invercargill.Properties build_field_properties () throws GLib.Error;
+			public static Invercargill.Mapping.PropertyMapper<Statum.Model.FormSubmitDirectiveDto> get_mapper ();
+			public FormSubmitDirectiveDto.with_form (string form, string? action = null, string? method = null);
+			public string? action { get; set; }
+			public override string discriminator { get; }
+			public string form { get; set; }
+			public string? method { get; set; }
+		}
+		[CCode (cheader_filename = "statum-0.1.h")]
+		public class FrameDto : GLib.Object {
+			public FrameDto ();
+			public static Invercargill.Mapping.PropertyMapper<Statum.Model.FrameDto> get_mapper ();
+			public string content { get; set; }
+			public string signature { get; set; }
+			public string signer { get; set; }
+		}
+		[CCode (cheader_filename = "statum-0.1.h")]
+		public class InvalidateDirectiveDto : Statum.Model.DirectiveDto {
+			public InvalidateDirectiveDto ();
+			protected override Invercargill.Properties build_field_properties () throws GLib.Error;
+			public static Invercargill.Mapping.PropertyMapper<Statum.Model.InvalidateDirectiveDto> get_mapper ();
+			public InvalidateDirectiveDto.with_signature (string signature);
+			public override string discriminator { get; }
+			public string signature { get; set; }
+		}
+		[CCode (cheader_filename = "statum-0.1.h")]
+		public class NavigateDirectiveDto : Statum.Model.DirectiveDto {
+			public NavigateDirectiveDto ();
+			protected override Invercargill.Properties build_field_properties () throws GLib.Error;
+			public static Invercargill.Mapping.PropertyMapper<Statum.Model.NavigateDirectiveDto> get_mapper ();
+			public NavigateDirectiveDto.with_uri (string uri);
+			public override string discriminator { get; }
+			public string uri { get; set; }
+		}
+		[CCode (cheader_filename = "statum-0.1.h")]
+		public class NotifyDirectiveDto : Statum.Model.DirectiveDto {
+			public NotifyDirectiveDto ();
+			protected override Invercargill.Properties build_field_properties () throws GLib.Error;
+			public static Invercargill.Mapping.PropertyMapper<Statum.Model.NotifyDirectiveDto> get_mapper ();
+			public NotifyDirectiveDto.@with (string kind, string message);
+			public override string discriminator { get; }
+			public string kind { get; set; }
+			public string message { get; set; }
+		}
+		[CCode (cheader_filename = "statum-0.1.h")]
+		public class PostDirectiveDto : Statum.Model.DirectiveDto {
+			public PostDirectiveDto ();
+			protected override Invercargill.Properties build_field_properties () throws GLib.Error;
+			public static Invercargill.Mapping.PropertyMapper<Statum.Model.PostDirectiveDto> get_mapper ();
+			public PostDirectiveDto.@with (string uri, Invercargill.Properties data);
+			public Invercargill.Properties data { get; set; }
+			public override string discriminator { get; }
+			public string uri { get; set; }
+		}
+		[CCode (cheader_filename = "statum-0.1.h")]
+		public class SetDirectiveDto : Statum.Model.DirectiveDto {
+			public SetDirectiveDto ();
+			protected override Invercargill.Properties build_field_properties () throws GLib.Error;
+			public static Invercargill.Mapping.PropertyMapper<Statum.Model.SetDirectiveDto> get_mapper ();
+			public SetDirectiveDto.with_frame (Statum.Model.FrameDto snapshot_frame);
+			public override string discriminator { get; }
+			public Statum.Model.FrameDto snapshot_frame { get; set; }
+		}
+		[CCode (cheader_filename = "statum-0.1.h")]
+		public class SlotDto : GLib.Object {
+			public SlotDto ();
+			public static Invercargill.Mapping.PropertyMapper<Statum.Model.SlotDto> get_mapper ();
+			public string key { get; set; }
+			public Statum.Scope scope { get; set; }
+			public string slot_type { get; set; }
+		}
+		[CCode (cheader_filename = "statum-0.1.h")]
+		public class SnapshotDto : GLib.Object {
+			public SnapshotDto ();
+			public static Invercargill.Mapping.PropertyMapper<Statum.Model.SnapshotDto> get_mapper ();
+			public GLib.DateTime as_at { get; set; }
+			public GLib.DateTime? invalid_after { get; set; }
+			public string? @private { get; set; }
+			public Invercargill.Properties @public { get; set; }
+			public Statum.Model.SlotDto slot { get; set; }
+			public GLib.DateTime? transmit_after { get; set; }
+		}
+		[CCode (cheader_filename = "statum-0.1.h")]
+		public class SubscribeDirectiveDto : Statum.Model.DirectiveDto {
+			public SubscribeDirectiveDto ();
+			protected override Invercargill.Properties build_field_properties () throws GLib.Error;
+			public static Invercargill.Mapping.PropertyMapper<Statum.Model.SubscribeDirectiveDto> get_mapper ();
+			public SubscribeDirectiveDto.with_key (string key);
+			public override string discriminator { get; }
+			public string key { get; set; }
+		}
+		[CCode (cheader_filename = "statum-0.1.h")]
+		public class TransmitDirectiveDto : Statum.Model.DirectiveDto {
+			public TransmitDirectiveDto ();
+			protected override Invercargill.Properties build_field_properties () throws GLib.Error;
+			public static Invercargill.Mapping.PropertyMapper<Statum.Model.TransmitDirectiveDto> get_mapper ();
+			public TransmitDirectiveDto.@with (string key, bool retry);
+			public override string discriminator { get; }
+			public string key { get; set; }
+			public bool retry { get; set; }
+		}
+		[CCode (cheader_filename = "statum-0.1.h")]
+		public class UnsubscribeDirectiveDto : Statum.Model.DirectiveDto {
+			public UnsubscribeDirectiveDto ();
+			protected override Invercargill.Properties build_field_properties () throws GLib.Error;
+			public static Invercargill.Mapping.PropertyMapper<Statum.Model.UnsubscribeDirectiveDto> get_mapper ();
+			public UnsubscribeDirectiveDto.with_key (string key);
+			public override string discriminator { get; }
+			public string key { get; set; }
+		}
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class ActionEndpoint : GLib.Object, Astralis.Endpoint {
+		public ActionEndpoint ();
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class ActionRegistry : GLib.Object {
+		public ActionRegistry ();
+		public Statum.Model.ActionDto author<TAction> (string method = "POST");
+		public Statum.Model.ActionDto author_private<TAction,TPrivate> (TPrivate model, string method = "POST") throws GLib.Error;
+		public string guid_for<TAction> ();
+		public string guid_for_type (GLib.Type type);
+		public string namespace_for<TAction> ();
+		public string namespace_for_type (GLib.Type type);
+		public void register<TAction> ();
+		public void register_type (GLib.Type type);
+		public GLib.Type? resolve (string guid);
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class ChannelEndpoint : Astralis.SseEndpoint, Statum.ChannelService {
+		public ChannelEndpoint ();
+		public bool has_channel (string id);
+		public override async void new_connection (Astralis.HttpContext http_context, Astralis.RouteContext route_context, Astralis.SseStream stream);
+		public void subscribe (string id, string key);
+		public void unsubscribe (string id, string key);
+		public override uint retry_interval { get; }
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class ChannelSubscriptionEndpoint : GLib.Object, Astralis.Endpoint {
+		public ChannelSubscriptionEndpoint ();
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class ClientScript : Statum.ConstantStatumResource {
+		public ClientScript ();
+		public override string get_best_encoding (Invercargill.Set<string> supported);
+		public override unowned uint8[] get_encoding (string encoding);
+		public override string content_type { get; }
+		public override string file_hash { get; }
+		public override string name { get; }
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class ClientWorker : Statum.ConstantStatumResource {
+		public ClientWorker ();
+		public override string get_best_encoding (Invercargill.Set<string> supported);
+		public override unowned uint8[] get_encoding (string encoding);
+		public override string content_type { get; }
+		public override string file_hash { get; }
+		public override string name { get; }
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public abstract class ConstantStatumResource : GLib.Object, Statum.StatumResource {
+		protected ConstantStatumResource ();
+		public abstract string get_best_encoding (Invercargill.Set<string> supported);
+		public abstract unowned uint8[] get_encoding (string encoding);
+		public abstract string content_type { get; }
+		public abstract string file_hash { get; }
+		public abstract string name { get; }
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class DirectiveBuilder : GLib.Object {
+		public async Invercargill.Lot<Statum.Model.DirectiveDto> build () throws GLib.Error;
+		public Statum.DirectiveBuilder clear (string key);
+		public Statum.DirectiveBuilder error (string message, string? code = null, Invercargill.Properties? data = null);
+		public Statum.DirectiveBuilder form_reset (string form_id);
+		public Statum.DirectiveBuilder form_submit (string form_id, string? action = null, string? method = null);
+		public Statum.DirectiveBuilder invalidate (string signature);
+		public Statum.DirectiveBuilder navigate (string uri);
+		public Statum.DirectiveBuilder notify (string kind, string message);
+		public Statum.DirectiveBuilder post (string uri, Invercargill.Properties data);
+		public Statum.DirectiveBuilder @set (Statum.Slot slot);
+		public Statum.DirectiveBuilder set_frame (Statum.Model.FrameDto frame);
+		public Statum.DirectiveBuilder set_global (string type_name) throws GLib.Error;
+		public Statum.DirectiveBuilder set_private_typed<TPublic,TPrivate> (string type_name, Statum.Scope scope, TPublic pub, TPrivate priv) throws GLib.Error;
+		public Statum.DirectiveBuilder set_typed<TPublic> (string type_name, Statum.Scope scope, TPublic pub) throws GLib.Error;
+		public Statum.DirectiveBuilder subscribe (string key);
+		public Statum.DirectiveBuilder subscribe_global (string type_name);
+		public async Invercargill.Lot<Statum.Model.DirectiveDto> to_lot () throws GLib.Error;
+		public Statum.DirectiveBuilder transmit (string key, bool retry = false);
+		public Statum.DirectiveBuilder unsubscribe (string key);
+		public Statum.DirectiveBuilder update (string key, Statum.State state);
+		public Statum.DirectiveBuilder update_held<TPublic> (string type_name, owned Statum.UpdateMutator<TPublic> mutator) throws GLib.Error;
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class EntrypointEndpoint : GLib.Object, Astralis.Endpoint {
+		public EntrypointEndpoint ();
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class EntrypointMatch : GLib.Object {
+		public EntrypointMatch ();
+		public GLib.Type entrypoint_type { get; set; }
+		public Invercargill.ReadOnlyAssociative<string,string> route_params { get; set; }
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class EntrypointRouteTable : GLib.Object {
+		public EntrypointRouteTable ();
+		public Statum.EntrypointMatch? match (string uri);
+		public void register (Astralis.EndpointRoute route, GLib.Type entrypoint_type);
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class GObjectMapping : GLib.Object {
+		public GObjectMapping ();
+		public static GLib.Object from_properties (GLib.Type type, Invercargill.Properties props) throws GLib.Error;
+		public static T from_properties_typed<T> (GLib.Type type, Invercargill.Properties props) throws GLib.Error;
+		public static bool? get_bool (Invercargill.Properties props, string key);
+		public static double? get_double (Invercargill.Properties props, string key);
+		public static int? get_int (Invercargill.Properties props, string key);
+		public static string? get_string (Invercargill.Properties props, string key);
+		public static void register_mapper<T> (Invercargill.Mapping.PropertyMapper<T> mapper);
+		public static Invercargill.DataStructures.PropertyDictionary to_properties (GLib.Object model) throws GLib.Error;
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class HeldSlot : GLib.Object {
+		public HeldSlot ();
+		public GLib.DateTime as_at { get; set; }
+		public GLib.DateTime? invalid_after { get; set; }
+		public string key { get; set; }
+		public Invercargill.Properties @private { get; set; }
+		public Invercargill.Properties @public { get; set; }
+		public Statum.Scope scope { get; set; }
+		public GLib.DateTime? transmit_after { get; set; }
+		public string type_name { get; set; }
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class HeldSlotResolver : GLib.Object {
+		public HeldSlotResolver ();
+		public Statum.ResolvedSlots resolve (Astralis.HttpContext http_context);
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class HeldStates : GLib.Object {
+		public T @get<T> (string type_name);
+		public Invercargill.Properties? get_properties (string type_name);
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class ResolvedSlots : GLib.Object {
+		public ResolvedSlots ();
+		public Invercargill.DataStructures.Dictionary<string,Statum.HeldSlot> held { get; private set; }
+		public Invercargill.DataStructures.Series<string> invalid_signatures { get; private set; }
+		public Invercargill.DataStructures.Series<string> stale_keys { get; private set; }
+		public Invercargill.DataStructures.Series<string> transmit_keys { get; private set; }
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class ResourceEndpoint : GLib.Object, Astralis.Endpoint {
+		public ResourceEndpoint ();
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class Slot : GLib.Object {
+		public Slot ();
+		public Statum.State current_state { get; set; }
+		public string id { get; set; }
+		public Statum.Scope scope { get; set; }
+		public string type_name { get; set; }
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class SlotPostEndpoint : GLib.Object, Astralis.Endpoint {
+		public SlotPostEndpoint ();
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class State : GLib.Object {
+		public State ();
+		public GLib.DateTime last_touched { get; set; }
+		public Invercargill.Properties private_data { get; set; }
+		public Invercargill.Properties public_data { get; set; }
+		public string type_name { get; set; }
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public abstract class StateModifier : GLib.Object, Statum.TopicStateModifier {
+		protected Statum.StateService state_service;
+		protected StateModifier ();
+		public abstract GLib.Object? derive (GLib.Object payload, GLib.Object? current, Statum.HeldStates held);
+		public abstract GLib.Type state_type { get; }
+		public string state_type_name { get; }
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class StateService : GLib.Object {
+		public const string GLOBAL_KEY_PREFIX;
+		public StateService ();
+		public void cleanup ();
+		public async void clear_global (string type_name);
+		public async void clear_slot (string key);
+		public Statum.Slot? get_slot (string id);
+		public Statum.Slot new_global_slot (string type_name, Statum.Scope slot_scope, Statum.State state);
+		public Statum.Slot new_slot (Statum.Scope slot_scope, Statum.State initial_state);
+		public bool restore_slot (Statum.Model.FrameDto frame) throws GLib.Error;
+		public Statum.Model.FrameDto? sign_slot (string id, bool unidirectional = false) throws GLib.Error;
+		public async Statum.Model.FrameDto update (string key, Statum.State state) throws GLib.Error;
+		public async Statum.Model.FrameDto update_global (string type_name, Statum.State state) throws GLib.Error;
+		public async void update_global_typed<TPublic> (string type_name, owned Statum.UpdateMutator<TPublic> mutator) throws GLib.Error;
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public abstract class StatumAction : GLib.Object, Astralis.Endpoint {
+		protected Statum.ActionRegistry action_registry;
+		protected Statum.ChannelService channel_service;
+		protected Statum.Cryptography.EncryptionProvider encryption_provider;
+		protected Statum.StatumRequest request;
+		protected Statum.HeldSlotResolver resolver;
+		protected Inversion.Scope scope;
+		protected Statum.StateService state_service;
+		protected Statum.TopicRegistry topic_registry;
+		public const string ACTION_PRIVATE_NAMESPACE;
+		protected StatumAction ();
+		protected Statum.DirectiveBuilder directives ();
+		public abstract async Statum.DirectiveBuilder handle () throws GLib.Error;
+		protected virtual string? private_error_code { get; }
+		protected virtual string private_error_message { get; }
+		protected virtual string private_namespace { get; }
+		protected virtual bool requires_valid_private { get; }
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class StatumConfigurator : GLib.Object {
+		public Statum.StateService state_service;
+		public Statum.TopicRegistry topic_registry;
+		public StatumConfigurator ();
+		public void action<TAction> ();
+		public void add_page<TPage,TEntrypoint> () throws GLib.Error;
+		public void add_resource<T> ();
+		public void add_static_page<TPage> () throws GLib.Error;
+		public void global<TPublic> (string type_name, TPublic initial) throws GLib.Error;
+		public void topic<TTopic> (string prefix);
+		public void topic_modifier<TModifier,TState> (string prefix);
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class StatumDirectives : GLib.Object {
+		public const string CONTENT_TYPE;
+		public StatumDirectives ();
+		public static Astralis.HttpResult to_result (Invercargill.Lot<Statum.Model.DirectiveDto> directives, Invercargill.Lot<string>? invalid_signatures = null, Statum.StateService? state_service = null, Invercargill.Lot<string>? stale_keys = null) throws GLib.Error;
+		public static Astralis.HttpResult transmit_result (Invercargill.Lot<string> transmit_keys, Invercargill.Lot<string>? invalid_signatures = null) throws GLib.Error;
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public abstract class StatumEntrypoint : GLib.Object {
+		protected Statum.ActionRegistry action_registry;
+		protected Statum.ChannelService channel_service;
+		protected Statum.StatumRequest request;
+		protected Statum.StateService state_service;
+		protected Statum.TopicRegistry topic_registry;
+		protected StatumEntrypoint ();
+		protected Statum.DirectiveBuilder directives ();
+		public abstract async Statum.DirectiveBuilder handle () throws GLib.Error;
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class StatumModule : GLib.Object, Inversion.Module {
+		public StatumModule ();
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public abstract class StatumPage : GLib.Object, Astralis.Endpoint {
+		protected StatumPage ();
+		public abstract string get_best_encoding (Invercargill.Set<string> supported);
+		public abstract unowned uint8[] get_encoding (string encoding);
+		public abstract string get_etag_for (string encoding);
+		public abstract string content_type { get; }
+		public abstract string route { get; }
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class StatumRequest : GLib.Object {
+		public StatumRequest ();
+		public TPublic held_as<TPublic> (string type_name);
+		public string? route (string name);
+		public Invercargill.Properties? action_private { get; set; }
+		public Astralis.FormData? form { get; set; }
+		public Invercargill.ReadOnlyAssociative<string,Statum.HeldSlot> held { get; set; }
+		public Invercargill.DataStructures.Catalogue<string,string> query { get; set; }
+		public Invercargill.ReadOnlyAssociative<string,string> route_params { get; set; }
+		public string uri { get; set; }
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public abstract class Topic<TPayload> : GLib.Object, Statum.TopicLoader {
+		protected Topic ();
+		public abstract async TPayload load (string topic_key) throws GLib.Error;
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public class TopicRegistry : GLib.Object {
+		public TopicRegistry ();
+		public void register_modifier<TModifier,TState> (string prefix);
+		public void register_topic<TTopic> (string prefix);
+		public void sweep ();
+		public async void trigger (string topic_key) throws GLib.Error;
+		public void watch (string topic_key, string slot_key, string slot_type, Invercargill.ReadOnlyAssociative<string,Statum.HeldSlot> request_held);
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public abstract class TypedStatumAction<TPrivate> : Statum.StatumAction {
+		protected TypedStatumAction ();
+		protected override string private_namespace { get; }
+		protected virtual GLib.Type private_type { get; }
+		protected TPrivate request_private { get; }
+		protected override bool requires_valid_private { get; }
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public interface ChannelService : GLib.Object {
+		public abstract async void push_clear (string key);
+		public abstract async void push_set (string key, string frame_json);
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public interface StatumResource : GLib.Object {
+		public abstract string get_best_encoding (Invercargill.Set<string> supported);
+		public abstract string get_etag_for (string encoding);
+		public abstract Astralis.HttpResult to_result (string encoding, Astralis.StatusCode status = Astralis.StatusCode.OK) throws GLib.Error;
+		public abstract string name { get; }
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public interface TopicLoader : GLib.Object {
+		public abstract async GLib.Object? load_payload (string topic_key) throws GLib.Error;
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public interface TopicStateModifier : GLib.Object {
+		public abstract async Statum.ModifierOutcome apply (GLib.Object payload, string slot_key, Statum.HeldStates held) throws GLib.Error;
+		public abstract GLib.Type state_type { get; }
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public enum ModifierOutcome {
+		UPDATED,
+		UNCHANGED,
+		CLEARED
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public enum Scope {
+		DEVICE,
+		SESSION,
+		FLOW,
+		PAGE,
+		TRANSIENT;
+		public string to_string ();
+		public static Statum.Scope? from_string (string value);
+		public static Statum.Scope parse (string value) throws GLib.Error;
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public errordomain ModelError {
+		INVALID_SCOPE,
+		UNKNOWN_DIRECTIVE
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public errordomain StateError {
+		SLOT_NOT_FOUND
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public errordomain StatumError {
+		CONFIGURATION
+	}
+	[CCode (cheader_filename = "statum-0.1.h")]
+	public delegate void UpdateMutator<T> (T current);
+}