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

feat: rules — migration, CRUD page, project/task attach UI, effective rule set (task ∪ primary ∪ co-developed, deduped, ordered) in the /start payload

clanker 1 месяц назад
Родитель
Сommit
39097a49dd

+ 11 - 0
meson.build

@@ -79,6 +79,12 @@ saddles_page = custom_target('saddles-page',
     command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'SaddlesPage', '--ns', 'ClankerSaddle', '@INPUT@'],
     depend_files: template_deps
 )
+rules_page = custom_target('rules-page',
+    input: 'src/pages/rules.html',
+    output: 'RulesPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'RulesPage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
 # spry:pages-end
 
 # =============================================================================
@@ -102,12 +108,14 @@ app_sources = files(
     'src/Data/Entities.vala',
     'src/Data/Migrations/M0001_Initial.vala',
     'src/Data/Migrations/M0002_Part2.vala',
+    'src/Data/Migrations/M0003_Rules.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/SaddleService.vala',
     'src/Services/SaddlePollState.vala',
     'src/Services/StatePulse.vala',
     'src/Services/Dispatcher.vala',
+    'src/Services/RuleService.vala',
     'src/Api/SaddleApi.vala',
     'src/Api/TerminalSse.vala',
     'src/actions/Forms.vala',
@@ -116,6 +124,7 @@ app_sources = files(
     'src/actions/TaskActions.vala',
     'src/actions/DashboardActions.vala',
     'src/actions/SaddleActions.vala',
+    'src/actions/RuleActions.vala',
     'src/entrypoints/HomeEntrypoint.vala',
     'src/entrypoints/LoginEntrypoint.vala',
     'src/entrypoints/ProjectsEntrypoint.vala',
@@ -123,6 +132,7 @@ app_sources = files(
     'src/entrypoints/TaskDetailEntrypoint.vala',
     'src/entrypoints/TaskTerminalEntrypoint.vala',
     'src/entrypoints/SaddlesEntrypoint.vala',
+    'src/entrypoints/RulesEntrypoint.vala',
     # spry:sources-end
 )
 
@@ -136,6 +146,7 @@ app_generated = [
     task_detail_page,
     task_terminal_page,
     saddles_page,
+    rules_page,
     # spry:generated-end
 ]
 

+ 50 - 0
src/Data/Entities.vala

@@ -266,6 +266,56 @@ namespace ClankerSaddle.Data {
         }
     }
 
+    /**
+     * A reusable instruction the dispatched agent must follow, stored in
+     * the `rules` table and attached to projects and/or tasks.
+     */
+    public class Rule : Object {
+
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+        public string body { get; set; default = ""; }
+        public DateTime? created_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<Rule> cfg) {
+            cfg.table("rules")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<string>("name", o => o.name, (o, v) => o.name = v)
+                .column<string>("body", o => o.body, (o, v) => o.body = v)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
+        }
+    }
+
+    /** An attachment of a rule to a project (`rule_projects`). */
+    public class RuleProject : Object {
+
+        public int64 id { get; set; }
+        public int64 rule_id { get; set; }
+        public int64 project_id { get; set; }
+
+        public static void entity_mapping(EntityMapperBuilder<RuleProject> cfg) {
+            cfg.table("rule_projects")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("rule_id", o => o.rule_id, (o, v) => o.rule_id = v)
+                .column<int64?>("project_id", o => o.project_id, (o, v) => o.project_id = v);
+        }
+    }
+
+    /** An attachment of a rule to a task (`task_rules`). */
+    public class TaskRule : Object {
+
+        public int64 id { get; set; }
+        public int64 rule_id { get; set; }
+        public int64 task_id { get; set; }
+
+        public static void entity_mapping(EntityMapperBuilder<TaskRule> cfg) {
+            cfg.table("task_rules")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("rule_id", o => o.rule_id, (o, v) => o.rule_id = v)
+                .column<int64?>("task_id", o => o.task_id, (o, v) => o.task_id = v);
+        }
+    }
+
     /**
      * A bearer token authorising one saddle daemon's calls against the
      * `/api/saddle/*` endpoints, generated at registration.

+ 67 - 0
src/Data/Migrations/M0003_Rules.vala

@@ -0,0 +1,67 @@
+using InvercargillSql.Migrations;
+
+namespace ClankerSaddle.Data.Migrations {
+
+    /**
+     * Web-stack rules schema: reusable rule bodies, their project and
+     * task attachments. The effective rule set for a dispatch is task ∪
+     * primary project ∪ co-developed projects (see
+     * {@link ClankerSaddle.Services.RuleService}).
+     */
+    public class M0003_Rules : Migration {
+
+        public override string name { get { return "Rules: rules, rule_projects, task_rules"; } }
+        public override uint64 serial { get { return 3; } }
+        public override string migration_namespace { get { return "clanker-saddle"; } }
+
+        public override void up(MigrationBuilder b) {
+            b.create_table("rules", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<string>("name")
+                    .not_null()
+                    .unique();
+                t.column<string>("body")
+                    .not_null();
+                t.column<DateTime?>("created_at");
+            });
+
+            b.create_table("rule_projects", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("rule_id")
+                    .not_null()
+                    .references("rules", "id")
+                    .indexed();
+                t.column<int64?>("project_id")
+                    .not_null()
+                    .references("projects", "id")
+                    .indexed();
+                t.unique("uq_rule_projects_pair", {"rule_id", "project_id"});
+            });
+
+            b.create_table("task_rules", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("rule_id")
+                    .not_null()
+                    .references("rules", "id")
+                    .indexed();
+                t.column<int64?>("task_id")
+                    .not_null()
+                    .references("tasks", "id")
+                    .indexed();
+                t.unique("uq_task_rules_pair", {"rule_id", "task_id"});
+            });
+        }
+
+        public override void down(MigrationBuilder b) {
+            b.drop_table("task_rules");
+            b.drop_table("rule_projects");
+            b.drop_table("rules");
+        }
+    }
+}

+ 252 - 0
src/Services/RuleService.vala

@@ -0,0 +1,252 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql.Orm;
+using Inversion;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /**
+     * Rule storage: CRUD over `rules` plus their project and task
+     * attachments, and the effective-rule computation the dispatcher
+     * ships in the `/start` payload.
+     */
+    public class RuleService : Object {
+
+        private OrmSession db = inject<OrmSession>();
+
+        /**
+         * Inserts a rule; names are unique. Returns the stored row (with
+         * its id).
+         */
+        public async Rule create_rule(string name, string body) throws Error {
+            if (name.strip().length == 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 30,
+                    "A rule name is required");
+            }
+            if (body.strip().length == 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 31,
+                    "A rule body is required");
+            }
+            var existing = yield db.query<Rule>()
+                .where(expr("name == $0", elem(name.strip())))
+                .first_async();
+            if (existing != null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 32,
+                    @"A rule named '$(name)' already exists");
+            }
+            var rule = new Rule() {
+                name = name.strip(),
+                body = body,
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<Rule>(rule);
+            return rule;
+        }
+
+        /** Loads the rule identified by `id`, or null. */
+        public async Rule? get_rule(int64 id) throws Error {
+            return yield db.query<Rule>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+        }
+
+        /** Every rule ordered by name. */
+        public async ImmutableLot<Rule> list_rules() throws Error {
+            return yield db.query<Rule>()
+                .order_by(expr("name"))
+                .materialise_async();
+        }
+
+        /**
+         * Saves the editable fields of the rule identified by `id`; null
+         * or empty arguments keep their current values. Returns null when
+         * the rule does not exist.
+         */
+        public async Rule? update_rule(int64 id, string? name, string? body) throws Error {
+            var rule = yield get_rule(id);
+            if (rule == null) {
+                return null;
+            }
+            if (name != null && name.strip().length > 0) {
+                var clash = yield db.query<Rule>()
+                    .where(expr("name == $0 && id != $1", elem(name.strip()), elem<int64?>(id)))
+                    .first_async();
+                if (clash != null) {
+                    throw new Error(Quark.from_string("clanker-saddle"), 32,
+                        @"A rule named '$(name)' already exists");
+                }
+                ((!)rule).name = name.strip();
+            }
+            if (body != null && body.length > 0) {
+                ((!)rule).body = body;
+            }
+            yield db.update_async<Rule>((!)rule);
+            return rule;
+        }
+
+        /**
+         * Deletes a rule and every attachment referencing it (task and
+         * project links go with it).
+         */
+        public async void delete_rule(int64 id) throws Error {
+            foreach (var link in yield db.query<TaskRule>()
+                    .where(expr("rule_id == $0", elem<int64?>(id)))
+                    .materialise_async()) {
+                yield db.delete_async<TaskRule>(link);
+            }
+            foreach (var link in yield db.query<RuleProject>()
+                    .where(expr("rule_id == $0", elem<int64?>(id)))
+                    .materialise_async()) {
+                yield db.delete_async<RuleProject>(link);
+            }
+            var rule = yield get_rule(id);
+            if (rule != null) {
+                yield db.delete_async<Rule>((!)rule);
+            }
+        }
+
+        /**
+         * Attaches `rule_id` to `project_id`; duplicates are rejected
+         * (the pair is unique).
+         */
+        public async void attach_rule_to_project(int64 rule_id, int64 project_id) throws Error {
+            var rule = yield get_rule(rule_id);
+            if (rule == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 33,
+                    @"No rule exists with id $(rule_id)");
+            }
+            var existing = yield db.query<RuleProject>()
+                .where(expr("rule_id == $0 && project_id == $1",
+                    elem<int64?>(rule_id), elem<int64?>(project_id)))
+                .materialise_async();
+            if (existing.length > 0) {
+                return;
+            }
+            yield db.insert_async<RuleProject>(new RuleProject() {
+                rule_id = rule_id,
+                project_id = project_id
+            });
+        }
+
+        /** Detaches the `rule_projects` row identified by `id`. */
+        public async void detach_rule_from_project(int64 link_id) throws Error {
+            var link = yield db.query<RuleProject>()
+                .where(expr("id == $0", elem<int64?>(link_id)))
+                .first_async();
+            if (link != null) {
+                yield db.delete_async<RuleProject>((!)link);
+            }
+        }
+
+        /** The `rule_projects` attachments of `project_id`, oldest first. */
+        public async ImmutableLot<RuleProject> list_project_attachments(int64 project_id) throws Error {
+            return yield db.query<RuleProject>()
+                .where(expr("project_id == $0", elem<int64?>(project_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+        }
+
+        /** The rules attached to `project_id`, attachment order. */
+        public async ImmutableLot<Rule> list_rules_for_project(int64 project_id) throws Error {
+            var rows = new Vector<Rule>();
+            foreach (var link in yield list_project_attachments(project_id)) {
+                var rule = yield get_rule(link.rule_id);
+                if (rule != null) {
+                    rows.add((!)rule);
+                }
+            }
+            return rows.to_immutable_buffer();
+        }
+
+        /**
+         * Attaches `rule_id` to `task_id`; duplicates are rejected (the
+         * pair is unique).
+         */
+        public async void attach_rule_to_task(int64 rule_id, int64 task_id) throws Error {
+            var rule = yield get_rule(rule_id);
+            if (rule == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 33,
+                    @"No rule exists with id $(rule_id)");
+            }
+            var existing = yield db.query<TaskRule>()
+                .where(expr("rule_id == $0 && task_id == $1",
+                    elem<int64?>(rule_id), elem<int64?>(task_id)))
+                .materialise_async();
+            if (existing.length > 0) {
+                return;
+            }
+            yield db.insert_async<TaskRule>(new TaskRule() {
+                rule_id = rule_id,
+                task_id = task_id
+            });
+        }
+
+        /** Detaches the `task_rules` row identified by `id`. */
+        public async void detach_rule_from_task(int64 link_id) throws Error {
+            var link = yield db.query<TaskRule>()
+                .where(expr("id == $0", elem<int64?>(link_id)))
+                .first_async();
+            if (link != null) {
+                yield db.delete_async<TaskRule>((!)link);
+            }
+        }
+
+        /** The `task_rules` attachments of `task_id`, oldest first. */
+        public async ImmutableLot<TaskRule> list_task_attachments(int64 task_id) throws Error {
+            return yield db.query<TaskRule>()
+                .where(expr("task_id == $0", elem<int64?>(task_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+        }
+
+        /** The rules attached to `task_id`, attachment order. */
+        public async ImmutableLot<Rule> list_rules_for_task(int64 task_id) throws Error {
+            var rows = new Vector<Rule>();
+            foreach (var link in yield list_task_attachments(task_id)) {
+                var rule = yield get_rule(link.rule_id);
+                if (rule != null) {
+                    rows.add((!)rule);
+                }
+            }
+            return rows.to_immutable_buffer();
+        }
+
+        /**
+         * The effective rule set for a dispatched task, in the wire
+         * contract's stable order: the task's own rules, then the primary
+         * project's, then the co-developed projects' in link order —
+         * deduped by id (reference projects contribute nothing).
+         */
+        public async ImmutableLot<Rule> effective_rules(TaskEntity task,
+                ProjectService projects) throws Error {
+            var ordered = new Vector<Rule>();
+            // Ids are stringified: the Invercargill collections cannot
+            // key (or hash) on value types.
+            var seen = new HashSet<string>();
+
+            foreach (var rule in yield list_rules_for_task(task.id)) {
+                if (seen.add(rule.id.to_string())) {
+                    ordered.add(rule);
+                }
+            }
+            foreach (var rule in yield list_rules_for_project(task.project_id)) {
+                if (seen.add(rule.id.to_string())) {
+                    ordered.add(rule);
+                }
+            }
+            foreach (var link in yield projects.list_links(task.project_id)) {
+                if (link.relationship != LINK_CO_DEVELOPED) {
+                    continue;
+                }
+                foreach (var rule in yield list_rules_for_project(link.linked_project_id)) {
+                    if (seen.add(rule.id.to_string())) {
+                        ordered.add(rule);
+                    }
+                }
+            }
+            return ordered.to_immutable_buffer();
+        }
+    }
+}

+ 27 - 9
src/Services/SaddleService.vala

@@ -67,6 +67,7 @@ namespace ClankerSaddle.Services {
         private Soup.Session http = new Soup.Session();
         private SaddlePollState poll_state = inject<SaddlePollState>();
         private StatePulse pulse = inject<StatePulse>();
+        private RuleService rules = inject<RuleService>();
 
         /**
          * Registers a saddle container and generates its API token.
@@ -247,15 +248,17 @@ namespace ClankerSaddle.Services {
 
         /**
          * Pushes a task assignment to the saddle daemon's `POST /start`
-         * endpoint in its wire shape — `{task, llm_config}` — with the
-         * project, co-developed/reference links, branch fields, plan and
-         * recent comments inside the task object, plus any block answers
-         * not yet handed over (a resumed workspace picks its pending
-         * answers up from the assignment itself). Project checkouts are
-         * addressed by the `SADDLE_PROJECTS_ROOT` convention
-         * (`root/task-<task id>/name`) and cloned from each project's
-         * `git_url`; the LLM endpoint by the {@link LlmEndpoint}
-         * environment. Returns the parsed JSON response body.
+         * endpoint in its wire shape — `{task, rules, llm_config}` — with
+         * the project, co-developed/reference links, branch fields, plan
+         * and recent comments inside the task object, plus any block
+         * answers not yet handed over (a resumed workspace picks its
+         * pending answers up from the assignment itself) and the
+         * effective rule set ({@link RuleService.effective_rules}) as the
+         * top-level `rules` array. Project checkouts are addressed by the
+         * `SADDLE_PROJECTS_ROOT` convention (`root/task-<task id>/name`)
+         * and cloned from each project's `git_url`; the LLM endpoint by
+         * the {@link LlmEndpoint} environment. Returns the parsed JSON
+         * response body.
          */
         public async Json.Object push_task_to_saddle(int64 saddle_id, TaskEntity task, int64 session_id,
                 ProjectService projects) throws Error {
@@ -364,6 +367,21 @@ namespace ClankerSaddle.Services {
             payload.end_array();
             payload.end_object();
 
+            // The wire contract's top-level `rules` addition: the
+            // effective rule set (task ∪ primary ∪ co-developed, deduped,
+            // stable order — see RuleService.effective_rules).
+            payload.set_member_name("rules");
+            payload.begin_array();
+            foreach (var rule in yield rules.effective_rules(task, projects)) {
+                payload.begin_object();
+                payload.set_member_name("name");
+                payload.add_string_value(rule.name);
+                payload.set_member_name("body");
+                payload.add_string_value(rule.body);
+                payload.end_object();
+            }
+            payload.end_array();
+
             payload.set_member_name("llm_config");
             payload.begin_object();
             payload.set_member_name("base_url");

+ 250 - 0
src/actions/RuleActions.vala

@@ -0,0 +1,250 @@
+using Inversion;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** Sealed rule id carried by the rule row actions. */
+    public class RuleActionPrivate : Object {
+        public int64 rule_id { get; set; }
+    }
+
+    /** Sealed project-rule attachment identity. */
+    public class ProjectRuleActionPrivate : Object {
+        public int64 project_id { get; set; }
+        public int64 link_id { get; set; }
+    }
+
+    /** Sealed task-rule attachment identity. */
+    public class TaskRuleActionPrivate : Object {
+        public int64 task_id { get; set; }
+        public int64 link_id { get; set; }
+    }
+
+    /**
+     * Creates a rule from the rules page form (name and body required;
+     * names are unique).
+     */
+    public class CreateRuleAction : StatumAction {
+
+        private RuleService rules = inject<RuleService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            string name = Forms.field(request, "name");
+            string body = Forms.field_raw(request, "body");
+            if (name.length == 0 || body.strip().length == 0) {
+                return directives().notify("error", "A rule name and body are required").navigate("/rules");
+            }
+
+            try {
+                var rule = yield rules.create_rule(name, body);
+                return directives().notify("success", @"Rule '$(rule.name)' created")
+                    .navigate("/rules");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not create the rule: $(e.message)")
+                    .navigate("/rules");
+            }
+        }
+    }
+
+    /**
+     * Saves the editable fields of a rule; the id comes from the sealed
+     * {@link RuleActionPrivate} blob (the hidden form field is a
+     * fallback).
+     */
+    public class UpdateRuleAction : TypedStatumAction<RuleActionPrivate> {
+
+        private RuleService rules = inject<RuleService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(RuleActionPrivate); } }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            int64 rule_id = ctx != null ? ((!)ctx).rule_id : Forms.integer(request, "rule_id", 0);
+            if (rule_id <= 0) {
+                return directives().navigate("/rules");
+            }
+
+            try {
+                yield rules.update_rule(rule_id,
+                    Forms.optional(request, "name"),
+                    request.form != null ? Forms.field_raw(request, "body") : null);
+                return directives().notify("success", "Rule saved").navigate("/rules");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not save the rule: $(e.message)")
+                    .navigate("/rules");
+            }
+        }
+    }
+
+    /** Deletes a rule (and its attachments); the id is sealed. */
+    public class DeleteRuleAction : TypedStatumAction<RuleActionPrivate> {
+
+        private RuleService rules = inject<RuleService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(RuleActionPrivate); } }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            if (ctx == null) {
+                return directives().navigate("/rules");
+            }
+
+            try {
+                yield rules.delete_rule(((!)ctx).rule_id);
+                return directives().notify("success", "Rule deleted").navigate("/rules");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not delete the rule: $(e.message)")
+                    .navigate("/rules");
+            }
+        }
+    }
+
+    /**
+     * Attaches the form-selected rule to the sealed project; the project
+     * page's rules section.
+     */
+    public class AttachProjectRuleAction : TypedStatumAction<ProjectActionPrivate> {
+
+        private RuleService rules = inject<RuleService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(ProjectActionPrivate); } }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            int64 project_id = ctx != null ? ((!)ctx).project_id : Forms.integer(request, "project_id", 0);
+            int64 rule_id = Forms.integer(request, "rule_id", 0);
+            if (project_id <= 0 || rule_id <= 0) {
+                return directives().notify("error", "Pick a rule to attach")
+                    .navigate(@"/projects/$(project_id)");
+            }
+
+            try {
+                yield rules.attach_rule_to_project(rule_id, project_id);
+                return directives().notify("success", "Rule attached")
+                    .navigate(@"/projects/$(project_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not attach the rule: $(e.message)")
+                    .navigate(@"/projects/$(project_id)");
+            }
+        }
+    }
+
+    /** Detaches a project-rule attachment; both ids are sealed. */
+    public class DetachProjectRuleAction : TypedStatumAction<ProjectRuleActionPrivate> {
+
+        private RuleService rules = inject<RuleService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(ProjectRuleActionPrivate); } }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            if (ctx == null) {
+                return directives().navigate("/projects");
+            }
+
+            try {
+                yield rules.detach_rule_from_project(((!)ctx).link_id);
+                return directives().notify("success", "Rule detached")
+                    .navigate(@"/projects/$(((!)ctx).project_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not detach the rule: $(e.message)")
+                    .navigate(@"/projects/$(((!)ctx).project_id)");
+            }
+        }
+    }
+
+    /**
+     * Attaches the form-selected rule to the sealed task; the task page's
+     * rules section.
+     */
+    public class AttachTaskRuleAction : SealedTaskAction {
+
+        private RuleService rule_service = inject<RuleService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 task_id = sealed_task_id();
+            int64 rule_id = Forms.integer(request, "rule_id", 0);
+            if (task_id <= 0 || rule_id <= 0) {
+                return directives().notify("error", "Pick a rule to attach")
+                    .navigate(@"/tasks/$(task_id)");
+            }
+
+            try {
+                yield rule_service.attach_rule_to_task(rule_id, task_id);
+                return directives().notify("success", "Rule attached").navigate(@"/tasks/$(task_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not attach the rule: $(e.message)")
+                    .navigate(@"/tasks/$(task_id)");
+            }
+        }
+    }
+
+    /** Detaches a task-rule attachment; both ids are sealed. */
+    public class DetachTaskRuleAction : TypedStatumAction<TaskRuleActionPrivate> {
+
+        private RuleService rules = inject<RuleService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(TaskRuleActionPrivate); } }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            if (ctx == null) {
+                return directives().navigate("/tasks");
+            }
+
+            try {
+                yield rules.detach_rule_from_task(((!)ctx).link_id);
+                return directives().notify("success", "Rule detached")
+                    .navigate(@"/tasks/$(((!)ctx).task_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not detach the rule: $(e.message)")
+                    .navigate(@"/tasks/$(((!)ctx).task_id)");
+            }
+        }
+    }
+}

+ 2 - 1
src/actions/TaskActions.vala

@@ -374,6 +374,7 @@ namespace ClankerSaddle {
         private TaskService task_service = inject<TaskService>();
         private ProjectService project_service = inject<ProjectService>();
         private SaddleService saddle_service = inject<SaddleService>();
+        private RuleService rule_service = inject<RuleService>();
         private UserService users = inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -388,7 +389,7 @@ namespace ClankerSaddle {
             }
 
             var state = yield TaskDetailState.build(state_service, task_service,
-                project_service, saddle_service, action_registry, task_id);
+                project_service, saddle_service, rule_service, action_registry, task_id);
             return directives().set(state);
         }
     }

+ 51 - 3
src/entrypoints/ProjectsEntrypoint.vala

@@ -98,6 +98,21 @@ namespace ClankerSaddle {
         public bool is_design_task { get; set; }
     }
 
+    /** A rule attached to the project, with its detach action. */
+    public class ProjectRuleRowView : Object {
+        public int64 link_id { get; set; }
+        public int64 rule_id { get; set; }
+        public string name { get; set; default = ""; }
+        public string body { get; set; default = ""; }
+        public Statum.Model.ActionDto? detach { get; set; }
+    }
+
+    /** A rule option in the attach selects (project and task pages). */
+    public class RuleOptionView : Object {
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+    }
+
     /** Public data of the `project` slot. */
     public class ProjectDetailPublic : Object {
         public bool exists { get; set; }
@@ -109,17 +124,20 @@ namespace ClankerSaddle {
         public string created_display { get; set; default = ""; }
         public Statum.Model.ActionDto? update { get; set; }
         public Statum.Model.ActionDto? add_link { get; set; }
+        public Statum.Model.ActionDto? attach_rule { get; set; }
     }
 
     /**
      * Entrypoint for /projects/{project_id}: the project's editable card,
-     * its co-developed/reference links (with remove actions), the other
-     * projects available for linking, and its task list.
+     * its co-developed/reference links (with remove actions), its rules
+     * (with detach actions and the attach form), the other projects
+     * available for linking, and its task list.
      */
     public class ProjectDetailEntrypoint : StatumEntrypoint {
 
         private ProjectService project_service = Inversion.inject<ProjectService>();
         private TaskService task_service = Inversion.inject<TaskService>();
+        private RuleService rule_service = Inversion.inject<RuleService>();
         private UserService users = Inversion.inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -146,7 +164,8 @@ namespace ClankerSaddle {
                 created_display = ((!)project).created_at != null
                     ? ((!)((!)project).created_at).format("%Y-%m-%d %H:%M") : "",
                 update = action_registry.author_private<UpdateProjectAction, ProjectActionPrivate>(sealed),
-                add_link = action_registry.author_private<AddProjectLinkAction, ProjectActionPrivate>(sealed)
+                add_link = action_registry.author_private<AddProjectLinkAction, ProjectActionPrivate>(sealed),
+                attach_rule = action_registry.author_private<AttachProjectRuleAction, ProjectActionPrivate>(sealed)
             };
 
             var links = new Vector<ProjectLinkView>();
@@ -194,10 +213,39 @@ namespace ClankerSaddle {
                 });
             }
 
+            var rule_rows = new Vector<ProjectRuleRowView>();
+            foreach (var link in yield rule_service.list_project_attachments(project_id)) {
+                var rule = yield rule_service.get_rule(link.rule_id);
+                if (rule == null) {
+                    continue;
+                }
+                var private_blob = new ProjectRuleActionPrivate() {
+                    project_id = project_id,
+                    link_id = link.id
+                };
+                rule_rows.add(new ProjectRuleRowView() {
+                    link_id = link.id,
+                    rule_id = link.rule_id,
+                    name = ((!)rule).name,
+                    body = ((!)rule).body,
+                    detach = action_registry.author_private<DetachProjectRuleAction, ProjectRuleActionPrivate>(private_blob)
+                });
+            }
+
+            var rule_options = new Vector<RuleOptionView>();
+            foreach (var rule in yield rule_service.list_rules()) {
+                rule_options.add(new RuleOptionView() {
+                    id = rule.id,
+                    name = rule.name
+                });
+            }
+
             var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
             props["links"] = StateJson.list<ProjectLinkView>(links.to_immutable_buffer());
             props["linkable"] = StateJson.list<ProjectRowView>(linkable.to_immutable_buffer());
             props["tasks"] = StateJson.list<ProjectTaskRowView>(task_rows.to_immutable_buffer());
+            props["rules"] = StateJson.list<ProjectRuleRowView>(rule_rows.to_immutable_buffer());
+            props["rule_options"] = StateJson.list<RuleOptionView>(rule_options.to_immutable_buffer());
 
             var slot = state_service.new_slot(Scope.PAGE, new State() {
                 type_name = "project",

+ 71 - 0
src/entrypoints/RulesEntrypoint.vala

@@ -0,0 +1,71 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** A rule row on the rules page, with its row actions. */
+    public class RuleRowView : Object {
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+        public string body { get; set; default = ""; }
+        public string created_display { get; set; default = ""; }
+        public Statum.Model.ActionDto? update { get; set; }
+        public Statum.Model.ActionDto? delete { get; set; }
+    }
+
+    /** Public data of the `rules` slot. */
+    public class RulesPublic : Object {
+        public Statum.Model.ActionDto create { get; set; }
+    }
+
+    /**
+     * Entrypoint for /rules: every rule with an inline edit form and a
+     * delete action, plus the create form. Rules are the reusable
+     * instructions dispatched to saddle agents (attached on the project
+     * and task pages).
+     */
+    public class RulesEntrypoint : StatumEntrypoint {
+
+        private RuleService rule_service = Inversion.inject<RuleService>();
+        private UserService users = Inversion.inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var rows = new Vector<RuleRowView>();
+            foreach (var rule in yield rule_service.list_rules()) {
+                var sealed = new RuleActionPrivate() { rule_id = rule.id };
+                rows.add(new RuleRowView() {
+                    id = rule.id,
+                    name = rule.name,
+                    body = rule.body,
+                    created_display = rule.created_at != null
+                        ? ((!)rule.created_at).format("%Y-%m-%d %H:%M") : "",
+                    update = action_registry.author_private<UpdateRuleAction, RuleActionPrivate>(sealed),
+                    delete = action_registry.author_private<DeleteRuleAction, RuleActionPrivate>(sealed)
+                });
+            }
+
+            var pub = new RulesPublic();
+            pub.create = action_registry.author<CreateRuleAction>();
+
+            var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
+            props["rules"] = StateJson.list<RuleRowView>(rows.to_immutable_buffer());
+
+            var slot = state_service.new_slot(Scope.PAGE, new State() {
+                type_name = "rules",
+                public_data = props,
+                private_data = new PropertyDictionary()
+            });
+            return directives().set(slot);
+        }
+    }
+}

+ 43 - 3
src/entrypoints/TaskDetailEntrypoint.vala

@@ -64,6 +64,15 @@ namespace ClankerSaddle {
         public string status { get; set; default = ""; }
     }
 
+    /** A rule attached to the task, with its detach action. */
+    public class TaskRuleRowView : Object {
+        public int64 link_id { get; set; }
+        public int64 rule_id { get; set; }
+        public string name { get; set; default = ""; }
+        public string body { get; set; default = ""; }
+        public Statum.Model.ActionDto? detach { get; set; }
+    }
+
     /** A session row in the task's history, linking to the terminal view. */
     public class SessionView : Object {
         public int64 id { get; set; }
@@ -106,6 +115,7 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? complete { get; set; }
         public Statum.Model.ActionDto? cancel { get; set; }
         public Statum.Model.ActionDto? refresh { get; set; }
+        public Statum.Model.ActionDto? attach_rule { get; set; }
     }
 
     /**
@@ -123,8 +133,8 @@ namespace ClankerSaddle {
          * is false when the task is missing.
          */
         public static async Slot build(StateService state_service, TaskService tasks,
-                ProjectService projects, SaddleService saddles, ActionRegistry actions,
-                int64 task_id) throws GLib.Error {
+                ProjectService projects, SaddleService saddles, RuleService rules,
+                ActionRegistry actions, int64 task_id) throws GLib.Error {
             var detail = yield tasks.get_task(task_id);
             if (detail == null) {
                 return slot_for(state_service, new TaskDetailPublic() { exists = false },
@@ -165,6 +175,7 @@ namespace ClankerSaddle {
             pub.complete = actions.author_private<CompleteTaskAction, TaskActionPrivate>(sealed);
             pub.cancel = actions.author_private<CancelTaskAction, TaskActionPrivate>(sealed);
             pub.refresh = actions.author_private<RefreshTaskDataAction, TaskActionPrivate>(sealed, "GET");
+            pub.attach_rule = actions.author_private<AttachTaskRuleAction, TaskActionPrivate>(sealed);
 
             var comments = new Vector<CommentView>();
             foreach (var comment in ((!)detail).comments) {
@@ -243,6 +254,32 @@ namespace ClankerSaddle {
                 }
             }
 
+            var task_rules = new Vector<TaskRuleRowView>();
+            foreach (var link in yield rules.list_task_attachments(task.id)) {
+                var rule = yield rules.get_rule(link.rule_id);
+                if (rule == null) {
+                    continue;
+                }
+                var rule_private = new TaskRuleActionPrivate() {
+                    task_id = task.id,
+                    link_id = link.id
+                };
+                task_rules.add(new TaskRuleRowView() {
+                    link_id = link.id,
+                    rule_id = link.rule_id,
+                    name = ((!)rule).name,
+                    body = ((!)rule).body,
+                    detach = actions.author_private<DetachTaskRuleAction, TaskRuleActionPrivate>(rule_private)
+                });
+            }
+            var rule_options = new Vector<RuleOptionView>();
+            foreach (var rule in yield rules.list_rules()) {
+                rule_options.add(new RuleOptionView() {
+                    id = rule.id,
+                    name = rule.name
+                });
+            }
+
             var sessions = new Vector<SessionView>();
             foreach (var session in ((!)detail).sessions) {
                 var saddle = yield saddles.get_saddle(session.saddle_id);
@@ -266,6 +303,8 @@ namespace ClankerSaddle {
             props["subtasks"] = StateJson.list<SubtaskView>(subtasks.to_immutable_buffer());
             props["dependencies"] = StateJson.list<DependencyView>(dependencies.to_immutable_buffer());
             props["candidates"] = StateJson.list<DependencyView>(candidates.to_immutable_buffer());
+            props["rules"] = StateJson.list<TaskRuleRowView>(task_rules.to_immutable_buffer());
+            props["rule_options"] = StateJson.list<RuleOptionView>(rule_options.to_immutable_buffer());
             props["sessions"] = StateJson.list<SessionView>(sessions.to_immutable_buffer());
             return slot_for(state_service, pub, props);
         }
@@ -333,6 +372,7 @@ namespace ClankerSaddle {
         private TaskService task_service = Inversion.inject<TaskService>();
         private ProjectService project_service = Inversion.inject<ProjectService>();
         private SaddleService saddle_service = Inversion.inject<SaddleService>();
+        private RuleService rule_service = Inversion.inject<RuleService>();
         private UserService users = Inversion.inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -343,7 +383,7 @@ namespace ClankerSaddle {
 
             int64 task_id = int64.parse(request.route("task_id") ?? "0");
             var state = yield TaskDetailState.build(state_service, task_service,
-                project_service, saddle_service, action_registry, task_id);
+                project_service, saddle_service, rule_service, action_registry, task_id);
             return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
         }
     }

+ 13 - 0
src/main.vala

@@ -28,6 +28,7 @@ int main(string[] args) {
         db.register_connection(@"sqlite://$(db_path)");
         db.add_migration<ClankerSaddle.Data.Migrations.M0001_Initial>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0002_Part2>();
+        db.add_migration<ClankerSaddle.Data.Migrations.M0003_Rules>();
         db.add_entity<Project>(Project.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);
@@ -39,11 +40,15 @@ int main(string[] args) {
         db.add_entity<Session>(Session.entity_mapping);
         db.add_entity<SessionCommand>(SessionCommand.entity_mapping);
         db.add_entity<ApiToken>(ApiToken.entity_mapping);
+        db.add_entity<Rule>(Rule.entity_mapping);
+        db.add_entity<RuleProject>(RuleProject.entity_mapping);
+        db.add_entity<TaskRule>(TaskRule.entity_mapping);
         db.migrate_on_startup();
 
         application.add_scoped<ProjectService>();
         application.add_scoped<TaskService>();
         application.add_scoped<SaddleService>();
+        application.add_scoped<RuleService>();
         application.add_singleton<SaddlePollState>();
         application.add_singleton<StatePulse>();
         application.add_singleton<Dispatcher>();
@@ -61,6 +66,7 @@ int main(string[] args) {
         statum.add_page<TaskDetailPage, TaskDetailEntrypoint>();
         statum.add_page<TaskTerminalPage, TaskTerminalEntrypoint>();
         statum.add_page<SaddlesPage, SaddlesEntrypoint>();
+        statum.add_page<RulesPage, RulesEntrypoint>();
         // spry:pages-end
 
         // spry:actions-begin
@@ -84,6 +90,13 @@ int main(string[] args) {
         statum.action<RegisterSaddleAction>();
         statum.action<UnregisterSaddleAction>();
         statum.action<RefreshSaddleStatusAction>();
+        statum.action<CreateRuleAction>();
+        statum.action<UpdateRuleAction>();
+        statum.action<DeleteRuleAction>();
+        statum.action<AttachProjectRuleAction>();
+        statum.action<DetachProjectRuleAction>();
+        statum.action<AttachTaskRuleAction>();
+        statum.action<DetachTaskRuleAction>();
         // spry:actions-end
 
         // spry:resources-begin

+ 1 - 0
src/pages/main.html

@@ -131,6 +131,7 @@
             <a href="/projects">Projects</a>
             <a href="/tasks">Tasks</a>
             <a href="/saddles">Saddles</a>
+            <a href="/rules">Rules</a>
             <!-- spry:nav-end -->
             <span stm-if="auth != null"><strong stm-text="auth.username">user</strong>
                 <button type="button" class="inline" stm-action="auth.logout">Log out</button>

+ 20 - 0
src/pages/project_detail.html

@@ -62,6 +62,26 @@
                 <button type="submit">Add link</button>
             </form>
 
+            <h3>Rules</h3>
+            <p class="muted">Attached rules ride along with every task dispatched for this project.</p>
+            <div stm-for-rule-in="project.rules" stm-key="rule.link_id" class="card">
+                <h3 stm-text="rule.name">name</h3>
+                <div class="md" stm-markdown="rule.body">body</div>
+                <form class="inline">
+                    <button type="button" stm-action="rule.detach" stm-confirm="Detach this rule?">Detach</button>
+                </form>
+            </div>
+            <p stm-if="project.rules.length == 0" class="muted">No rules attached.</p>
+            <form class="inline" stm-action="project.attach_rule" stm-if="project.rule_options.length > 0">
+                <label for="rule-target">Attach rule</label>
+                <select id="rule-target" name="rule_id" required>
+                    <option stm-for-option-in="project.rule_options" stm-attribute.value="option.id"
+                             stm-text="option.name">rule</option>
+                </select>
+                <button type="submit">Attach</button>
+            </form>
+            <p class="muted" stm-if="project.rule_options.length == 0">Create rules on the <a href="/rules">rules page</a>.</p>
+
             <h3>Tasks in this project</h3>
             <table>
                 <thead><tr><th>#</th><th>Title</th><th>Status</th><th>Priority</th></tr></thead>

+ 50 - 0
src/pages/rules.html

@@ -0,0 +1,50 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/rules</pstm-uri>
+    <pstm-template>main</pstm-template>
+</head>
+<body>
+    <section>
+        <h2>Rules</h2>
+        <p class="muted">Reusable instructions dispatched with every task that carries them —
+            attach rules on the project and task pages.</p>
+        <p stm-if="rules == null">Loading…</p>
+
+        <div stm-if="rules != null">
+            <details>
+                <summary><strong>New rule</strong></summary>
+                <form stm-action="rules.create">
+                    <div class="form-row"><label>Name</label>
+                        <input type="text" name="name" required placeholder="Use Invercargill"></div>
+                    <div class="form-row"><label>Body</label>
+                        <textarea name="body" rows="3" required
+                                  placeholder="Never use libgee; use Invercargill enumerables."></textarea></div>
+                    <button type="submit">Create rule</button>
+                </form>
+            </details>
+
+            <div stm-for-rule-in="rules.rules" stm-key="rule.id" class="card">
+                <h3 stm-text="rule.name">name</h3>
+                <div class="md" stm-markdown="rule.body">body</div>
+                <p class="muted">Created <span stm-text="rule.created_display">when</span></p>
+                <details>
+                    <summary>Edit</summary>
+                    <form stm-action="rule.update">
+                        <input type="hidden" name="rule_id" stm-attribute.value="rule.id">
+                        <div class="form-row"><label>Name</label>
+                            <input type="text" name="name" stm-attribute.value="rule.name"></div>
+                        <div class="form-row"><label>Body</label>
+                            <textarea name="body" rows="4" stm-attribute.value="rule.body"></textarea></div>
+                        <button type="submit">Save</button>
+                    </form>
+                </details>
+                <form class="inline">
+                    <button type="button" stm-action="rule.delete" stm-confirm="Delete this rule everywhere?">Delete</button>
+                </form>
+            </div>
+            <p stm-if="rules.rules.length == 0" class="muted">No rules yet.</p>
+        </div>
+    </section>
+</body>
+</html>

+ 19 - 0
src/pages/task_detail.html

@@ -437,6 +437,25 @@
                 <button type="submit">Add dependency</button>
             </form>
 
+            <h3>Rules</h3>
+            <p class="muted">Task rules lead the effective set dispatched with this task.</p>
+            <div stm-for-rule-in="task.rules" stm-key="rule.link_id" class="card">
+                <h3 stm-text="rule.name">name</h3>
+                <div class="md" stm-markdown="rule.body">body</div>
+                <form class="inline">
+                    <button type="button" stm-action="rule.detach" stm-confirm="Detach this rule?">Detach</button>
+                </form>
+            </div>
+            <p stm-if="task.rules.length == 0" class="muted">No task-specific rules.</p>
+            <form class="inline" stm-action="task.attach_rule" stm-if="task.rule_options.length > 0">
+                <label for="task-rule-target">Attach rule</label>
+                <select id="task-rule-target" name="rule_id" required>
+                    <option stm-for-option-in="task.rule_options" stm-attribute.value="option.id"
+                             stm-text="option.name">rule</option>
+                </select>
+                <button type="submit">Attach</button>
+            </form>
+
             <h3>Sessions</h3>
             <table>
                 <thead><tr><th>#</th><th>Saddle</th><th>Started</th><th>Ended</th><th>Outcome</th><th></th></tr></thead>

BIN
test/__pycache__/web_stack_test.cpython-314.pyc


+ 91 - 0
test/web_stack_test.py

@@ -146,6 +146,11 @@ def task_state(auth, task_id):
     return slot_public(auth, "/tasks/%d" % task_id, "task")
 
 
+def wait_task_done(auth, task_id, timeout=90):
+    return wait_for(lambda: task_state(auth, task_id).get("status") == "done" or False,
+                    timeout=timeout, what="task %d -> done" % task_id)
+
+
 def saddle_api(method, path, token, payload=None):
     """Raw call against the saddle ingest API; returns (status, json)."""
     request = urllib.request.Request(WEB + path,
@@ -194,6 +199,7 @@ class FakeSaddle:
 
     class Handler(BaseHTTPRequestHandler):
         daemon = None  # set on the class before serving
+        protocol_version = "HTTP/1.1"
 
         def log_message(self, fmt, *args):
             pass
@@ -246,6 +252,7 @@ class FakeSaddle:
                 self._send(200, {"chunks": chunks, "next": nxt})
             else:
                 self._send(404, {"error": "no such route"})
+                self.close_connection = True
 
     def serve(self):
         handler = type("BoundHandler", (FakeSaddle.Handler,), {"daemon": self})
@@ -359,6 +366,90 @@ def main():
         rfc = [b for b in detail["blocks"] if b["block_type"] == "rfc"][0]
         check(rfc.get("show_body") is True,
               "answered plan review shows the body again (review section gone)")
+
+        print("== feature 5: rules ==")
+        rules_state = slot_public(auth, "/rules", "rules")
+        for name, body in [
+            ("Use Invercargill", "Never use libgee; use Invercargill enumerables."),
+            ("Write Vala docs", "Every public class carries a doc comment."),
+            ("Prefer meson", "Builds go through meson; no makefiles."),
+            ("Co-dev rule", "Keep the wire contract exactly."),
+            ("Reference rule", "Must never ride along."),
+        ]:
+            act(auth, rules_state["create"], {"name": name, "body": body})
+        listed = {r["name"]: r for r in slot_public(auth, "/rules", "rules")["rules"]}
+        check(len(listed) == 5, "five rules created and listed on /rules")
+
+        def rule_id(name):
+            rows = [r for r in slot_public(auth, "/rules", "rules")["rules"] if r["name"] == name]
+            return int(rows[0]["id"])
+
+        state = slot_public(auth, "/projects", "projects")
+        act(auth, state["create"], {"name": "co", "git_url": "https://example.com/co.git"})
+        act(auth, state["create"], {"name": "ref", "git_url": "https://example.com/ref.git"})
+        co_id = int([p["id"] for p in slot_public(auth, "/projects", "projects")["projects"]
+                     if p["name"] == "co"][0])
+        ref_id = int([p["id"] for p in slot_public(auth, "/projects", "projects")["projects"]
+                      if p["name"] == "ref"][0])
+
+        project = slot_public(auth, "/projects/1", "project")
+        act(auth, project["add_link"], {"linked_project_id": str(co_id),
+                                        "relationship": "co-developed"})
+        act(auth, project["add_link"], {"linked_project_id": str(ref_id),
+                                        "relationship": "reference"})
+        act(auth, project["attach_rule"], {"rule_id": str(rule_id("Use Invercargill"))})
+        act(auth, project["attach_rule"], {"rule_id": str(rule_id("Prefer meson"))})
+        project = slot_public(auth, "/projects/1", "project")
+        check(sorted(r["name"] for r in project["rules"]) == ["Prefer meson", "Use Invercargill"],
+              "project page lists its attached rules")
+
+        co_project = slot_public(auth, "/projects/%d" % co_id, "project")
+        act(auth, co_project["attach_rule"], {"rule_id": str(rule_id("Co-dev rule"))})
+        ref_project = slot_public(auth, "/projects/%d" % ref_id, "project")
+        act(auth, ref_project["attach_rule"], {"rule_id": str(rule_id("Reference rule"))})
+
+        tasks_state = slot_public(auth, "/tasks", "tasks")
+        act(auth, tasks_state["create"], {
+            "project_id": "1",
+            "title": "Rules dispatch",
+            "description": "Effective rule set rides the /start payload.",
+            "priority": "2",
+        })
+        rule_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+                         if t["title"] == "Rules dispatch"][0])
+        detail = task_state(auth, rule_task)
+        act(auth, detail["attach_rule"], {"rule_id": str(rule_id("Write Vala docs"))})
+        act(auth, detail["attach_rule"], {"rule_id": str(rule_id("Use Invercargill"))})
+        detail = task_state(auth, rule_task)
+        check(sorted(r["name"] for r in detail["rules"]) == ["Use Invercargill", "Write Vala docs"],
+              "task page lists its attached rules")
+
+        check(wait_for(lambda: len(fake.start_payloads) > 0 or False,
+                       timeout=30, what="/start push") is not None,
+              "dispatcher pushes the task to the fake saddle")
+        payload = fake.start_payloads[-1]
+        rule_names = [r["name"] for r in payload.get("rules", [])]
+        check(rule_names == ["Write Vala docs", "Use Invercargill", "Prefer meson", "Co-dev rule"],
+              "effective rules in /start: task, primary, co-developed order (%s)" % rule_names)
+        check(all(r.get("body") for r in payload.get("rules", [])),
+              "rules carry their bodies")
+        check("Reference rule" not in rule_names,
+              "reference project rules excluded")
+
+        detail = task_state(auth, rule_task)
+        act(auth, detail["rules"][0]["detach"], {})
+        check([r["name"] for r in task_state(auth, rule_task)["rules"]] == ["Use Invercargill"],
+              "detached task rule disappears")
+
+        rules_rows = slot_public(auth, "/rules", "rules")["rules"]
+        docs_row = [r for r in rules_rows if r["name"] == "Write Vala docs"][0]
+        act(auth, docs_row["update"], {"name": "Write Vala docs", "body": "Doc comments or bust."})
+        check(any(r["body"] == "Doc comments or bust."
+                  for r in slot_public(auth, "/rules", "rules")["rules"]),
+              "rule edit saves the body")
+
+        check(wait_task_done(auth, rule_task, timeout=60) is not None,
+              "dispatched task completes against the fake daemon")
     finally:
         app.terminate()
         try: